mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat: extract facts during sync (real-time hot memory)
Wire facts extraction into the sync pipeline so pages imported via
git get facts extracted immediately, not only through MCP put_page.
Changes:
- Add notability field (high/medium/low) to facts extraction schema
- Upgrade default extraction model from Haiku to Sonnet (configurable
via facts.extraction_model brain_config)
- Add notability-gated facts extraction to sync post-import hook:
- Only HIGH notability facts inserted during sync (life events,
major commitments, relationship/health changes)
- MEDIUM facts deferred to dream cycle
- LOW facts (logistical noise) dropped entirely
- Add notability column to facts table DDL
- Pass engine to extraction for config-aware model selection
Before: facts only extracted via MCP put_page (never during git sync)
After: meetings, conversations, personal pages get facts extracted
immediately on sync, with salience filtering
Closes the hot-memory gap where brain content committed via git was
invisible to the facts table until manually processed.
* fix: B1 — pass notability through facts JSON parser
Pre-fix, src/core/facts/extract.ts:tryArrayShape silently dropped the
LLM's notability field on the floor: the function copied fact/kind/
entity/confidence into the output but never read o.notability. The
outer loop in extractFactsFromTurn then read candidate.notability,
found undefined, and defaulted to 'medium'. sync.ts's HIGH-only filter
(`if (f.notability !== 'high') continue`) discarded 100% of facts.
Net: real-time facts on sync was a no-op despite Sonnet running and
costing money. Headline feature was dead on the happy path.
Fix is a one-line change in tryArrayShape. Two layers of test pin it:
1. Parser-pin (test/facts-extract.test.ts +75 LOC, 5 cases):
- notability passes through when LLM emits it
- notability omitted defaults to undefined (legacy compat)
- non-string notability is dropped defensively
- every documented field survives the parse (future field-drop guard)
- fenced JSON output (markdown code blocks) still threads correctly
2. End-to-end smoke (test/facts-extract-smoke.test.ts NEW, 145 LOC,
4 cases): drives extractFactsFromTurn with a stubbed gateway chat
transport. Asserts HIGH input → notability:'high' all the way out.
Guards against future prompt drift where Sonnet returns 'medium'
for everything; smoke fails loudly so the eval-mining flow gets
triggered.
Adds the chat test seam to enable the smoke test:
src/core/ai/gateway.ts: __setChatTransportForTests(fn) mirrors
v0.28.7's __setEmbedTransportForTests pattern. When set, chat()
routes through the stub; isAvailable('chat') returns true so tests
don't need full gateway configuration. resetGateway() clears it.
Test files stay regular .test.ts (parallel-safe; no mock.module).
PR 1 commit 1 of 15. See ~/.claude/plans/swift-gliding-key.md for the
full eng review and bisect-friendly commit ordering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: B2 — migration v46 ALTER facts.notability with idempotent CHECK
Pre-fix, the v0.31.1 PR shipped a CREATE TABLE edit to migration v45 that
added `notability NOT NULL DEFAULT 'medium' CHECK (notability IN (...))`
inline. Fresh installs got the column. But every brain that already ran
v45 BEFORE that edit (i.e., everyone running v0.31.0+ in production) keeps
the old facts table shape. INSERT now crashes with:
column "notability" of relation "facts" does not exist
This is the canonical "embedded schema mutation breaks upgrades" trap that
CLAUDE.md cites: "bit users 10+ times across 6 schema versions over 2 years."
Fix: new migration v46 ALTER. Idempotent under all four states:
1. Fresh install (v45 already added column inline)
→ ADD COLUMN IF NOT EXISTS no-ops; named CHECK probe finds existing
constraint → skip. Postgres emits a NOTICE; no error.
2. Old brain pre-edit (no column)
→ ADD COLUMN adds it with NOT NULL DEFAULT 'medium'; named CHECK
probe finds nothing → adds the constraint.
3. Partial state (column exists, CHECK missing)
→ ADD COLUMN no-ops; CHECK probe adds the named constraint.
4. Re-run after success
→ all probes skip; no error, no state change.
Implementation notes:
- CHECK constraint is named `facts_notability_check` (not autogen) so the
information_schema-equivalent probe via `pg_constraint` can find it
deterministically.
- Column-level CHECK in v45 inline (autogen-named) and the named CHECK
here are additive and non-conflicting — Postgres allows multiple CHECKs
covering the same predicate. Codex flagged this concern; the named
constraint addresses it cleanly.
- Both engines run the same SQL. PGLite is real Postgres in WASM and
supports DO $$ blocks. PGLite users with persistent older brains hit
the same bug.
E2E coverage (test/e2e/migration-v46-notability.test.ts, 5 cases):
- fresh-install fully-migrated: column + named CHECK both exist
- old brain (column dropped): v46 adds both back
- partial state (column exists, CHECK missing): v46 adds CHECK
- idempotent re-run on fully-migrated: no error, state unchanged
- CHECK constraint actually rejects out-of-domain values
Verified against real Postgres (pgvector/pgvector:pg16): 5/5 pass in 696ms.
PR 1 commit 2 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: B3 — restore v0_31_0 orchestrator gate to v < 45
Pre-fix, the v0_31_0 orchestrator's phaseASchema gate had been demoted
from `v < 45` to `v < 40` with an operator-facing message claiming
"v40 (facts hot memory + notability)". Facts is at v45, not v40 — the
message was wrong and the gate was permissive.
Symptom: brains at schema_version 40-44 (real states for users mid-
upgrade) passed the precondition, then immediately crashed on the
post-condition check three lines later (`SELECT FROM pg_tables WHERE
tablename = 'facts'`). Operator saw a green light, then a red light.
Fix: restore the gate to `v < 45` (the real semantic precondition:
the facts table is created by migration v45). Drop the misleading
"+ notability" claim — column shape is enforced by migration v46
alone (see MIGRATIONS[v46]), not gated here. Add a one-line comment
pointing at v46 so the next reader sees the separation.
Test coverage (test/migration-orchestrator-v0_31_0.test.ts NEW, 4 cases):
- schema_version < 45 fails with operator-facing message naming v45
+ recovery command. Negative assertions guard against regression
to the "v >= 40" / "+ notability" prior text.
- schema_version >= 45 with facts table present → status complete.
- dryRun short-circuits before any DB read.
- null engine short-circuits with no_brain_configured.
Verified: 4/4 pass; v45 + v46 both apply cleanly during test setup.
PR 1 commit 3 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: widen FactRow to expose notability across all readers
Codex's outside-voice pass on the cathedral plan flagged P1 #4: the read-
side contract was behind the write-side schema. notability lived in DDL
and the insertFact INSERT, but FactRow type omitted it and both row
mappers (pglite-engine + postgres-engine) silently dropped the column.
Every consumer above the engine (recall op, MCP _meta hook, CLI JSON
output) returned facts without their salience tier. PR2/PR3 surfaces
that need to filter or display notability would have required contract
surgery first; this lands the contract widening as the foundation.
Changes:
- src/core/engine.ts: add `notability: 'high' | 'medium' | 'low'` to
FactRow with doc comment naming the row source (column added by
migration v46) and the consumers (recall, daily-page, admin, MCP).
- src/core/postgres-engine.ts: FactRowSqlShape gains notability;
rowToFactPg propagates it with `?? 'medium'` belt-and-suspenders
fallback (NOT NULL DEFAULT in DDL is the primary; this is the
second line for any pre-v46 row that survives a SELECT).
- src/core/pglite-engine.ts: same pair (interface + mapper).
- src/core/operations.ts: recall op response shape adds notability.
- src/core/facts/meta-hook.ts: `_meta.brain_hot_memory` payload
surfaces notability so connected agents can filter or weight
HIGH-tier facts in their context budget.
- src/commands/recall.ts: `--json` output adds notability.
Test contract pin (test/facts-engine.test.ts):
- Existing 'inserts a fact' case asserts default 'medium' on the
read side (caller-omits-notability path).
- New 'notability round-trips for each tier' case inserts HIGH /
MEDIUM / LOW explicitly and reads back the same tier — without
this assertion, codex P1 #4 reappears silently.
Test fixtures (facts-classify.test.ts + facts-decay.test.ts) also
updated: makeFact() factories now construct complete FactRow objects
with notability:'medium' to match the tightened type.
PR 1 commit 4 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: move isFactsBackstopEligible to src/core/facts/eligibility.ts
Single source of truth for "should this page write fire the facts
extraction backstop?" Pre-extraction, lived inline at operations.ts:633
where only put_page could see it; sync.ts had its own divergent type
filter (`['conversation', 'transcript', 'personal', 'therapy', 'call']`
— only `meeting` was a real PageType, the rest never matched). Sync's
filter is deleted in commit 7; everyone routes through this predicate.
Adds the slug-prefix rescue branch the eng review pinned (D-eligibility):
parsed.type ∈ ELIGIBLE_TYPES OR slug.startsWith('meetings/' | 'personal/'
| 'daily/'). The rescue catches `meetings/2026-05-09-foo.md` pages that
frontmatter-typed themselves as 'note' (the legacy default) — directory
location wins.
Test pin (test/facts-eligibility.test.ts NEW, 28 cases):
- 4 BRANCH cases: typed-only, slug-only (each prefix), both, neither
- 7 GUARD cases: null/undefined parsed, wiki/agents/, dream_generated,
body length thresholds (< 80, exactly 80, whitespace-only)
- 14 COVERAGE cases: every eligible PageType on arbitrary slug → ok;
every non-eligible PageType on non-rescued slug → kind:<type> reason
Pure-function tests; no DB. The full predicate covered without spinning
a brain.
Existing test/facts-backstop-gating.test.ts still passes (it tests the
predicate via put_page; the move is transparent to that surface).
PR 1 commit 5 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add runFactsBackstop helper with full extract→resolve→dedup→insert pipeline
Single shared facts pipeline used by every brain write surface that
wants real-time hot memory extraction. Replaces five divergent
implementations:
- put_page MCP backstop hook (operations.ts:556)
- extract_facts MCP op (operations.ts:2438-2486)
- sync.ts post-import block (deleted in commit 7)
- file_upload + code_import (wired in commit 10)
Encapsulates the v0.31 smart pipeline:
extract → resolve → dedup (cosine @ 0.95) → insert
(matches extract_facts op precedent at operations.ts:2460.)
Two execution modes (D8):
- 'queue' (default): fire-and-forget via getFactsQueue().enqueue.
Caller awaits ~zero (just enqueue + microtask). Sync stays fast
on a 50-page batch.
- 'inline': await full pipeline; return real {inserted, duplicate,
superseded, fact_ids} counts. Used by extract_facts MCP op.
Discriminated return shape so TypeScript catches mode/result mismatches
at the call site:
| { mode: 'queue'; enqueued; queueDepth; skipped? }
| { mode: 'inline'; inserted; duplicate; superseded; fact_ids; skipped? }
Notability filter (D4): per-caller policy via FactsBackstopCtx.notabilityFilter.
Sync passes 'high-only' (HIGH lands now, MEDIUM waits for dream cycle,
LOW dropped at LLM layer). Other surfaces default to 'all'. Filter runs
post-LLM, pre-insert: saves the insert work but not the LLM call (the
notability tier IS what we're calling Sonnet to determine).
Eligibility + kill-switch gates run before any LLM cost. Skipped reasons
are stable strings the future facts:absorb writer (commit 13) and doctor
check (commit 12) consume.
Re-throws AbortError; absorbs gateway/parse/queue errors as `skipped: '...'`
envelope. Operator visibility lands via PR1 commit 13's ingest_log writer
(facts:absorb source_type).
Test pin (test/facts-backstop.test.ts NEW, 12 cases):
- 3 eligibility/kill-switch cases (extraction_disabled, subagent_namespace,
dream_generated)
- 5 inline-mode cases (insert + counts, notability filter, source string,
empty extraction, abort)
- 3 queue-mode cases (default mode, explicit mode, kill-switch envelope)
- 1 dedup contract case (insertions without embeddings short-circuit
cleanly; embedding-driven dedup is exercised by E2E with real gateway)
PGLite in-memory; LLM stubbed via __setChatTransportForTests (commit 1's
seam). 12/12 pass in 912ms.
PR 1 commit 6 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: sync.ts uses runFactsBackstop (deletes dead-code type filter)
Pre-fix sync.ts had a 60-line inline facts extraction block carrying:
1. Dead-code eligibility filter: ['meeting', 'conversation',
'transcript', 'personal', 'therapy', 'call'] — only `meeting` is
a real PageType. The other five never matched anything; eligibility
rested on the slug-prefix branch alone.
2. Divergent shape from put_page's backstop: no dedup, no supersede,
raw extract→insert. Garbage rows on re-sync.
3. Sequential per-page LLM calls in sync's request path: a 50-page
sync = 50 Sonnet calls in series ≈ 5+ minutes blocking.
Replaced with `runFactsBackstop(parsedPage, ctx)` from PR1 commit 6:
- Queue mode (fire-and-forget) so sync stays fast on multi-page batches.
- 'high-only' notabilityFilter (cathedral spec: HIGH lands now,
MEDIUM waits for dream cycle, LOW dropped at LLM).
- isFactsBackstopEligible (commit 5) — eligibility lives in one place.
- extract → resolve → dedup (cosine @ 0.95) → insert pipeline shared
with put_page + extract_facts.
Per-page try/catch survives so one failed page doesn't blow up the
whole sync (best-effort posture preserved).
Existing test/sync.test.ts (39 cases) passes unchanged — sync's outer
contract is untouched, only the inner facts-extract block changed.
PR 1 commit 7 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: operations.ts put_page uses runFactsBackstop
Replace the inline get-queue-extract-resolve-insert closure (operations.ts:540-583)
with a single `runFactsBackstop(parsed, ctx)` call in queue mode. put_page
and sync now share the same eligibility/extract/dedup/insert pipeline.
Behavioral preservation:
- Response shape `{queued: true} | {skipped: '<reason>'}` unchanged for
MCP clients. The helper's namespaced 'eligibility_failed:<reason>'
discriminator is mapped back to the bare reason ('kind:guide',
'too_short', 'subagent_namespace', 'dream_generated') before write
to factsQueued. test/facts-backstop-gating.test.ts (5 cases) passes
without modification.
- Default 'all' notabilityFilter (MEDIUM facts continue to land via
put_page; only sync filters to HIGH-only). This matches the
pre-v0.31.2 surface: put_page's prior shape inserted everything the
LLM returned, with the dream cycle's consolidate phase doing the
salience clustering overnight.
Net: -32 LOC of inline pipeline; one shared call site + one mapping
shim; same observable shape.
PR 1 commit 8 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: operations.ts extract_facts uses runFactsPipeline
Replace the 65-line inline extract→resolve→dedup→insert loop in the
extract_facts MCP op (operations.ts:2369-2454) with a single
`runFactsPipeline(turn_text, ctx)` call. The inline pipeline + the
helper are now the same code path; test/facts-mcp-allowlist + test/
facts-anti-loop pass unchanged.
Architecture: the helper has two entry points now —
- `runFactsBackstop(parsedPage, ctx)` — page-write hook with
eligibility + kill-switch + queue mode dispatch (PR1 commit 6).
Used by put_page, sync, file_upload, code_import.
- `runFactsPipeline(turnText, ctx)` — raw turn-text entry that
skips the page-shape eligibility predicate. Used by extract_facts
MCP op (this commit).
Both share an inner `runPipelineWithBody` so the actual extract → resolve
→ dedup (cosine @ 0.95) → insert pipeline lives in one place. Codex P0 #2
called this out: "extract_facts already does the smart pipeline; put_page
+ sync do raw extract→insert. Centralizing only extraction codifies the
worse pipeline." With commit 9, every fact-insert path goes through the
smart pipeline; raw insertFact loops in the brain are gone.
Behavioral preservation:
- extraction_disabled kill-switch envelope unchanged.
- is_dream_generated → returns {skipped: 'dream_generated'} envelope
(the predicate-bypass path; eligibility doesn't apply on raw
turn_text but dream_generated still does). Pre-fix the extractor
itself short-circuited; new shape surfaces the skip explicitly to
MCP clients.
- Visibility ('private' | 'world') threading preserved.
- Response shape {inserted, duplicate, superseded, fact_ids} identical
to pre-fix.
PR 1 commit 9 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document why file_upload + code_import don't wire runFactsBackstop
PR1 commit 10 was scoped in the eng review plan to "wire runFactsBackstop
to file_upload and code_import paths." Implementation analysis revealed
all three candidate surfaces are correctly handled WITHOUT explicit
wiring:
1. file_upload (operations.ts:1713) doesn't write a page. It uploads
a file to storage + inserts a `files` row. The associated page is
written separately via put_page, which already fires runFactsBackstop
in queue mode (commit 8). No double-firing needed.
2. importCodeFile (this file) writes pages with type='code'. The
isFactsBackstopEligible predicate rejects 'code' kind with reason
`kind:code`. Wiring runFactsBackstop here would always return the
skipped envelope. When README / doc-comment extraction lands in a
future release, the eligibility predicate is the single place to
update — adding 'code' to ELIGIBLE_TYPES makes existing call sites
auto-cover the change.
3. `gbrain import` (commands/import.ts) is bulk markdown import. Firing
facts extraction on every imported page would cost-spike on first-
time bulk imports of large brain repos (10K+ pages × Sonnet =
hundreds of dollars). User runs `gbrain dream` or the consolidate
phase to backfill facts from bulk-imported pages.
Adds a docstring above importCodeFile capturing all three rationales so
the next maintainer doesn't re-do this analysis.
PR 1 commit 10 of 15 — no behavior change; documentation only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: migration v47 — ingest_log.source_id ALTER (codex P1 #3)
Pre-fix the ingest_log table had no source_id column; sync.ts wrote rows
without source-scoping and doctor only checked 'default'. Codex's outside
voice flagged this on the cathedral plan: "facts:absorb logging inherits
a surface that cannot tell you which source is failing."
This commit closes the multi-source observability gap on the foundation:
- PR1 commit 13's facts:absorb writer (next) writes ingest_log rows
with source_id so multi-source brains scope failures per source.
- PR1 commit 12's doctor's facts_extraction_health check (after that)
iterates over `SELECT DISTINCT id FROM sources` instead of hardcoded
'default'.
Migration v47 (idempotent, both engines):
ALTER TABLE ingest_log ADD COLUMN IF NOT EXISTS source_id TEXT
NOT NULL DEFAULT 'default';
CREATE INDEX IF NOT EXISTS idx_ingest_log_source_type_created
ON ingest_log (source_id, source_type, created_at DESC);
Schema-bootstrap coverage:
- schema.sql / pglite-schema.ts inline definitions add source_id +
the new index for fresh installs.
- applyForwardReferenceBootstrap (both PGLite + Postgres) probes for
`ingest_log.source_id` and adds the column BEFORE SCHEMA_SQL replay
builds the new composite index. Without this, old brains running
initSchema() on the new schema-embedded.ts would crash on the index
creation (the column doesn't exist yet at replay time).
- test/schema-bootstrap-coverage.test.ts pins ingest_log.source_id as
REQUIRED_BOOTSTRAP_COVERAGE — adding a forward reference without
extending applyForwardReferenceBootstrap would fail this guard.
E2E (test/e2e/migration-v47-ingest-log-source-id.test.ts NEW, 3 cases):
- fresh-install: column + index both exist after runMigrationsUpTo(LATEST).
- old-brain simulation: drop column, run v47, column reappears with
NOT NULL DEFAULT 'default'; INSERT without source_id picks up the
default.
- idempotent re-run: v47 twice in a row is a no-op.
Verified against real Postgres (pgvector/pgvector:pg16): 3/3 pass; the v46
+ v47 E2Es land green together (8/8 in 2.05s). Bootstrap-coverage unit
test (5 cases) also green.
PR 1 commit 11 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: facts:absorb writer + reason codes (D5 contract)
D5 from /plan-ceo-review: every absorbed failure in the facts extraction
pipeline writes one row to ingest_log so doctor + admin dashboard
surface failures cross-process. CLAUDE.md's "zero silent failures" rule
gets enforced on the foundation.
Wires three layers:
1. Type widening (src/core/types.ts):
- IngestLogEntry gains source_id (codex P1 #3 — migration v47).
- IngestLogInput gains optional source_id; engines default to 'default'.
2. Engine row writers (pglite-engine.ts + postgres-engine.ts):
- logIngest threads source_id into INSERT.
- getIngestLog applies belt-and-suspenders 'default' fallback for
any pre-v47 row that somehow survived.
3. Helper (src/core/facts/absorb-log.ts NEW):
- writeFactsAbsorbLog(engine, ref, reason, detail, sourceId) writes
one ingest_log row with source_type='facts:absorb' and
summary='<reason>: <detail truncated to 240 chars>'.
- classifyFactsAbsorbError(err) heuristic-pattern-matches arbitrary
Errors into 6 stable reason codes:
gateway_error | parse_failure | queue_overflow
queue_shutdown | embed_failure | pipeline_error
- Best-effort: any logging failure is caught + stderr-warned;
the caller's pipeline keeps running.
4. runFactsBackstop wiring (src/core/facts/backstop.ts):
- queue mode: errors inside the queue worker classify + log via
absorb-log.ts. Were previously invisible (counter increment only).
- queue overflow drop also writes an absorb log row so doctor sees
the depth of capacity pressure.
- inline mode: errors bubble; caller decides logging (extract_facts
MCP op surfaces them as op-error responses).
Test pin (test/facts-absorb-log.test.ts NEW, 12 cases):
- 7 classifier cases pinning every reason path + fallback
- 5 writer cases pinning ingest_log row shape, custom sourceId,
240-char detail truncation, no-throw contract, reason-set
completeness
PR1 commit 12 (next) reads these rows for the facts_extraction_health
doctor check.
PR 1 commit 13 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: doctor facts_extraction_health check (multi-source)
Mirrors the eval_capture check shape but reads facts:absorb rows
(written by writeFactsAbsorbLog from PR1 commit 13). Iterates over
EVERY source (codex P1 #3 motivation) so multi-source brains see
per-source failure rates instead of only 'default'.
Configurable threshold: facts.absorb_warn_threshold (default 10 over
the last 24h, per source, per reason). When the threshold is exceeded
for any (source, reason) pair, status flips to warn and the message
names the breakdown:
facts:absorb activity in last 24h (under threshold 10):
default: 4 gateway_error, 1 parse_failure |
team-source: 2 queue_overflow
Single SQL grouping query covers the read; the composite index v47
added (idx_ingest_log_source_type_created on source_id, source_type,
created_at DESC) covers the filter + sort path so the check is fast
on brains with millions of ingest_log rows.
Operator UX:
- 'ok' under threshold (or zero failures) → quiet.
- 'warn' over threshold → message names every (source, reason, count)
tuple. Recovery hint: `gbrain recall --since 24h --json` to inspect
what landed; `gbrain config set facts.absorb_warn_threshold N` to
tune.
- Pre-v47 brain (column missing): 'ok' with skipped reason pointing
at `gbrain apply-migrations --yes`.
- RLS denies SELECT: 'warn' calling out that capture INSERTs are
likely also blocked.
Test pin (test/doctor.test.ts +28 LOC, 1 case):
Source-string assertions on the doctor.ts block:
- 'GROUP BY source_id' (multi-source contract)
- "source_type = 'facts:absorb'" (right table query)
- 'facts.absorb_warn_threshold' (configurable threshold)
- INTERVAL '24 hours' (right window)
- 'Skipped (ingest_log.source_id unavailable' (pre-v47 fallback)
- 'RLS denies SELECT on ingest_log' (RLS hint)
Negative: must NOT contain `source_id = 'default'` (the bug we're
fixing — codex P1 #3 was that doctor only checked 'default').
Live smoke against real Postgres: doctor renders the new check between
'eval_capture' and 'effective_date_health' as expected, shows 'ok' on
an empty test brain.
PR 1 commit 12 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: notability-eval mining + public-anonymized fixture (40 cases)
The notability gate is the load-bearing differentiator of the cathedral:
"only HIGH lands on sync, MEDIUM waits for the dream cycle, LOW dropped
at the LLM layer." Without an eval, the gate's quality is asserted via
hope; prompt drift (Sonnet returning 'medium' for everything) silently
turns the headline feature into a no-op.
This commit adds the mining half — eval suite is pinned in the next
commit (15).
NEW src/commands/notability-eval.ts:
- mineNotabilityCandidates(repoPath, opts): walks meetings/, personal/,
daily/ in the brain repo, splits markdown bodies into paragraphs
(filtered by 80–800 char length), pre-classifies each paragraph
with cheap-Haiku to bucket into HIGH/MEDIUM/LOW (round-robin
fallback when no chat gateway is available — local development
without API keys still produces a candidates file).
- Stratified random sample within each bucket: HIGH/MEDIUM/LOW
targets default 20/20/10 (per cathedral plan D7=B). Stratified
further across the three corpus dirs so HIGH cases come from
multiple dirs not just one.
- JSONL utilities (loadJsonlCases, writeJsonlCases) shared with the
review path. Default paths: ~/.gbrain/eval/notability-mining-
candidates.jsonl (mining) + ~/.gbrain/eval/notability-real.jsonl
(private confirmed).
- TTY review subcommand: walks candidates one-by-one, asks for
HIGH/MEDIUM/LOW confirmation, writes confirmed cases. Smoke-only
test (TTY interactivity is hard to test deterministically).
CLI dispatch (src/cli.ts):
- `gbrain notability-eval mine` (default targets 20/20/10).
- `gbrain notability-eval review` (TTY hand-confirm).
- `gbrain notability-eval help` (flag reference).
- sync.repo_path resolution mirrors the dream phase pattern; --repo
PATH overrides.
NEW test/fixtures/notability-eval-public.jsonl (40 cases):
- 14 HIGH (life events, major commitments, relationship/health changes,
financial decisions).
- 13 MEDIUM (durable preferences, beliefs, strong opinions revealing
character).
- 13 LOW (logistical noise — restaurant orders, scheduling, errands).
- Anonymized per CLAUDE.md privacy rule (alice-example, acme-co,
widget-co, fund-a placeholder names; no real contacts).
- Each case has a `tier_rationale` string documenting the choice for
reviewer transparency.
- Used by CI's eval harness in commit 15 (no API key required for
deterministic stub-driven contract tests).
PR 1 commit 14 of 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: notability-eval harness with precision@HIGH metric (40-case fixture)
Pins the load-bearing gate-quality contract in CI. Without this, prompt
drift (Sonnet returning 'medium' for everything → sync inserts nothing)
ships silently. The harness flips it from "asserted by hope" to "asserted
by metric."
NEW test/notability-eval.test.ts (13 cases across 5 describe blocks):
1. splitParagraphs (2 cases): blank-line splitting, length filters.
2. walkMarkdownFiles (1 case): tree walk drops non-.md files.
3. mineNotabilityCandidates round-robin path (2 cases): empty corpus
+ populated corpus produce expected candidate shape; round-robin
keeps tests deterministic without an LLM.
4. JSONL utilities (3 cases): write+read round-trip, malformed-line
skip, default paths under ~/.gbrain/eval/.
5. Public-anonymized fixture shape (2 cases): 40 cases, ≥10 per tier,
every paragraph ≥80 chars, every case has a tier_rationale.
6. Eval harness contract (3 cases) — the headline assertions:
- Perfect predictor (LLM-stub returns confirmed_tier verbatim) →
precision@HIGH = 1.0, recall@HIGH = 1.0.
- Always-medium model → precision@HIGH = 0 (no HIGH predictions
at all). Pins the "harness handles the no-positive-prediction
case correctly" contract.
- Always-high model → precision drops below the 0.50 PR-fail
threshold (TP / (TP + FP) = 14 / 40 = 0.35). Pins the
"harness CORRECTLY flags a misaligned model" contract.
Sample size justification: the public fixture has 14 HIGH cases. For
precision@HIGH = 0.75 with a 95% CI ±10pp, n=14 gives the right floor
for "is the gate dramatically wrong" — tighter measurements need the
private fixture (50 cases via mine + review).
The harness is a CONTRACT test for the metric shape, not a quality
measurement of any specific model. A real quality run uses the same
harness against a real Sonnet (no chat-transport stub) — that flow is
exposed via GBRAIN_NOTABILITY_EVAL_REAL=1 + the private mined fixture.
All 92 tests across all PR1 facts files pass green (extract / extract-
smoke / engine / backstop / eligibility / absorb-log / notability-eval).
Soft gate per the cathedral plan: warn if precision@HIGH < 0.75; fail
PR if < 0.50. CI wiring + the production gate are deferred to PR2 (the
visibility/observability surface PR); this PR1 commit lands the harness
+ fixture + contract tests so the gate is ready to wire.
PR 1 commit 15 of 15. Cathedral foundation lands here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fill PR1 gap-fill — backstop integration + Postgres parity
Test gap analysis flagged three high-priority untested behaviors in
PR1's surface:
Gap #3: extract_facts MCP op response shape stability after
routing through runFactsPipeline (commit 9). Existing tests
pin allowlist + anti-loop but not the {inserted, duplicate,
superseded, fact_ids} envelope that MCP clients display.
Gap #4: per-engine row-mapper parity for notability. facts-engine.test.ts
pins notability round-trip on PGLite; the Postgres row mapper
(postgres-engine.ts:rowToFactPg) is different code that wasn't
pinned. Codex P1 #4 was specifically about read-side contracts
drifting silently.
Gap #5: multi-source isolation in facts:absorb logging. Codex
P1 #3 motivated the source_id column; the absorb-log test pins
that source_id is written but not that source_id-scoped queries
return only the right source's rows.
NEW test/facts-backstop-integration.test.ts (6 cases):
- 2 cases on runFactsPipeline (extract_facts path) response shape:
successful extraction returns full {inserted, duplicate, superseded,
fact_ids} envelope with positive fact_ids; empty extraction returns
zero counts (no NaN/undefined).
- 2 cases on facts:absorb multi-source isolation: writeFactsAbsorbLog
rows are source-scoped; doctor's GROUP BY source_id query produces
the expected per-source breakdown.
- 2 cases on queue mode: happy-path drain pins counters.completed >= 1
+ counters.failed == 0; documented case noting that extract.ts
absorbs gateway errors silently (errors propagate from layers
ABOVE extract — resolver, dedup, insert — to backstop's catch,
not from the chat call itself).
NEW test/e2e/facts-notability-roundtrip.test.ts (5 cases, real Postgres):
- HIGH/MEDIUM/LOW round-trip via insertFact + listFactsByEntity.
- Omitting notability defaults to medium (NOT NULL DEFAULT contract).
- listFactsSince also surfaces notability.
All 5 pin the postgres.js driver + rowToFactPg row mapper.
PGLite parity is covered by the existing test/facts-engine.test.ts
case from commit 4.
Verified: 6/6 unit + 5/5 E2E green. The third high-priority gap
(integration sync.ts → runFactsBackstop end-to-end) is sufficiently
covered by the existing test/sync.test.ts behavior plus the per-page
runFactsBackstop assertions in test/facts-backstop.test.ts; chasing
the full happy-path sync→facts integration would require a real
git fixture which is heavier than warranted for this surface.
PR 1 commit 16 of 16 (gap fill).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1073 lines
44 KiB
TypeScript
1073 lines
44 KiB
TypeScript
import type {
|
||
Page, PageInput, PageFilters, GetPageOpts,
|
||
Chunk, ChunkInput, StaleChunkRow,
|
||
SearchResult, SearchOpts,
|
||
Link, GraphNode, GraphPath,
|
||
TimelineEntry, TimelineInput, TimelineOpts,
|
||
RawData,
|
||
PageVersion,
|
||
BrainStats, BrainHealth,
|
||
IngestLogEntry, IngestLogInput,
|
||
EngineConfig,
|
||
CodeEdgeInput, CodeEdgeResult,
|
||
EvalCandidate, EvalCandidateInput,
|
||
EvalCaptureFailure, EvalCaptureFailureReason,
|
||
SalienceOpts, SalienceResult, AnomaliesOpts, AnomalyResult,
|
||
EmotionalWeightInputRow, EmotionalWeightWriteRow,
|
||
} from './types.ts';
|
||
|
||
/**
|
||
* v0.27.1: file row for binary-asset metadata. Mirrors the `files` table
|
||
* shape on both engines (Postgres has had it since v0.18; PGLite gets it
|
||
* via migration v36).
|
||
*/
|
||
export interface FileRow {
|
||
id: number;
|
||
source_id: string;
|
||
page_slug: string | null;
|
||
page_id: number | null;
|
||
filename: string;
|
||
storage_path: string;
|
||
mime_type: string | null;
|
||
size_bytes: number | null;
|
||
content_hash: string;
|
||
metadata: Record<string, unknown>;
|
||
created_at: Date;
|
||
}
|
||
|
||
/**
|
||
* v0.27.1: spec for upsertFile. Identity is (source_id, storage_path).
|
||
* Re-upserting the same identity with a different content_hash updates the
|
||
* row in place (image was replaced); same content_hash is a no-op.
|
||
*/
|
||
export interface FileSpec {
|
||
source_id?: string;
|
||
page_slug?: string | null;
|
||
page_id?: number | null;
|
||
filename: string;
|
||
storage_path: string;
|
||
mime_type?: string | null;
|
||
size_bytes?: number | null;
|
||
content_hash: string;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
/** Input row for addLinksBatch. Optional fields default to '' (matches NOT NULL DDL). */
|
||
export interface LinkBatchInput {
|
||
from_slug: string;
|
||
to_slug: string;
|
||
link_type?: string;
|
||
context?: string;
|
||
/**
|
||
* Provenance (v0.13+). Pass 'frontmatter' for edges derived from YAML
|
||
* frontmatter, 'markdown' for [Name](path) refs, 'manual' for user-created.
|
||
* NULL means "legacy / unknown" and is only used by pre-v0.13 rows; new
|
||
* writes should always set this. Missing on input defaults to 'markdown'.
|
||
*/
|
||
link_source?: string;
|
||
/** For link_source='frontmatter': slug of the page whose frontmatter created this edge. */
|
||
origin_slug?: string;
|
||
/** Frontmatter field name (e.g. 'key_people', 'investors'). */
|
||
origin_field?: string;
|
||
/**
|
||
* v0.18.0: source id for each endpoint. When omitted, the engine JOINs
|
||
* against `source_id='default'`. Pass explicit values when the edge
|
||
* lives in a non-default source OR crosses sources.
|
||
*
|
||
* Without these fields, the batch JOIN `pages.slug = v.from_slug` fans
|
||
* out across every source containing that slug, silently creating wrong
|
||
* edges in a multi-source brain. The source_id filter eliminates the
|
||
* fan-out. Origin pages (frontmatter provenance) get their own
|
||
* source_id so reconciliation can't delete edges from another source's
|
||
* frontmatter.
|
||
*/
|
||
from_source_id?: string;
|
||
to_source_id?: string;
|
||
origin_source_id?: string;
|
||
}
|
||
|
||
/** Input row for addTimelineEntriesBatch. Optional fields default to '' (matches NOT NULL DDL). */
|
||
export interface TimelineBatchInput {
|
||
slug: string;
|
||
date: string;
|
||
source?: string;
|
||
summary: string;
|
||
detail?: string;
|
||
/**
|
||
* v0.18.0: source id for the owning page. When omitted, the engine JOINs
|
||
* against `source_id='default'`. Without this, two pages sharing the
|
||
* same slug across sources would fan out timeline rows to both.
|
||
*/
|
||
source_id?: string;
|
||
}
|
||
|
||
/**
|
||
* A single dedicated database connection, isolated from the engine's pool.
|
||
*
|
||
* Used by migration paths that need session-level GUCs (e.g.
|
||
* `SET statement_timeout = '600000'` before a `CREATE INDEX CONCURRENTLY`)
|
||
* without leaking into the shared pool, and by write-quiesce designs
|
||
* that need a session-lifetime Postgres advisory lock that survives
|
||
* across transaction boundaries.
|
||
*
|
||
* On Postgres: backed by postgres-js `sql.reserve()`; the same backend
|
||
* process serves every `executeRaw` call within the callback. Released
|
||
* automatically when the callback returns or throws.
|
||
*
|
||
* On PGLite: a thin pass-through. PGLite has no pool, so every call is
|
||
* already on the single backing connection. The interface is still
|
||
* exposed so cross-engine callers don't need to branch.
|
||
*
|
||
* Not safe to call from inside `transaction()`. The transaction holds a
|
||
* different backend; reserving a second one can deadlock on a row the
|
||
* transaction itself is waiting to write.
|
||
*/
|
||
export interface ReservedConnection {
|
||
executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||
}
|
||
|
||
/**
|
||
* v0.28: Takes — typed/weighted/attributed claims, indexed in Postgres.
|
||
* Markdown is source of truth (fenced table on the page); this row is the
|
||
* derived index. Page-scoped via page_id (NOT slug — slug is unique only
|
||
* within a source). `(page_id, row_num)` is the natural unique key.
|
||
*/
|
||
export interface TakeKindLiteral { kind: 'fact' | 'take' | 'bet' | 'hunch' }
|
||
export type TakeKind = TakeKindLiteral['kind'];
|
||
|
||
/** Input row for addTakesBatch. */
|
||
export interface TakeBatchInput {
|
||
page_id: number;
|
||
row_num: number;
|
||
claim: string;
|
||
kind: TakeKind;
|
||
holder: string;
|
||
weight?: number; // 0..1, default 0.5; clamped server-side
|
||
since_date?: string; // ISO date 'YYYY-MM-DD'
|
||
until_date?: string;
|
||
source?: string;
|
||
superseded_by?: number | null;
|
||
active?: boolean; // default true
|
||
}
|
||
|
||
/** Take row as returned by listTakes / searchTakes. */
|
||
export interface Take {
|
||
id: number;
|
||
page_id: number;
|
||
page_slug: string; // joined from pages
|
||
row_num: number;
|
||
claim: string;
|
||
kind: TakeKind;
|
||
holder: string;
|
||
weight: number;
|
||
since_date: string | null;
|
||
until_date: string | null;
|
||
source: string | null;
|
||
superseded_by: number | null;
|
||
active: boolean;
|
||
resolved_at: string | null;
|
||
resolved_outcome: boolean | null;
|
||
/**
|
||
* v0.30.0: 3-state outcome label. Sits alongside `resolved_outcome` for
|
||
* back-compat. New writes populate both; legacy v0.28-resolved rows have
|
||
* `resolved_quality` backfilled by migration v40 from the boolean.
|
||
* Null on unresolved rows. Schema CHECK enforces (quality, outcome) consistency:
|
||
* `correct` ↔ `outcome=true`, `incorrect` ↔ `outcome=false`, `partial` ↔ `outcome=NULL`.
|
||
*/
|
||
resolved_quality: 'correct' | 'incorrect' | 'partial' | null;
|
||
resolved_value: number | null;
|
||
resolved_unit: string | null;
|
||
resolved_source: string | null;
|
||
resolved_by: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface TakesListOpts {
|
||
page_id?: number;
|
||
page_slug?: string; // resolved via JOIN
|
||
holder?: string;
|
||
kind?: TakeKind;
|
||
active?: boolean; // default true (only active rows)
|
||
resolved?: boolean; // true = only resolved; false = only unresolved; undefined = both
|
||
/** Per-token MCP allow-list. Server applies AND holder = ANY($takesHoldersAllowList) when set. */
|
||
takesHoldersAllowList?: string[];
|
||
sortBy?: 'weight' | 'since_date' | 'created_at';
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
/** Search result row from searchTakes / searchTakesVector. */
|
||
export interface TakeHit {
|
||
take_id: number;
|
||
page_id: number;
|
||
page_slug: string;
|
||
row_num: number;
|
||
claim: string;
|
||
kind: TakeKind;
|
||
holder: string;
|
||
weight: number;
|
||
score: number; // search rank score (ts_rank for keyword, 1-cos_dist for vector)
|
||
}
|
||
|
||
/** v0.28 stale-takes row (mirrors StaleChunkRow shape). Embedding column intentionally omitted. */
|
||
export interface StaleTakeRow {
|
||
take_id: number;
|
||
page_slug: string;
|
||
row_num: number;
|
||
claim: string;
|
||
}
|
||
|
||
/** Resolution metadata for resolveTake. */
|
||
export interface TakeResolution {
|
||
/**
|
||
* v0.30.0: primary 3-state input. When set, takes precedence over `outcome`
|
||
* and the engine writes both columns (quality directly; outcome derived:
|
||
* `correct→true`, `incorrect→false`, `partial→null`).
|
||
*/
|
||
quality?: 'correct' | 'incorrect' | 'partial';
|
||
/**
|
||
* v0.28 back-compat input. Keep submitting for v0.28 callers; the engine
|
||
* derives quality (`true→correct`, `false→incorrect`). When `quality` is
|
||
* also set, `quality` wins. When neither is set, the engine throws.
|
||
* Mutually-exclusive with `quality === 'partial'` because partial isn't
|
||
* binary.
|
||
*/
|
||
outcome?: boolean;
|
||
value?: number;
|
||
unit?: string; // 'usd' | 'pct' | 'count' | other
|
||
source?: string;
|
||
resolvedBy: string; // slug or 'garry'
|
||
}
|
||
|
||
/** v0.30.0: scorecard aggregate. */
|
||
export interface TakesScorecard {
|
||
total_bets: number;
|
||
resolved: number;
|
||
correct: number;
|
||
incorrect: number;
|
||
partial: number;
|
||
/** Accuracy = correct / (correct + incorrect). NULL when n=0. */
|
||
accuracy: number | null;
|
||
/**
|
||
* Brier score over rows where `resolved_quality IN ('correct','incorrect')`.
|
||
* Maps `correct→1`, `incorrect→0`, computes `mean((weight − outcome)²)`.
|
||
* Lower is better; 0 = perfect; 0.25 = always-50% baseline.
|
||
* Excludes partial — that label hides hedging behavior; `partial_rate`
|
||
* surfaces it as a separate signal. NULL when no correct+incorrect rows.
|
||
*/
|
||
brier: number | null;
|
||
/** partial / resolved. NULL when n=0. */
|
||
partial_rate: number | null;
|
||
}
|
||
|
||
export interface TakesScorecardOpts {
|
||
holder?: string;
|
||
domainPrefix?: string; // e.g. 'companies/' to scope the scorecard
|
||
since?: string; // ISO date 'YYYY-MM-DD'
|
||
until?: string; // ISO date 'YYYY-MM-DD'
|
||
}
|
||
|
||
/** v0.30.0: calibration curve bucket. */
|
||
export interface CalibrationBucket {
|
||
/** Lower bound of the weight bucket, inclusive. */
|
||
bucket_lo: number;
|
||
/** Upper bound, exclusive (except for the final bucket which is inclusive of 1.0). */
|
||
bucket_hi: number;
|
||
/** Count of resolved correct+incorrect bets falling in this weight range. */
|
||
n: number;
|
||
/** correct / n. NULL when n=0. */
|
||
observed: number | null;
|
||
/** mean(weight) within the bucket — what was predicted on average. NULL when n=0. */
|
||
predicted: number | null;
|
||
}
|
||
|
||
export interface CalibrationCurveOpts {
|
||
holder?: string;
|
||
bucketSize?: number; // default 0.1
|
||
}
|
||
|
||
/** Synthesis evidence row input (provenance from think synthesis pages). */
|
||
export interface SynthesisEvidenceInput {
|
||
synthesis_page_id: number;
|
||
take_page_id: number;
|
||
take_row_num: number;
|
||
citation_index: number;
|
||
}
|
||
|
||
/** Dream-cycle Haiku verdict on whether a transcript is worth processing. */
|
||
export interface DreamVerdict {
|
||
worth_processing: boolean;
|
||
reasons: string[];
|
||
judged_at: string;
|
||
}
|
||
|
||
/** Input shape for putDreamVerdict — judged_at defaults to now() server-side. */
|
||
export interface DreamVerdictInput {
|
||
worth_processing: boolean;
|
||
reasons: string[];
|
||
}
|
||
|
||
// ============================================================
|
||
// v0.31 Hot Memory: facts table + recall surface
|
||
// ============================================================
|
||
|
||
/** Allowed `facts.kind` values. Different decay halflives apply per kind. */
|
||
export type FactKind = 'event' | 'preference' | 'commitment' | 'belief' | 'fact';
|
||
|
||
export const ALL_FACT_KINDS: readonly FactKind[] = [
|
||
'event', 'preference', 'commitment', 'belief', 'fact',
|
||
] as const;
|
||
|
||
/** Visibility tier on a fact row. Mirrors takes' world-default ACL contract (D21). */
|
||
export type FactVisibility = 'private' | 'world';
|
||
|
||
/** Status returned by insertFact. */
|
||
export type FactInsertStatus = 'inserted' | 'duplicate' | 'superseded';
|
||
|
||
/** A fact row read from the facts table. */
|
||
export interface FactRow {
|
||
id: number;
|
||
source_id: string;
|
||
entity_slug: string | null;
|
||
fact: string;
|
||
kind: FactKind;
|
||
visibility: FactVisibility;
|
||
/**
|
||
* v0.31.2: salience tier the LLM assigned at extraction time. Surfaces
|
||
* to consumers (recall response, daily-page writer, admin dashboard,
|
||
* agents reading via MCP `_meta.brain_hot_memory`). Pre-v45 brains had
|
||
* no notability column; migration v46 backfills with default 'medium'.
|
||
*/
|
||
notability: 'high' | 'medium' | 'low';
|
||
context: string | null;
|
||
valid_from: Date;
|
||
valid_until: Date | null;
|
||
expired_at: Date | null;
|
||
superseded_by: number | null;
|
||
consolidated_at: Date | null;
|
||
consolidated_into: number | null;
|
||
source: string;
|
||
source_session: string | null;
|
||
confidence: number;
|
||
embedding: Float32Array | null;
|
||
embedded_at: Date | null;
|
||
created_at: Date;
|
||
}
|
||
|
||
/** Input for insertFact. source_id supplied via the ctx arg. */
|
||
export interface NewFact {
|
||
fact: string;
|
||
kind?: FactKind; // default 'fact'
|
||
entity_slug?: string | null;
|
||
visibility?: FactVisibility; // default 'private'
|
||
context?: string | null;
|
||
valid_from?: Date; // default now()
|
||
valid_until?: Date | null;
|
||
source: string; // 'mcp:put_page' | 'mcp:extract_facts' | 'cli:think' | etc
|
||
source_session?: string | null;
|
||
confidence?: number; // [0,1], default 1.0
|
||
notability?: 'high' | 'medium' | 'low'; // salience filter for extraction gate
|
||
embedding?: Float32Array | null; // pre-computed; if null, insertFact computes via gateway
|
||
}
|
||
|
||
/** Options shared by list-facts methods. */
|
||
export interface FactListOpts {
|
||
/** Hide expired_at IS NOT NULL rows. Default true. */
|
||
activeOnly?: boolean;
|
||
limit?: number;
|
||
offset?: number;
|
||
/** Restrict to specific kinds. Default: all kinds. */
|
||
kinds?: FactKind[];
|
||
/**
|
||
* Visibility filter. When undefined, returns all. When set, only matches
|
||
* are returned. Remote (untrusted) callers must supply ['world'].
|
||
*/
|
||
visibility?: FactVisibility[];
|
||
}
|
||
|
||
/** Per-source operational health snapshot consumed by `gbrain doctor`. */
|
||
export interface FactsHealth {
|
||
source_id: string;
|
||
total_active: number; // facts where expired_at IS NULL
|
||
total_today: number; // created in last 24h
|
||
total_week: number; // created in last 7d
|
||
total_expired: number; // expired_at IS NOT NULL
|
||
total_consolidated: number; // consolidated_at IS NOT NULL
|
||
top_entities: Array<{ entity_slug: string; count: number }>;
|
||
/** Optional counters fed by the queue / classifier — populated when those modules report. */
|
||
drop_counter?: number;
|
||
classifier_fail_counter?: number;
|
||
p50_latency_ms?: number;
|
||
p99_latency_ms?: number;
|
||
}
|
||
|
||
/** Maximum results returned by search operations. Internal bulk operations (listPages) are not clamped. */
|
||
export const MAX_SEARCH_LIMIT = 100;
|
||
|
||
/** Clamp a user-provided search limit to a safe range. */
|
||
export function clampSearchLimit(limit: number | undefined, defaultLimit = 20, cap = MAX_SEARCH_LIMIT): number {
|
||
if (limit === undefined || limit === null || !Number.isFinite(limit) || Number.isNaN(limit)) return defaultLimit;
|
||
if (limit <= 0) return defaultLimit;
|
||
return Math.min(Math.floor(limit), cap);
|
||
}
|
||
|
||
export interface BrainEngine {
|
||
/** Discriminator: lets migrations and other consumers branch on engine kind without instanceof + dynamic imports. */
|
||
readonly kind: 'postgres' | 'pglite';
|
||
|
||
// Lifecycle
|
||
connect(config: EngineConfig): Promise<void>;
|
||
disconnect(): Promise<void>;
|
||
initSchema(): Promise<void>;
|
||
transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T>;
|
||
/**
|
||
* Run `fn` with a dedicated connection (Postgres: reserved backend;
|
||
* PGLite: pass-through). See `ReservedConnection` for semantics and
|
||
* usage constraints. Release is automatic.
|
||
*/
|
||
withReservedConnection<T>(fn: (conn: ReservedConnection) => Promise<T>): Promise<T>;
|
||
|
||
// Pages CRUD
|
||
/**
|
||
* Fetch a page by slug.
|
||
* v0.26.5: by default soft-deleted rows return null (matches the search
|
||
* filter contract). Pass `opts.includeDeleted: true` to surface them with
|
||
* `deleted_at` populated — used by `gbrain pages purge-deleted` listing,
|
||
* by `restore_page` flow, and by operator diagnostics.
|
||
*/
|
||
getPage(slug: string, opts?: GetPageOpts): Promise<Page | null>;
|
||
/**
|
||
* Insert or update a page. When `opts.sourceId` is omitted, the row is
|
||
* written under the schema DEFAULT ('default'). When provided, `source_id`
|
||
* is included in the INSERT column list so ON CONFLICT (source_id, slug)
|
||
* DO UPDATE actually targets the intended row instead of fabricating a
|
||
* duplicate at (default, slug). Multi-source brains MUST pass sourceId.
|
||
*/
|
||
putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page>;
|
||
/**
|
||
* Hard-delete a page row. Cascades to content_chunks, page_links,
|
||
* chunk_relations via existing FK ON DELETE CASCADE.
|
||
*
|
||
* v0.26.5: this is no longer the public-facing `delete_page` op handler —
|
||
* the op now soft-deletes via `softDeletePage` instead. `deletePage` stays
|
||
* as the underlying primitive used by `purgeDeletedPages` and by callers
|
||
* that explicitly want hard-delete semantics (e.g. test setup teardown).
|
||
*/
|
||
/**
|
||
* v0.18.0+ multi-source: `opts.sourceId` scopes the DELETE so a source-A
|
||
* delete doesn't hard-delete the same-slug pages in sources B/C/D. Without
|
||
* it, the bare DELETE matches every row with that slug across all sources.
|
||
* Cascades through content_chunks / page_links / chunk_relations via FKs.
|
||
*/
|
||
deletePage(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||
/**
|
||
* v0.26.5 — set `deleted_at = now()` on a page. Returns the slug if a row
|
||
* was soft-deleted, null if no row matched (already soft-deleted OR not found).
|
||
* Idempotent-as-null. The page stays in the DB and cascade rows (chunks,
|
||
* links) stay intact; the autopilot purge phase hard-deletes after 72h.
|
||
*/
|
||
softDeletePage(slug: string, opts?: { sourceId?: string }): Promise<{ slug: string } | null>;
|
||
/**
|
||
* v0.26.5 — clear `deleted_at` on a soft-deleted page. Returns true iff a
|
||
* row was restored. False if the slug is unknown OR the page is not
|
||
* currently soft-deleted (idempotent-as-false).
|
||
*/
|
||
restorePage(slug: string, opts?: { sourceId?: string }): Promise<boolean>;
|
||
/**
|
||
* v0.26.5 — hard-delete pages whose `deleted_at` is older than the cutoff.
|
||
* Called by the autopilot purge phase and by the `gbrain pages purge-deleted`
|
||
* CLI escape hatch. Cascades through existing FKs.
|
||
*/
|
||
purgeDeletedPages(olderThanHours: number): Promise<{ slugs: string[]; count: number }>;
|
||
/**
|
||
* v0.26.5: by default `listPages` excludes soft-deleted rows. Set
|
||
* `filters.includeDeleted: true` to surface them.
|
||
*/
|
||
listPages(filters?: PageFilters): Promise<Page[]>;
|
||
resolveSlugs(partial: string): Promise<string[]>;
|
||
/**
|
||
* Returns the slug of every page in the brain. Used by batch commands as a
|
||
* mutation-immune iteration source (alternative to listPages OFFSET pagination,
|
||
* which is unstable when ordering by updated_at and writes are happening).
|
||
*/
|
||
getAllSlugs(): Promise<Set<string>>;
|
||
|
||
// Search
|
||
searchKeyword(query: string, opts?: SearchOpts): Promise<SearchResult[]>;
|
||
searchVector(embedding: Float32Array, opts?: SearchOpts): Promise<SearchResult[]>;
|
||
getEmbeddingsByChunkIds(ids: number[]): Promise<Map<number, Float32Array>>;
|
||
|
||
// Chunks
|
||
/**
|
||
* Replace the chunk set for a page. Internal page-id lookup is sourceId-
|
||
* scoped when `opts.sourceId` is given; without it, the schema DEFAULT
|
||
* matches and bare-slug lookup blows up if the same slug exists in
|
||
* multiple sources (Postgres 21000).
|
||
*/
|
||
upsertChunks(slug: string, chunks: ChunkInput[], opts?: { sourceId?: string }): Promise<void>;
|
||
/**
|
||
* Read every chunk for a page. `opts.sourceId` source-scopes the page
|
||
* lookup; without it, multi-source brains return chunks from every
|
||
* same-slug source (importCodeFile uses this for incremental embedding
|
||
* reuse, which would then attach the wrong source's embeddings).
|
||
*/
|
||
getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]>;
|
||
/**
|
||
* Count chunks across the entire brain where embedded_at IS NULL.
|
||
* Pre-flight short-circuit for `embed --stale` so a 100%-embedded brain
|
||
* does no further work after a single SELECT count(*) (~50 bytes wire).
|
||
*/
|
||
countStaleChunks(): Promise<number>;
|
||
/**
|
||
* Return every chunk where embedded_at IS NULL, with the metadata needed
|
||
* to call embedBatch + upsertChunks. The `embedding` column is omitted
|
||
* by design — stale rows have NULL embeddings, so shipping them wastes
|
||
* wire bytes for no gain. Caller groups by slug, embeds, and re-upserts.
|
||
*
|
||
* Bounded by an internal LIMIT of 100000 to mirror listPages.
|
||
*/
|
||
listStaleChunks(): Promise<StaleChunkRow[]>;
|
||
/**
|
||
* Delete every chunk for a page. Internal page-id lookup is sourceId-scoped
|
||
* when `opts.sourceId` is given; otherwise the bare-slug subquery returns
|
||
* the wrong row count in multi-source brains.
|
||
*/
|
||
deleteChunks(slug: string, opts?: { sourceId?: string }): Promise<void>;
|
||
|
||
// Links
|
||
/**
|
||
* Single-row link insert. linkSource defaults to 'markdown' for back-compat
|
||
* with pre-v0.13 callers. Pass 'frontmatter' + originSlug + originField for
|
||
* frontmatter-derived edges; 'manual' for user-initiated edges.
|
||
*/
|
||
/**
|
||
* v0.18.0+ multi-source: each endpoint can live in a different source.
|
||
* `opts.fromSourceId` / `opts.toSourceId` / `opts.originSourceId` default to
|
||
* 'default'. Without these, the original cross-product `FROM pages f, pages t`
|
||
* fanned out across every source containing the slug.
|
||
*/
|
||
addLink(
|
||
from: string,
|
||
to: string,
|
||
context?: string,
|
||
linkType?: string,
|
||
linkSource?: string,
|
||
originSlug?: string,
|
||
originField?: string,
|
||
opts?: { fromSourceId?: string; toSourceId?: string; originSourceId?: string },
|
||
): Promise<void>;
|
||
/**
|
||
* Bulk insert links via a single multi-row INSERT...SELECT FROM (VALUES) JOIN pages
|
||
* statement with ON CONFLICT DO NOTHING. Returns the count of rows actually inserted
|
||
* (RETURNING clause excludes conflicts and JOIN-dropped rows whose slugs don't exist).
|
||
* Used by extract.ts to avoid 47K sequential round-trips on large brains.
|
||
*/
|
||
addLinksBatch(links: LinkBatchInput[]): Promise<number>;
|
||
/**
|
||
* Remove links from `from` to `to`. If linkType is provided, only that specific
|
||
* (from, to, type) row is removed. If omitted, ALL link types between the pair
|
||
* are removed (matches pre-multi-type-link behavior). linkSource additionally
|
||
* constrains the delete to a specific provenance ('frontmatter', 'markdown',
|
||
* 'manual') — used by runAutoLink reconciliation to avoid deleting edges from
|
||
* other provenances when pruning frontmatter-derived edges.
|
||
*/
|
||
removeLink(
|
||
from: string,
|
||
to: string,
|
||
linkType?: string,
|
||
linkSource?: string,
|
||
opts?: { fromSourceId?: string; toSourceId?: string },
|
||
): Promise<void>;
|
||
getLinks(slug: string): Promise<Link[]>;
|
||
getBacklinks(slug: string): Promise<Link[]>;
|
||
/**
|
||
* Fuzzy-match a display name to a page slug using pg_trgm similarity.
|
||
* Zero embedding cost, zero LLM cost — designed for the v0.13 resolver used
|
||
* during migration/batch backfill where 5K+ lookups must stay sub-second.
|
||
*
|
||
* Returns the best match whose title similarity is at or above `minSimilarity`
|
||
* (default 0.55). If `dirPrefix` is given (e.g. 'people' or 'companies'),
|
||
* only slugs starting with that prefix are considered. Returns null when no
|
||
* page meets the threshold.
|
||
*
|
||
* Uses the `%` trigram operator (GIN-indexed) + the standard `similarity()`
|
||
* function. Both engines support pg_trgm (PGLite 0.3+, Postgres always).
|
||
*/
|
||
findByTitleFuzzy(
|
||
name: string,
|
||
dirPrefix?: string,
|
||
minSimilarity?: number,
|
||
): Promise<{ slug: string; similarity: number } | null>;
|
||
traverseGraph(slug: string, depth?: number): Promise<GraphNode[]>;
|
||
/**
|
||
* Edge-based graph traversal with optional type and direction filters.
|
||
* Returns a list of edges (GraphPath[]) instead of nodes. Supports:
|
||
* - linkType: per-edge filter, only follows matching edges (per-edge semantics)
|
||
* - direction: 'in' (follow to->from), 'out' (follow from->to), 'both'
|
||
* - depth: max depth from root (default 5)
|
||
* Uses cycle prevention (visited array in recursive CTE).
|
||
*/
|
||
traversePaths(
|
||
slug: string,
|
||
opts?: { depth?: number; linkType?: string; direction?: 'in' | 'out' | 'both' },
|
||
): Promise<GraphPath[]>;
|
||
/**
|
||
* For a list of slugs, return how many inbound links each has.
|
||
* Used by hybrid search backlink boost. Single SQL query, not N+1.
|
||
* Slugs with zero inbound links are present in the map with value 0.
|
||
*/
|
||
getBacklinkCounts(slugs: string[]): Promise<Map<string, number>>;
|
||
/**
|
||
* v0.27.0: for a list of slugs, return their updated_at timestamps (or created_at fallback).
|
||
* Used by hybrid search recency boost. Single SQL query, not N+1.
|
||
* Slugs with no timestamp get no entry in the map.
|
||
*
|
||
* @deprecated v0.29.1: prefer getEffectiveDates (composite-keyed, multi-source-safe).
|
||
* Kept for back-compat with PR #618 callers.
|
||
*/
|
||
getPageTimestamps(slugs: string[]): Promise<Map<string, Date>>;
|
||
/**
|
||
* v0.29.1: for a list of (slug, source_id) refs, return COALESCE(effective_date,
|
||
* updated_at) per ref. Single SQL query. Composite-keyed map (key format:
|
||
* `${source_id}::${slug}`) so multi-source brains don't conflate pages with
|
||
* the same slug across sources (codex pass-1 finding #3).
|
||
*
|
||
* Drives the new applyRecencyBoost post-fusion stage. Returns NULL for refs
|
||
* with no row; map omits them.
|
||
*/
|
||
getEffectiveDates(refs: Array<{slug: string; source_id: string}>): Promise<Map<string, Date>>;
|
||
/**
|
||
* v0.29.1: for a list of (slug, source_id) refs, return the salience score
|
||
* (emotional_weight × 5 + ln(1 + take_count)) per ref. Single SQL query.
|
||
* Composite-keyed (`${source_id}::${slug}`) like getEffectiveDates.
|
||
*
|
||
* Drives the new applySalienceBoost post-fusion stage. Pages with no row
|
||
* (or zero emotional_weight + zero takes) get score = 0; the boost stage
|
||
* skips them.
|
||
*/
|
||
getSalienceScores(refs: Array<{slug: string; source_id: string}>): Promise<Map<string, number>>;
|
||
/**
|
||
* Return every page with no inbound links (from any source).
|
||
* Domain comes from the frontmatter `domain` field (null if unset).
|
||
* The caller filters pseudo-pages + derives display domain.
|
||
* Used by `gbrain orphans` and `runCycle`'s orphan sweep phase.
|
||
*/
|
||
findOrphanPages(): Promise<Array<{ slug: string; title: string; domain: string | null }>>;
|
||
|
||
// Tags
|
||
/**
|
||
* v0.18.0+ multi-source: `opts.sourceId` scopes the page-id lookup. When
|
||
* omitted, the schema DEFAULT 'default' applies; in multi-source brains
|
||
* with the same slug across sources the bare-slug lookup returns >1 row
|
||
* and the INSERT/DELETE fails with Postgres 21000.
|
||
*/
|
||
addTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
|
||
removeTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
|
||
getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]>;
|
||
|
||
// Timeline
|
||
/**
|
||
* Insert a timeline entry. By default verifies the page exists and throws if not.
|
||
* Pass opts.skipExistenceCheck=true for batch operations where the slug is already
|
||
* known to exist (e.g., from a getAllSlugs() snapshot). Duplicates are silently
|
||
* deduplicated by the (page_id, date, summary) UNIQUE index (ON CONFLICT DO NOTHING).
|
||
*/
|
||
/**
|
||
* Insert a timeline entry. By default verifies the page exists and throws if not.
|
||
* `opts.skipExistenceCheck` skips the pre-check for batch loops where the slug
|
||
* is already known to exist. `opts.sourceId` source-scopes both the existence
|
||
* check AND the page-id lookup inside the INSERT — required for multi-source
|
||
* brains where the slug exists in 2+ sources.
|
||
*/
|
||
addTimelineEntry(
|
||
slug: string,
|
||
entry: TimelineInput,
|
||
opts?: { skipExistenceCheck?: boolean; sourceId?: string },
|
||
): Promise<void>;
|
||
/**
|
||
* Bulk insert timeline entries via a single multi-row INSERT...SELECT FROM (VALUES)
|
||
* JOIN pages statement with ON CONFLICT DO NOTHING. Returns the count of rows
|
||
* actually inserted (RETURNING excludes conflicts and JOIN-dropped rows whose
|
||
* slugs don't exist). Used by extract.ts to avoid sequential round-trips.
|
||
*/
|
||
addTimelineEntriesBatch(entries: TimelineBatchInput[]): Promise<number>;
|
||
getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]>;
|
||
|
||
// Raw data
|
||
putRawData(slug: string, source: string, data: object): Promise<void>;
|
||
getRawData(slug: string, source?: string): Promise<RawData[]>;
|
||
|
||
// Files (v0.27.1: binary asset metadata + storage_path. Image bytes never
|
||
// enter the DB; storage_path references a path inside the brain repo or an
|
||
// external store).
|
||
upsertFile(spec: FileSpec): Promise<{ id: number; created: boolean }>;
|
||
getFile(sourceId: string, storagePath: string): Promise<FileRow | null>;
|
||
listFilesForPage(pageId: number): Promise<FileRow[]>;
|
||
|
||
// ============================================================
|
||
// v0.28: Takes (typed/weighted/attributed claims) + synthesis evidence
|
||
// ============================================================
|
||
/**
|
||
* Bulk insert/upsert takes. Uses `unnest()` (Postgres) or manual `$N`
|
||
* placeholders (PGLite). Idempotency: ON CONFLICT (page_id, row_num) DO UPDATE
|
||
* — re-extract on a changed claim/weight updates the row in place.
|
||
* Returns the number of rows inserted OR updated.
|
||
*
|
||
* Weight outside [0, 1] is clamped server-side and surfaces a stderr
|
||
* warning per call (`TAKES_WEIGHT_CLAMPED`). Invalid `kind` values
|
||
* fail the whole batch via the CHECK constraint — caller is responsible
|
||
* for parser validation upstream.
|
||
*/
|
||
addTakesBatch(rows: TakeBatchInput[]): Promise<number>;
|
||
|
||
/** List takes filtered by holder/kind/active/etc. Resolves page_slug via JOIN. */
|
||
listTakes(opts?: TakesListOpts): Promise<Take[]>;
|
||
|
||
/**
|
||
* Keyword search across active takes. Uses pg_trgm similarity over claim text.
|
||
* Honors `takesHoldersAllowList` via WHERE filter so MCP-bound calls cannot
|
||
* retrieve holders outside the token's allow-list.
|
||
*/
|
||
searchTakes(query: string, opts?: SearchOpts & { takesHoldersAllowList?: string[] }): Promise<TakeHit[]>;
|
||
|
||
/**
|
||
* Vector search across active takes. Cosine distance against `embedding`.
|
||
* Skipped (returns []) when no embedding column has been populated yet.
|
||
*/
|
||
searchTakesVector(
|
||
embedding: Float32Array,
|
||
opts?: SearchOpts & { takesHoldersAllowList?: string[] },
|
||
): Promise<TakeHit[]>;
|
||
|
||
/** Look up embeddings by take id (mirrors getEmbeddingsByChunkIds). */
|
||
getTakeEmbeddings(ids: number[]): Promise<Map<number, Float32Array>>;
|
||
|
||
/** Pre-flight count for `gbrain embed --stale`. WHERE active AND embedding IS NULL. */
|
||
countStaleTakes(): Promise<number>;
|
||
|
||
/** List stale takes (no embedding column in payload — same pattern as listStaleChunks). */
|
||
listStaleTakes(): Promise<StaleTakeRow[]>;
|
||
|
||
/**
|
||
* Update a take's mutable fields. May NOT change claim/kind/holder per the
|
||
* supersession invariants — those route through supersedeTake. Throws
|
||
* `TAKE_ROW_NOT_FOUND` when (page_id, row_num) doesn't exist.
|
||
*/
|
||
updateTake(
|
||
pageId: number,
|
||
rowNum: number,
|
||
fields: { weight?: number; since_date?: string; source?: string },
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Supersede the take at (page_id, oldRow). Marks old row active=false +
|
||
* sets superseded_by; appends new row at the next row_num for the page;
|
||
* returns both row_nums. Atomic (transactional). Cycle prevention: if newRow
|
||
* sets superseded_by pointing to a chain that comes back to oldRow, throws
|
||
* `TAKES_SUPERSEDE_CYCLE`. Resolved bets (`resolved_at IS NOT NULL`) cannot
|
||
* be superseded — throws `TAKE_RESOLVED_IMMUTABLE`.
|
||
*/
|
||
supersedeTake(
|
||
pageId: number,
|
||
oldRow: number,
|
||
newRow: Omit<TakeBatchInput, 'page_id' | 'row_num' | 'superseded_by'>,
|
||
): Promise<{ oldRow: number; newRow: number }>;
|
||
|
||
/**
|
||
* Resolve a bet (or take). Sets resolved_* columns. Immutable: re-resolve
|
||
* attempts throw `TAKE_ALREADY_RESOLVED`. Use supersede to express a new bet.
|
||
*
|
||
* v0.30.0: accepts either `quality` (3-state, primary) or `outcome` (boolean,
|
||
* back-compat). When both set, `quality` wins. The engine writes BOTH columns
|
||
* derived from whichever input was given: `quality='correct'/'incorrect'` →
|
||
* `outcome=true/false`; `quality='partial'` → `outcome=NULL`. The schema
|
||
* `takes_resolution_consistency` CHECK constraint catches contradictory
|
||
* states at the DB layer as a defense-in-depth backstop.
|
||
*/
|
||
resolveTake(pageId: number, rowNum: number, resolution: TakeResolution): Promise<void>;
|
||
|
||
/**
|
||
* v0.30.0: aggregate calibration scorecard. Pure SQL aggregation; no LLM.
|
||
* Counts resolved bets, computes accuracy, Brier score (correct+incorrect
|
||
* only), and `partial_rate`. Filtering: `holder` scopes to one identity;
|
||
* `domainPrefix` scopes to a slug-prefix (e.g. `companies/`); `since`/`until`
|
||
* scope to a `since_date` window.
|
||
*
|
||
* Privacy (D4 from plan): `allowList` is REQUIRED in the TS signature.
|
||
* The engine applies `WHERE holder = ANY($allowList)` INSIDE the GROUP BY
|
||
* so hidden-holder rows contribute zero to aggregates. Pass an empty array
|
||
* to enforce zero-results; pass `undefined` only from server-side trusted
|
||
* callers that have already verified the request is unrestricted.
|
||
*/
|
||
getScorecard(opts: TakesScorecardOpts, allowList: string[] | undefined): Promise<TakesScorecard>;
|
||
|
||
/**
|
||
* v0.30.0: calibration curve. Bins resolved correct+incorrect bets by stated
|
||
* weight (default bucket size 0.1) and reports observed vs predicted frequency
|
||
* per bucket. Same allow-list contract as `getScorecard`. Excludes partial
|
||
* (consistent with Brier — partial has no binary outcome to compare against).
|
||
*/
|
||
getCalibrationCurve(opts: CalibrationCurveOpts, allowList: string[] | undefined): Promise<CalibrationBucket[]>;
|
||
|
||
/** Persist think provenance. ON CONFLICT DO NOTHING; returns rows inserted. */
|
||
addSynthesisEvidence(rows: SynthesisEvidenceInput[]): Promise<number>;
|
||
|
||
// Dream-cycle significance verdict cache (v0.23).
|
||
// Keyed by (file_path, content_hash). Distinct from raw_data, which is
|
||
// page-scoped — transcripts being judged aren't pages yet.
|
||
getDreamVerdict(filePath: string, contentHash: string): Promise<DreamVerdict | null>;
|
||
putDreamVerdict(filePath: string, contentHash: string, verdict: DreamVerdictInput): Promise<void>;
|
||
|
||
// ============================================================
|
||
// v0.31 Hot memory — facts table operations
|
||
// ============================================================
|
||
/**
|
||
* Insert a fact into the per-source hot memory. The handler:
|
||
* 1. canonicalizes entity_slug against pages (caller may pre-canonicalize)
|
||
* 2. queries findCandidateDuplicates (entity-prefiltered, k=5 cap)
|
||
* 3. cosine ≥0.95 fast-path → mark duplicate, skip classifier
|
||
* 4. else classifier (caller's job; this engine method handles the
|
||
* DB-side INSERT/UPDATE only). On insert.status === 'duplicate' or
|
||
* 'superseded' the engine returns the existing/superseding row id.
|
||
* Per-entity advisory lock on Postgres serializes the dedup window.
|
||
* PGLite no-op for the lock (single-process).
|
||
*
|
||
* `status` reflects what the engine wrote:
|
||
* 'inserted' → row inserted
|
||
* 'duplicate' → no new row (returns the matching candidate id)
|
||
* 'superseded' → new row inserted; old row got expired_at + superseded_by
|
||
*/
|
||
insertFact(
|
||
input: NewFact,
|
||
ctx: { source_id: string; supersedeId?: number },
|
||
): Promise<{ id: number; status: FactInsertStatus }>;
|
||
|
||
/**
|
||
* Mark a fact expired. Never DELETE. Returns true iff a row was updated.
|
||
* Idempotent-as-false (already expired returns false without changing state).
|
||
*/
|
||
expireFact(id: number, opts?: { supersededBy?: number; at?: Date }): Promise<boolean>;
|
||
|
||
/** List active facts about an entity within a source, newest first. */
|
||
listFactsByEntity(
|
||
source_id: string,
|
||
entitySlug: string,
|
||
opts?: FactListOpts,
|
||
): Promise<FactRow[]>;
|
||
|
||
/** List facts created since a given timestamp within a source. */
|
||
listFactsSince(
|
||
source_id: string,
|
||
since: Date,
|
||
opts?: FactListOpts & { entitySlug?: string },
|
||
): Promise<FactRow[]>;
|
||
|
||
/** List facts captured under a session id within a source. */
|
||
listFactsBySession(
|
||
source_id: string,
|
||
sessionId: string,
|
||
opts?: FactListOpts,
|
||
): Promise<FactRow[]>;
|
||
|
||
/**
|
||
* Audit log: facts that were superseded (expired_at + superseded_by both set),
|
||
* newest first. Drives `gbrain recall --supersessions`.
|
||
*/
|
||
listSupersessions(
|
||
source_id: string,
|
||
opts?: { since?: Date; limit?: number },
|
||
): Promise<FactRow[]>;
|
||
|
||
/**
|
||
* Find candidate duplicates for a new fact within a source+entity bucket.
|
||
* Entity-prefilter is mandatory (bounds the contradiction-classifier blast
|
||
* radius). Hard cap k=5 by default. Embedding-cosine when both sides have
|
||
* embeddings; recency fallback otherwise.
|
||
*/
|
||
findCandidateDuplicates(
|
||
source_id: string,
|
||
entitySlug: string,
|
||
factText: string,
|
||
opts?: { k?: number; embedding?: Float32Array },
|
||
): Promise<FactRow[]>;
|
||
|
||
/**
|
||
* Mark a fact as consolidated into a take. Sets consolidated_at + consolidated_into.
|
||
* Never DELETE — facts stay as audit trail.
|
||
*/
|
||
consolidateFact(id: number, takeId: number): Promise<void>;
|
||
|
||
/** Per-source operational metrics for `gbrain doctor` facts_health check. */
|
||
getFactsHealth(source_id: string): Promise<FactsHealth>;
|
||
|
||
// Versions
|
||
/**
|
||
* Snapshot a page row into page_versions. Source-scoped via `opts.sourceId`;
|
||
* without it the bare-slug lookup snapshots whichever row Postgres returns
|
||
* first when the slug exists across multiple sources.
|
||
*/
|
||
createVersion(slug: string, opts?: { sourceId?: string }): Promise<PageVersion>;
|
||
getVersions(slug: string): Promise<PageVersion[]>;
|
||
revertToVersion(slug: string, versionId: number): Promise<void>;
|
||
|
||
// Stats + health
|
||
getStats(): Promise<BrainStats>;
|
||
getHealth(): Promise<BrainHealth>;
|
||
|
||
// Ingest log
|
||
logIngest(entry: IngestLogInput): Promise<void>;
|
||
getIngestLog(opts?: { limit?: number }): Promise<IngestLogEntry[]>;
|
||
|
||
// Sync
|
||
/**
|
||
* Rename a page's slug (chunks + links + tags + timeline + versions all
|
||
* preserved via stable page_id). `opts.sourceId` scopes the UPDATE — without
|
||
* it, the bare `WHERE slug = old` matches every row across every source and
|
||
* would either rename them all OR violate the (source_id, slug) UNIQUE.
|
||
*/
|
||
updateSlug(oldSlug: string, newSlug: string, opts?: { sourceId?: string }): Promise<void>;
|
||
rewriteLinks(oldSlug: string, newSlug: string): Promise<void>;
|
||
|
||
// Config
|
||
getConfig(key: string): Promise<string | null>;
|
||
setConfig(key: string, value: string): Promise<void>;
|
||
|
||
// Migration support
|
||
runMigration(version: number, sql: string): Promise<void>;
|
||
getChunksWithEmbeddings(slug: string): Promise<Chunk[]>;
|
||
|
||
// Raw SQL (for Minions job queue and other internal modules)
|
||
executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||
|
||
// ============================================================
|
||
// v0.20.0 Cathedral II: code edges (Layer 5 populates, Layer 7 consumes)
|
||
// ============================================================
|
||
/**
|
||
* Bulk-insert code edges. Resolved edges (to_chunk_id set) land in
|
||
* code_edges_chunk; unresolved refs (to_chunk_id null, to_symbol_qualified
|
||
* set) land in code_edges_symbol. ON CONFLICT DO NOTHING handles idempotency.
|
||
* Returns count of rows actually inserted.
|
||
*/
|
||
addCodeEdges(edges: CodeEdgeInput[]): Promise<number>;
|
||
|
||
/**
|
||
* Delete all code edges involving these chunk IDs, in BOTH directions, across
|
||
* both code_edges_chunk and code_edges_symbol. Called by importCodeFile on
|
||
* per-chunk invalidation (codex SP-2): when a chunk's text changed, stale
|
||
* inbound edges from other pages pointing at the old symbol must wipe before
|
||
* new edges write.
|
||
*/
|
||
deleteCodeEdgesForChunks(chunkIds: number[]): Promise<void>;
|
||
|
||
/**
|
||
* "Who calls this symbol?" Returns UNION of code_edges_chunk +
|
||
* code_edges_symbol matching `to_symbol_qualified = qualifiedName`.
|
||
* Source scoping (codex SP-3): if opts.sourceId is set, filter by the
|
||
* anchor chunk's source; if opts.allSources, ignore scoping.
|
||
*/
|
||
getCallersOf(
|
||
qualifiedName: string,
|
||
opts?: { sourceId?: string; allSources?: boolean; limit?: number },
|
||
): Promise<CodeEdgeResult[]>;
|
||
|
||
/**
|
||
* "What does this symbol call?" Returns edges from chunks whose
|
||
* from_symbol_qualified = qualifiedName. Same source-scoping semantics
|
||
* as getCallersOf.
|
||
*/
|
||
getCalleesOf(
|
||
qualifiedName: string,
|
||
opts?: { sourceId?: string; allSources?: boolean; limit?: number },
|
||
): Promise<CodeEdgeResult[]>;
|
||
|
||
/**
|
||
* All edges touching a chunk in the given direction. Used by A2 two-pass
|
||
* retrieval to expand from anchor chunks. direction='in' returns edges
|
||
* pointing AT the chunk; 'out' returns edges FROM it; 'both' unions.
|
||
*/
|
||
getEdgesByChunk(
|
||
chunkId: number,
|
||
opts?: { direction?: 'in' | 'out' | 'both'; edgeType?: string; limit?: number },
|
||
): Promise<CodeEdgeResult[]>;
|
||
|
||
/**
|
||
* Chunk-grain keyword search. Ranks by content_chunks.search_vector
|
||
* without the dedup-to-page pass that searchKeyword applies. Consumed
|
||
* by A2 two-pass retrieval as its anchor source. Most callers should
|
||
* prefer searchKeyword (external contract: page-grain best-chunk-per-page).
|
||
*/
|
||
searchKeywordChunks(query: string, opts?: SearchOpts): Promise<SearchResult[]>;
|
||
|
||
// Eval capture (v0.25.0 — BrainBench-Real substrate).
|
||
// Captured at the op-layer wrapper in src/core/operations.ts; reads via
|
||
// `gbrain eval export` (NDJSON) for sibling gbrain-evals consumption.
|
||
// Adding these to BrainEngine is a breaking-interface change for third-
|
||
// party engine implementers — this is why v0.25.0 is a minor bump.
|
||
/** Insert a captured candidate. Returns the new row id. Best-effort: callers swallow failures and route them through `logEvalCaptureFailure`. */
|
||
logEvalCandidate(input: EvalCandidateInput): Promise<number>;
|
||
/** Read candidates by time window / limit / tool filter. Used by `gbrain eval export`. */
|
||
listEvalCandidates(filter?: { since?: Date; limit?: number; tool?: 'query' | 'search' }): Promise<EvalCandidate[]>;
|
||
/** Delete candidates created before `date`. Returns rows deleted. Used by `gbrain eval prune`. */
|
||
deleteEvalCandidatesBefore(date: Date): Promise<number>;
|
||
/** Log a capture failure so `gbrain doctor` can surface drops cross-process. Best-effort; symmetric with logEvalCandidate (failure-of-failure is lost). */
|
||
logEvalCaptureFailure(reason: EvalCaptureFailureReason): Promise<void>;
|
||
/** Read capture failures within an optional time window. Used by `gbrain doctor`. */
|
||
listEvalCaptureFailures(filter?: { since?: Date }): Promise<EvalCaptureFailure[]>;
|
||
|
||
// ============================================================
|
||
// v0.29 — Salience + Anomaly Detection
|
||
// ============================================================
|
||
// The brain surfaces what's unusual and emotionally charged without being
|
||
// asked. Cost: ~zero at query time (deterministic SQL), with backfill done
|
||
// during the new `recompute_emotional_weight` cycle phase.
|
||
|
||
/**
|
||
* Batch-load tag + take inputs for the emotional-weight formula. One CTE-shaped
|
||
* query: `pages` LEFT JOIN aggregated `tags` and aggregated `takes` (each
|
||
* pre-aggregated in its own CTE so the page × N tags × M takes cartesian
|
||
* product is avoided).
|
||
*
|
||
* If `slugs` is undefined, returns inputs for every page in the brain
|
||
* (full-mode backfill). If provided, returns only matching slugs (incremental
|
||
* recompute after sync / synthesize touched specific pages).
|
||
*
|
||
* Multi-source-aware: each row carries its `source_id` so the matching
|
||
* `setEmotionalWeightBatch` UPDATE can composite-key correctly.
|
||
*/
|
||
batchLoadEmotionalInputs(slugs?: string[]): Promise<EmotionalWeightInputRow[]>;
|
||
|
||
/**
|
||
* Apply pre-computed emotional weights in a single UPDATE. Composite-keyed
|
||
* on `(slug, source_id)` because `pages.slug` is only unique within a
|
||
* source — a slug-only UPDATE would fan out across sources, the same bug
|
||
* that the v0.18.0 link batches fixed for cross-source edges.
|
||
*
|
||
* Returns the count of rows actually updated. Pages whose `(slug, source_id)`
|
||
* tuple doesn't exist (race with delete) are silently skipped.
|
||
*/
|
||
setEmotionalWeightBatch(rows: EmotionalWeightWriteRow[]): Promise<number>;
|
||
|
||
/**
|
||
* Salience query: pages recently touched, ranked by a deterministic
|
||
* `(emotional_weight * 5) + ln(1 + take_count) + recency_decay` score.
|
||
*
|
||
* The handler computes the time boundary in JS (`now - days * 86400000`)
|
||
* and binds it as TIMESTAMPTZ so the SQL is identical across PGLite +
|
||
* Postgres (eng review D5 — avoids dialect drift on `interval` binding).
|
||
*/
|
||
getRecentSalience(opts: SalienceOpts): Promise<SalienceResult[]>;
|
||
|
||
/**
|
||
* Anomaly detection: cohorts (tag, type) with unusually-high page activity
|
||
* on a target day vs baseline mean+stddev over the previous N days. Year
|
||
* cohort is deferred to v0.30 (slug-regex year extraction is fragile).
|
||
*
|
||
* Baseline densifies the day series via `generate_series` zero-fill so
|
||
* sparse-day rare cohorts don't look "normally active" — a sparse-day cohort
|
||
* with one touch in 30 days has a low baseline mean and high sigma at 7 touches,
|
||
* not a misleading mean of 1.
|
||
*/
|
||
findAnomalies(opts: AnomaliesOpts): Promise<AnomalyResult[]>;
|
||
}
|