* fix(doctor,entities): supervisor crash classification + bare-name resolver + stub guard - doctor.ts/jobs.ts: classify worker exits with code !== 0 as real crashes vs code === 0 clean restarts (separate counter); fixes false-positive WARN on healthy supervisors - entities/resolve.ts: prefix-expansion step between fuzzy match and slugify fallback catches bare first names that score too low on pg_trgm; picks highest-connection candidate as tiebreaker - facts/fence-write.ts: stub-creation guard refuses to spawn unprefixed entity pages at brain root - facts/backstop.ts: routes stubGuardBlocked facts to engine.insertFact so the fact still persists even when no markdown file is created - docs/issues/doctor-auto-heal-and-scoring.md: spec for follow-up doctor health-score improvements - .gitignore: guard reports/network-intelligence/ (private brain exports) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(privacy): scrub real names from entity-resolve test fixtures and JSDoc Replace YC partner names with placeholders per CLAUDE.md privacy rule: alice-example, bob-example, charlie-example, dave-example. Stripe and Stripe Atlas retained (allowed household brands; exercises the two-word company-prefix case). Test semantics preserved: - Alice / Dave: single-match cases - Bob / Charlie: multi-match tiebreaker cases (winner has more chunks) All 13 entity-resolve cases pass with the scrubbed fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(supervisor): extract classifyWorkerExit() helper (DRY) Three call sites were inline-classifying worker exits: supervisor's restart policy (child-worker-supervisor.ts:291), doctor's supervisor check (doctor.ts:1016), and jobs supervisor status (jobs.ts:806). Same rule, three copies — drift risk if one is updated without the others. Extract to src/core/minions/exit-classification.ts as a pure function. Signature consumes audit-JSON shape ({ code: number | null }) so doctor and jobs (which read serialized events from JSONL) and supervisor (which reads Node's exit callback) call the same function. Helper's classification rule: code === 0 → clean_exit, everything else (non-zero, null, undefined, missing) → crash. Default-to-crash prevents corrupted rows from silently demoting into the clean-restart bucket. 5 hermetic unit tests (test/exit-classification.test.ts) pin all edge cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(facts): audit + sunset comment for stub-guard fires Wire telemetry into the v0.34.5 stub-guard at fence-write.ts:190. Every guard fire now appends a JSONL line to ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl with {ts, slug, source_id, fact_count}. Operator visibility for the sunset criterion: when the new audit log reads <5 hits/week for 3 consecutive weeks on production brains, the prefix-expansion in resolveEntitySlug is sufficient and the guard can be removed in v0.36. Reader (readRecentStubGuardEvents) deliberately diverges from supervisor-audit.ts:readSupervisorEvents — it reads BOTH the current AND previous ISO-week file before filtering by ts. supervisor-audit's reader only reads the current week, which loses 24h-window correctness across Monday 00:00 UTC (a Sunday 23:55 event lives in last week's file). The 2-file read costs nothing and makes the window actually 24h. 9 hermetic unit tests pin filename math, the writer's swallows-errors contract, the cross-week-boundary read, sort order, missing-file behavior, and malformed-row tolerance. The cross-week test is the regression guard: if a future refactor copies the supervisor's single-file pattern, that test fails. Follow-up TODO (not in this PR): fix readSupervisorEvents to use the same 2-file pattern. The new stub-guard reader becomes the canonical template to copy back. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): stub_guard_24h check surfaces resolver gaps Adds a new doctor check that reads ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl (via the dual-week-aware reader from T8) and surfaces the 24h fire count. WARN at >10 fires — at that rate the prefix-expansion in resolveEntitySlug is probably missing a case (typo prefix, alias, non-Latin script) and operators should grep the audit log for the offending slugs. Below the threshold but non-zero shows as OK with a count, so operators can watch the v0.36 sunset criterion (<5/week for 3 weeks → guard can be removed). Zero hits emits no check, keeping the doctor output clean on healthy brains. 5 source-grep regression tests pin the contract: check name, WARN threshold, fix hint mentions the audit log + the resolver function name, reader is the dual-week-aware variant (NOT the supervisor-audit single- week pattern), and zero-hits stays silent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(facts): pin stub-guard contract at writeFactsToFence + backstop layers - fence-write.test.ts: 3 new cases for the v0.34.5 stub guard. Bare slugs return {inserted: 0, stubGuardBlocked: true, ids: []} and create no file/.tmp at brain root. Prefixed slugs bypass the guard (regression guard against accidentally inverting the slug.includes('/') check). Empty facts array short-circuits before the guard fires. - facts-backstop.test.ts: 1 new case for the end-to-end routing. A bare-name LLM extraction resolves through to a bare slug, hits the guard, and lands in the facts table via engine.insertFact (DB-only). No phantom .md file; entity_slug stores the bare slug; source_markdown_slug is null. This is the routing contract Codex flagged as a "split-brain" data shape — the test pins the by-design behavior so a future refactor can't silently drop these facts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(supervisor): pin classifyWorkerExit consumer wire-up + regressions 12 new cases on top of the 5 helper unit tests: - doctor.ts / jobs.ts / child-worker-supervisor.ts each import the helper - All three call classifyWorkerExit at least once - doctor.ts and jobs.ts no longer carry the pre-T7 inline filter - supervisor uses the helper result to choose the clean_exit branch - audit-event shape round-trip: code=0 → clean_exit, code=1 → crash, code=null+SIGKILL → crash (catches future shape changes) The regression guards (3) and the wire-up checks (6) close the gap that motivated T7 in the first place: if a future change accidentally re-inlines the filter or shifts the audit event shape, the test fails before production sees the silent divergence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(entities): correlated subqueries scoped to slug-LIKE candidates Replace the derived-table JOIN shape in tryPrefixExpansion with correlated subqueries. The pre-fix SQL did LEFT JOIN (SELECT to_page_id, COUNT(*) FROM links GROUP BY to_page_id) li ON ... which forced the planner to aggregate the entire links + content_chunks tables on every prefix-expansion call — O(N) per call where N is total links/chunks in the brain. On a 100K-link / 50K-chunk brain that's slow enough to bottleneck fact-extraction. New shape uses correlated subqueries: (SELECT COUNT(*) FROM links WHERE to_page_id = p.id) + (SELECT COUNT(*) FROM links WHERE from_page_id = p.id) + (SELECT COUNT(*) FROM content_chunks WHERE page_id = p.id) The slug LIKE filter is already selective (typical brain has 0-5 pages per prefix), so the three subqueries run N≈3 times per matched row against the existing indexes on links.to_page_id, links.from_page_id, and content_chunks.page_id. Behavior preserved: 13/13 entity-resolve tests pass (single-match + multi-match tiebreaker + edge cases). Codex's outside-voice review caught the dead-end design that an earlier draft of this plan proposed (a CTE with `LIMIT 50` candidate cap — would have excluded correct high-connection candidates if their slug sorted late). Correlated subqueries without a candidate cap are the cleaner shape that lets the LIKE filter do the bounding work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(entities): perf regression guard for prefix-expansion (58x speedup) Hermetic PGLite benchmark with 5K pages + 50K links + 25K chunks. Runs the pre-T12 derived-table shape and the new correlated-subquery shape side-by-side against the same fixture, asserts NEW >= 5x faster than OLD. Baseline-ratio, not absolute wall-clock — different machines / Bun versions / CI load can shift absolute timings by 10x without indicating a real regression, but the SHAPE difference between "aggregate the full tables" and "correlated subquery per candidate" is what we care about. Measured: old_median=18.16ms, new_median=0.31ms, speedup=58.22x. The 5x assertion has plenty of headroom. The OLD SQL is embedded verbatim as the regression baseline. If a future refactor re-introduces full-table aggregation (LEFT JOIN against SELECT...GROUP BY over the whole links or content_chunks table), the test fails. PGLite-only — Postgres planner can shape derived-table JOINs differently enough that the 5x ratio could be noise on a 5K-page fixture. The structural correctness of the rewrite is the same on both; this is purely a planner-shape regression guard. .slow.test.ts suffix keeps it out of the fast loop (run via `bun run test:slow`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.35.2.0) Wave content: - Privacy scrub: PII rebuilt out of branch history; real names → placeholders - Bug fix: doctor + jobs no longer count clean worker exits as crashes - Bug fix: entity resolver prefix-expansion catches bare first names - DRY refactor: classifyWorkerExit() helper (one rule, 3 call sites) - Observability: stub_guard_24h doctor check + ISO-week audit log - Perf: 58x speedup on tryPrefixExpansion query shape Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: rebump v0.35.2.0 → v0.35.4.0 + scrub TODOS.md privacy violation VERSION/package.json/CHANGELOG header rebumped to v0.35.4.0 per user request (queue allocation). TODOS.md rephrased to not literally name the banned private-agent string — that was the CI failure root cause on the v0.35.2.0 push. CHANGELOG.md is on check-privacy.sh's allow-list (meta-documentation exception); TODOS.md is not. CI re-runs against this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
500 lines
19 KiB
TypeScript
500 lines
19 KiB
TypeScript
/**
|
|
* v0.31.2 — runFactsBackstop: shared facts pipeline used by every brain
|
|
* write surface that wants real-time hot memory extraction.
|
|
*
|
|
* Encapsulates the v0.31 smart pipeline:
|
|
*
|
|
* extract (extractFactsFromTurn — sanitize + LLM + parser fixed in B1)
|
|
* ↓
|
|
* resolve (resolveEntitySlug — canonicalize free-form entity refs)
|
|
* ↓
|
|
* dedup (findCandidateDuplicates + cosineSimilarity @ 0.95)
|
|
* ↓
|
|
* insert (engine.insertFact with supersede support)
|
|
*
|
|
* Replaces five divergent implementations (put_page hook, extract_facts
|
|
* MCP op, sync.ts post-import block, file_upload, code_import) with one
|
|
* choke point. Eligibility runs through `isFactsBackstopEligible` from
|
|
* src/core/facts/eligibility.ts; kill-switch via `isFactsExtractionEnabled`.
|
|
*
|
|
* Two execution modes (D8 from /plan-eng-review):
|
|
*
|
|
* - 'queue' (default): fire-and-forget via `getFactsQueue().enqueue`.
|
|
* Caller's await is ~zero (just the enqueue + microtask schedule).
|
|
* Used by sync, put_page, file_upload, code_import. Sync stays fast
|
|
* even on a 50-page batch.
|
|
*
|
|
* - 'inline': await the full pipeline; return real {inserted, duplicate,
|
|
* superseded, fact_ids} counts. Used by the explicit extract_facts
|
|
* MCP op so tool-call responses carry truthful numbers.
|
|
*
|
|
* Notability filter (D4): per-caller policy via FactsBackstopCtx.notabilityFilter.
|
|
* Sync passes 'high-only' (HIGH lands now, MEDIUM waits for the dream
|
|
* cycle, LOW dropped at LLM layer). Other surfaces default to 'all'.
|
|
*
|
|
* Failure modes route to ingest_log (D5) via writeFactsAbsorbLog (lands
|
|
* in PR1 commit 13). For PR1 commit 6 the absorb writer is a placeholder;
|
|
* commit 13 wires it.
|
|
*/
|
|
|
|
import type { BrainEngine, FactInsertStatus, NewFact } from '../engine.ts';
|
|
import { isFactsBackstopEligible } from './eligibility.ts';
|
|
import type { PageType } from '../types.ts';
|
|
|
|
export interface FactsBackstopCtx {
|
|
engine: BrainEngine;
|
|
/** Brain source identifier; default 'default'. */
|
|
sourceId: string;
|
|
/** source_session for provenance; null if absent. */
|
|
sessionId: string | null;
|
|
/**
|
|
* Provenance source string written into facts.source. Stable values:
|
|
* - 'sync:import' — git sync post-import hook
|
|
* - 'mcp:put_page' — MCP put_page backstop
|
|
* - 'mcp:extract_facts' — explicit MCP op (inline mode)
|
|
* - 'file_upload' — file_upload import path
|
|
* - 'code_import' — code import path
|
|
*/
|
|
source: 'sync:import' | 'mcp:put_page' | 'mcp:extract_facts' | 'file_upload' | 'code_import';
|
|
/** Execution mode — D8. Default 'queue' (fire-and-forget). */
|
|
mode?: 'queue' | 'inline';
|
|
/** Notability filter — D4. Default 'all'; sync uses 'high-only'. */
|
|
notabilityFilter?: 'all' | 'high-only';
|
|
/** Abort signal for shutdown propagation. */
|
|
abortSignal?: AbortSignal;
|
|
/** Mirrors OperationContext.remote for trust-aware logging paths. */
|
|
remote?: boolean;
|
|
/** Optional entity hints (extract_facts MCP op forwards these). */
|
|
entityHints?: string[];
|
|
/** Optional visibility tier (default 'private'). extract_facts forwards `world` when caller asks. */
|
|
visibility?: 'private' | 'world';
|
|
/** Override the chat model (extract_facts forwards user's model param when set). */
|
|
model?: string;
|
|
}
|
|
|
|
/** Discriminated return shape based on FactsBackstopCtx.mode. */
|
|
export type FactsBackstopResult =
|
|
| {
|
|
mode: 'queue';
|
|
enqueued: boolean;
|
|
queueDepth: number;
|
|
skipped?: 'extraction_disabled' | 'queue_overflow' | 'queue_shutdown' | `eligibility_failed:${string}`;
|
|
}
|
|
| {
|
|
mode: 'inline';
|
|
inserted: number;
|
|
duplicate: number;
|
|
superseded: number;
|
|
fact_ids: number[];
|
|
skipped?: 'extraction_disabled' | `eligibility_failed:${string}`;
|
|
};
|
|
|
|
interface ParsedPageInput {
|
|
slug: string;
|
|
type: PageType;
|
|
compiled_truth: string;
|
|
frontmatter: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Cosine similarity threshold for the dedup fast-path. Matches the existing
|
|
* extract_facts op behavior at operations.ts:2460. Higher = stricter
|
|
* dedup (more rows kept distinct); lower = looser (more rows treated as
|
|
* duplicates of older ones).
|
|
*/
|
|
const DEDUP_THRESHOLD = 0.95;
|
|
|
|
/** k for findCandidateDuplicates — ceiling on candidates considered. */
|
|
const DEDUP_CANDIDATE_LIMIT = 5;
|
|
|
|
/**
|
|
* Once-per-process stderr warning memo. v0.32.2 uses this to surface
|
|
* the thin-client / no-local_path fallback without spamming a warning
|
|
* on every put_page in a long-running brain.
|
|
*/
|
|
const _warnedKeys = new Set<string>();
|
|
function warnOnce(key: string, msg: string): void {
|
|
if (_warnedKeys.has(key)) return;
|
|
_warnedKeys.add(key);
|
|
// eslint-disable-next-line no-console
|
|
console.warn(msg);
|
|
}
|
|
/** Test-only: reset the once-per-process warning memo. */
|
|
export function __resetBackstopWarningsForTests(): void {
|
|
_warnedKeys.clear();
|
|
}
|
|
|
|
/**
|
|
* Run the facts pipeline for one page write. See module docstring for
|
|
* the full lifecycle and mode semantics.
|
|
*
|
|
* Re-throws AbortError; absorbs gateway/parse/queue errors as
|
|
* `skipped: '...'` envelope (operator visibility lands via PR1 commit 13's
|
|
* ingest_log writer).
|
|
*/
|
|
export async function runFactsBackstop(
|
|
parsedPage: ParsedPageInput,
|
|
ctx: FactsBackstopCtx,
|
|
): Promise<FactsBackstopResult> {
|
|
const mode = ctx.mode ?? 'queue';
|
|
|
|
// --- Eligibility + kill-switch gates (run before any LLM cost) ---
|
|
const { isFactsExtractionEnabled } = await import('./extract.ts');
|
|
const enabled = await isFactsExtractionEnabled(ctx.engine);
|
|
if (!enabled) {
|
|
return mode === 'queue'
|
|
? { mode: 'queue', enqueued: false, queueDepth: 0, skipped: 'extraction_disabled' }
|
|
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped: 'extraction_disabled' };
|
|
}
|
|
|
|
const eligible = isFactsBackstopEligible(parsedPage.slug, parsedPage);
|
|
if (!eligible.ok) {
|
|
const skipped = `eligibility_failed:${eligible.reason}` as const;
|
|
return mode === 'queue'
|
|
? { mode: 'queue', enqueued: false, queueDepth: 0, skipped }
|
|
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped };
|
|
}
|
|
|
|
// --- Mode dispatch ---
|
|
if (mode === 'queue') {
|
|
const { getFactsQueue } = await import('./queue.ts');
|
|
const queue = getFactsQueue();
|
|
const enqueued = queue.enqueue(async (signal) => {
|
|
// v0.31.2 (PR1 commit 13): facts:absorb writer wired here. Errors
|
|
// inside the queue worker were previously invisible (queue counter
|
|
// increments only). Now they land in ingest_log so doctor +
|
|
// dashboard surface failure modes per source.
|
|
try {
|
|
await runPipeline(parsedPage, ctx, signal);
|
|
} catch (err) {
|
|
const { classifyFactsAbsorbError, writeFactsAbsorbLog } = await import('./absorb-log.ts');
|
|
const reason = classifyFactsAbsorbError(err);
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
await writeFactsAbsorbLog(ctx.engine, parsedPage.slug, reason, msg, ctx.sourceId);
|
|
}
|
|
}, ctx.sessionId ?? parsedPage.slug);
|
|
|
|
if (enqueued < 0) {
|
|
// -1 means the queue is shutting down OR cap-overflow drop fired.
|
|
// Caller can disambiguate via getCounters() if they care; for now
|
|
// collapse to a single skipped reason and record the absorb event.
|
|
const { writeFactsAbsorbLog } = await import('./absorb-log.ts');
|
|
await writeFactsAbsorbLog(
|
|
ctx.engine,
|
|
parsedPage.slug,
|
|
'queue_overflow',
|
|
`queue capacity hit; enqueue dropped (sessionId=${ctx.sessionId ?? parsedPage.slug})`,
|
|
ctx.sourceId,
|
|
);
|
|
return { mode: 'queue', enqueued: false, queueDepth: 0, skipped: 'queue_overflow' };
|
|
}
|
|
return { mode: 'queue', enqueued: true, queueDepth: enqueued };
|
|
}
|
|
|
|
// 'inline' mode: caller awaits the full pipeline. Errors bubble to the
|
|
// caller — extract_facts MCP op surfaces them as op-error responses
|
|
// (the explicit-call contract). Unlike queue mode, we don't absorb-log
|
|
// here because the caller decides whether the failure is interesting
|
|
// enough to record (vs. retry, vs. surface directly to the user).
|
|
const r = await runPipeline(parsedPage, ctx, ctx.abortSignal);
|
|
return { mode: 'inline', ...r };
|
|
}
|
|
|
|
/**
|
|
* Public pipeline entry-point — extract → resolve → dedup → insert.
|
|
*
|
|
* Used by:
|
|
* - runFactsBackstop (above) — wraps with eligibility + kill-switch
|
|
* gates and queue-mode dispatch.
|
|
* - extract_facts MCP op — calls directly with raw turn_text. The op
|
|
* is an explicit user request, not a page-write hook, so eligibility
|
|
* doesn't apply (no slug, no PageType, no frontmatter). Operator-
|
|
* level visibility filter (private vs world) and kill-switch gating
|
|
* are the op's responsibility.
|
|
*
|
|
* Inputs come from extractFactsFromTurn — the LLM extractor — but this
|
|
* function itself is shape-agnostic: it takes a `turnText` and the same
|
|
* FactsBackstopCtx used elsewhere. AbortError re-thrown; gateway / parse
|
|
* / DB errors bubble (caller decides whether to absorb).
|
|
*/
|
|
export async function runFactsPipeline(
|
|
turnText: string,
|
|
ctx: FactsBackstopCtx,
|
|
): Promise<{ inserted: number; duplicate: number; superseded: number; fact_ids: number[] }> {
|
|
return runPipelineWithBody({
|
|
turnText,
|
|
isDreamGenerated: false,
|
|
}, ctx, ctx.abortSignal);
|
|
}
|
|
|
|
/**
|
|
* Internal pipeline: extract → resolve → dedup → insert. Pure work
|
|
* (no eligibility/kill-switch gates — those run upstream in the
|
|
* exported entry point).
|
|
*
|
|
* Returns count envelope for inline-mode callers; queue-mode callers
|
|
* discard the return value (the queue worker only cares that the
|
|
* promise settled).
|
|
*/
|
|
async function runPipeline(
|
|
parsedPage: ParsedPageInput,
|
|
ctx: FactsBackstopCtx,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<{ inserted: number; duplicate: number; superseded: number; fact_ids: number[] }> {
|
|
return runPipelineWithBody(
|
|
{
|
|
turnText: parsedPage.compiled_truth,
|
|
isDreamGenerated: false, // eligibility check already rejected dream pages
|
|
},
|
|
ctx,
|
|
abortSignal,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inner pipeline body. Shared between runFactsBackstop (page-shape entry)
|
|
* and runFactsPipeline (raw turn-text entry). Eligibility + kill-switch
|
|
* are upstream of this; we just extract → resolve → dedup → write fence
|
|
* → stamp DB.
|
|
*
|
|
* v0.32.2 (Codex R2-#2): markdown-first rewrite. Both this function's
|
|
* callers route through here, so making the write path fence-first here
|
|
* makes BOTH runFactsBackstop AND runFactsPipeline canonical without
|
|
* changing either entry-point signature.
|
|
*
|
|
* Pipeline:
|
|
* 1. extract (extractFactsFromTurn — sanitize + LLM + parser)
|
|
* 2. resolve (resolveEntitySlug — canonicalize free-form entity refs)
|
|
* 3. dedup (findCandidateDuplicates + cosineSimilarity @ 0.95)
|
|
* 4. write (writeFactsToFence → markdown atomic write + engine.insertFacts)
|
|
*
|
|
* Step 4 falls through to legacy single-row engine.insertFact when the
|
|
* brain has no sources.local_path configured (thin-client install). A
|
|
* once-per-process stderr warning names the missing config so operators
|
|
* see the degraded mode at boot.
|
|
*
|
|
* Facts with no resolved entity_slug structurally can't be fenced (no
|
|
* entity page to fence them on), so they take the same legacy DB-only
|
|
* fallback regardless of local_path.
|
|
*/
|
|
async function runPipelineWithBody(
|
|
input: { turnText: string; isDreamGenerated: boolean },
|
|
ctx: FactsBackstopCtx,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<{ inserted: number; duplicate: number; superseded: number; fact_ids: number[] }> {
|
|
const { extractFactsFromTurn } = await import('./extract.ts');
|
|
const { resolveEntitySlug } = await import('../entities/resolve.ts');
|
|
const { cosineSimilarity } = await import('./classify.ts');
|
|
const { writeFactsToFence, lookupSourceLocalPath } = await import('./fence-write.ts');
|
|
|
|
if (abortSignal?.aborted) {
|
|
return { inserted: 0, duplicate: 0, superseded: 0, fact_ids: [] };
|
|
}
|
|
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: input.turnText,
|
|
sessionId: ctx.sessionId,
|
|
entityHints: ctx.entityHints,
|
|
source: ctx.source,
|
|
isDreamGenerated: input.isDreamGenerated,
|
|
engine: ctx.engine,
|
|
abortSignal,
|
|
model: ctx.model,
|
|
});
|
|
|
|
const filter = ctx.notabilityFilter ?? 'all';
|
|
const visibility = ctx.visibility ?? 'private';
|
|
|
|
let inserted = 0;
|
|
let duplicate = 0;
|
|
let superseded = 0;
|
|
const fact_ids: number[] = [];
|
|
|
|
// Phase 1: per-fact filter + dedup. Surviving facts (no dedup hit)
|
|
// get grouped by entity_slug for the fence-write phase below.
|
|
type SurvivedFact = {
|
|
f: typeof facts[number];
|
|
resolvedSlug: string | null;
|
|
};
|
|
const survived: SurvivedFact[] = [];
|
|
|
|
for (const f of facts) {
|
|
if (abortSignal?.aborted) break;
|
|
|
|
// D4: notability filter applied post-extraction, pre-insert.
|
|
if (filter === 'high-only' && f.notability !== 'high') continue;
|
|
|
|
const resolvedSlug = f.entity_slug
|
|
? await resolveEntitySlug(ctx.engine, ctx.sourceId, f.entity_slug)
|
|
: null;
|
|
|
|
// Dedup against DB candidates (correct per Codex Q7: fence rows
|
|
// have no embeddings; FS lock + sync invariant means DB == fence
|
|
// at write time). Threshold 0.95 unchanged.
|
|
let matchedExistingId: number | null = null;
|
|
if (resolvedSlug && f.embedding) {
|
|
const candidates = await ctx.engine.findCandidateDuplicates(
|
|
ctx.sourceId,
|
|
resolvedSlug,
|
|
f.fact,
|
|
{ embedding: f.embedding, k: DEDUP_CANDIDATE_LIMIT },
|
|
);
|
|
let topId: number | null = null;
|
|
let topScore = -1;
|
|
for (const c of candidates) {
|
|
if (!c.embedding) continue;
|
|
const s = cosineSimilarity(f.embedding, c.embedding);
|
|
if (s > topScore) { topScore = s; topId = c.id; }
|
|
}
|
|
if (topId !== null && topScore >= DEDUP_THRESHOLD) {
|
|
matchedExistingId = topId;
|
|
}
|
|
}
|
|
|
|
if (matchedExistingId !== null) {
|
|
duplicate += 1;
|
|
fact_ids.push(matchedExistingId);
|
|
continue;
|
|
}
|
|
|
|
survived.push({ f, resolvedSlug });
|
|
}
|
|
|
|
if (survived.length === 0) {
|
|
return { inserted, duplicate, superseded, fact_ids };
|
|
}
|
|
|
|
// Phase 2: group survived facts by resolved entity_slug. Facts with
|
|
// no resolved slug go to a special legacy bucket.
|
|
const byEntity = new Map<string, SurvivedFact[]>();
|
|
const unparented: SurvivedFact[] = [];
|
|
for (const s of survived) {
|
|
if (s.resolvedSlug === null) {
|
|
unparented.push(s);
|
|
} else {
|
|
const list = byEntity.get(s.resolvedSlug) ?? [];
|
|
list.push(s);
|
|
byEntity.set(s.resolvedSlug, list);
|
|
}
|
|
}
|
|
|
|
// Phase 3: look up source.local_path once for the fence path. Null
|
|
// means thin-client / no FS — fall through to legacy DB-only for
|
|
// every fact.
|
|
const localPath = await lookupSourceLocalPath(ctx.engine, ctx.sourceId);
|
|
|
|
// Phase 4: legacy DB-only fallback for unparented + thin-client.
|
|
// Single-row engine.insertFact preserves the v0.31 semantics for
|
|
// these structurally-unfenceable cases.
|
|
const legacyBucket: SurvivedFact[] = [];
|
|
if (localPath === null) {
|
|
warnOnce(
|
|
'facts:thin-client-fallback',
|
|
'[facts] sources.local_path unset for source_id=' + ctx.sourceId +
|
|
' — falling through to DB-only inserts. Configure local_path via `gbrain sources update` to enable system-of-record fence writes.',
|
|
);
|
|
for (const s of survived) legacyBucket.push(s);
|
|
} else {
|
|
for (const s of unparented) legacyBucket.push(s);
|
|
}
|
|
|
|
for (const { f, resolvedSlug } of legacyBucket) {
|
|
const newFact: NewFact = {
|
|
fact: f.fact,
|
|
kind: f.kind,
|
|
entity_slug: resolvedSlug,
|
|
visibility,
|
|
notability: f.notability,
|
|
source: f.source,
|
|
source_session: f.source_session ?? null,
|
|
confidence: f.confidence,
|
|
embedding: f.embedding ?? null,
|
|
};
|
|
const result = await ctx.engine.insertFact(newFact, { source_id: ctx.sourceId }); // gbrain-allow-direct-insert: legacy DB-only fallback for unparented / thin-client facts (no entity page to fence onto)
|
|
fact_ids.push(result.id);
|
|
if (result.status === 'inserted') inserted += 1;
|
|
else if ((result.status as FactInsertStatus) === 'duplicate') duplicate += 1;
|
|
else superseded += 1;
|
|
}
|
|
|
|
if (localPath === null) {
|
|
// All went through legacy bucket; nothing left to fence.
|
|
return { inserted, duplicate, superseded, fact_ids };
|
|
}
|
|
|
|
// Phase 5: fence-write per entity. writeFactsToFence handles the
|
|
// page lock, stub-create, atomic .tmp+parse+rename, and the
|
|
// engine.insertFacts batch.
|
|
for (const [slug, group] of byEntity) {
|
|
if (abortSignal?.aborted) break;
|
|
|
|
const inputFacts = group.map(({ f }) => ({
|
|
fact: f.fact,
|
|
kind: f.kind,
|
|
notability: f.notability,
|
|
source: f.source,
|
|
context: null,
|
|
visibility,
|
|
confidence: f.confidence,
|
|
validFrom: f.valid_from ?? new Date(),
|
|
embedding: f.embedding ?? null,
|
|
sessionId: f.source_session ?? null,
|
|
}));
|
|
|
|
const result = await writeFactsToFence(
|
|
ctx.engine,
|
|
{ sourceId: ctx.sourceId, localPath, slug },
|
|
inputFacts,
|
|
);
|
|
|
|
if (result.fenceWriteFailed) {
|
|
// Fence parse-validate rejected the .tmp; .tmp stays as
|
|
// quarantine. The JSONL log is the operator surface. Treat
|
|
// every fact in this entity group as not-inserted (no fact_id
|
|
// returned). Do NOT fall through to legacy DB-only — that
|
|
// would write rows to a DB index whose fence is broken.
|
|
continue;
|
|
}
|
|
if (result.stubGuardBlocked) {
|
|
// v0.34.5: writeFactsToFence refused to spawn a phantom
|
|
// unprefixed entity page (e.g. `jared.md` at brain root).
|
|
// Route these facts to the legacy DB-only path so they
|
|
// aren't dropped — the slug stays attached but no markdown
|
|
// file is created.
|
|
for (const { f } of group) {
|
|
const newFact: NewFact = {
|
|
fact: f.fact,
|
|
kind: f.kind,
|
|
entity_slug: slug,
|
|
visibility,
|
|
notability: f.notability,
|
|
source: f.source,
|
|
source_session: f.source_session ?? null,
|
|
confidence: f.confidence,
|
|
embedding: f.embedding ?? null,
|
|
};
|
|
const legacyResult = await ctx.engine.insertFact(newFact, { source_id: ctx.sourceId }); // gbrain-allow-direct-insert: stub-guard fallback for unprefixed entity slugs (no fenceable page)
|
|
fact_ids.push(legacyResult.id);
|
|
if (legacyResult.status === 'inserted') inserted += 1;
|
|
else if ((legacyResult.status as FactInsertStatus) === 'duplicate') duplicate += 1;
|
|
else superseded += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (result.legacyFallback) {
|
|
// Defensive: writeFactsToFence sees localPath as null. We
|
|
// checked above so this shouldn't fire — log loud + skip.
|
|
warnOnce(
|
|
'facts:fence-write-unexpected-fallback',
|
|
`[facts] writeFactsToFence returned legacyFallback for slug=${slug} despite localPath being set — investigation needed.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
inserted += result.inserted;
|
|
fact_ids.push(...result.ids);
|
|
}
|
|
|
|
return { inserted, duplicate, superseded, fact_ids };
|
|
}
|