2026-09-13 02:08:55 +03:00
import * as crypto from "node:crypto" ;
2026-09-12 02:34:18 +03:00
import type { Usage } from "@earendil-works/pi-ai" ;
2026-09-25 23:32:13 +03:00
import type { AgentTool } from "@earendil-works/pi-agent-core" ;
import { Type , type Static , type TSchema } from "typebox" ;
import { toToolResult } from "../tool-execute.ts" ;
2026-09-13 02:08:55 +03:00
import type { ModelRole } from "../../../config.ts" ;
2026-09-24 22:51:12 +03:00
import {
FACTORY_SETTINGS ,
ModelsNotConfiguredError ,
ProviderKeyMissingError ,
ROLE_SLOT ,
type ModelSlotName ,
type ProviderKeyMissing ,
} from "../../core/settings.ts" ;
2026-09-12 02:34:18 +03:00
import type { PiEngine } from "../models.ts" ;
import { runSubagent } from "./pi-run.ts" ;
2026-09-29 20:42:33 +03:00
import { withBgLlmSlot } from "./bg-llm-semaphore.ts" ;
2026-09-11 03:25:25 +03:00
import type {
2026-09-28 19:22:58 +03:00
AuditVerdicts ,
2026-09-11 03:25:25 +03:00
CallUsage ,
ChatRecord ,
ExtractionRunUsage ,
ExtractionStageUsage ,
SessionFindings ,
} from "../../core/contracts.ts" ;
2026-09-30 17:44:37 +03:00
import { getGraph , mergeFindings , type DerivedGraph , type EmissionStats , type GraphDeps } from "../../core/graph.ts" ;
2026-09-13 14:29:16 +03:00
import type { GraphStore } from "../../core/ports.ts" ;
2026-10-02 17:31:35 +03:00
import { emit } from "../../core/events.ts" ;
2026-09-13 09:52:12 +03:00
import { settleGraphLayout } from "../../core/layout-settle.ts" ;
2026-09-28 16:54:44 +03:00
import { batchRuns , coverageOf , messageViewLines } from "../../core/coverage.ts" ;
2026-09-28 22:37:18 +03:00
import type { LhMessage } from "../../core/native-messages.ts" ;
2026-09-28 16:54:44 +03:00
import { auditorGraphTools } from "../tools/graph.ts" ;
2026-09-30 16:38:45 +03:00
import { runSubmitAgent , healStringifiedArrays } from "./submit-agent.ts" ;
2026-08-31 22:36:41 +03:00
/**
2026-09-28 16:54:44 +03:00
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign;
* relinked 2026-09-27) — the sole graph writer. Three stages, one
2026-09-30 17:44:37 +03:00
* orchestrator (the spine stage came and went, 2026-09-30: serialized
* digest-aware extraction replaced it — the owner's live test showed
* the post-hoc assembly pass added complexity without perceptible
* gain; the extractor itself is the assembler now):
2026-08-31 22:36:41 +03:00
*
2026-09-28 16:54:44 +03:00
* extractor (LLM) the ONE stage that sees the raw source (a session
2026-10-13 08:26:31 +03:00
* rendered as the teacher's prose — each passage
* labeled with its ACTUAL message id, "TEACHER [id]",
* owner 2026-10-13 — or a notes file verbatim, walked
* in stretches) → submits NEW core ideas + reasoning
* chains (owner redesign, 2026-09-30); a session
* submit additionally carries each proposition's
* messageId — the anchor the merger stamps into the
* node's message-granular provenance (2026-10-13).
* DIGEST-AWARE (2026-09-30 evening):
2026-09-30 17:44:37 +03:00
* when the source already yielded material, the
* prompt carries THE GRAPH SO FAR (that source's
* propositions + chains, ids verbatim) and the
* extractor builds on it — referencing existing ids
* instead of restating, leaning on established
* conclusions as premises. Stretches of one source
* run SERIALLY (each extract reads every earlier
* merge); tree-blind, FULLY TRUSTED — nothing
* downstream re-checks it.
2026-09-28 16:54:44 +03:00
* auditor (LLM) never sees the source. Its job is IDENTITY: for each
2026-09-30 17:44:37 +03:00
* NEW proposition, linkTo an existing graph node or
* create — decided by EXPLORING the graph through
* tools (find / list_sessions / list_propositions),
* not by an injected graph dump (context bloat,
* owner 2026-09-27). Links are its ONLY deliverable
* — it never mints edges (owner 2026-09-28: the
* graph connects across sources solely through
* identified equivalences). Input is PROPOSITIONS
* only — never chains (owner 2026-09-30).
* merger (core) mergeFindings() → deterministic ledger appends +
* chain simplification (2026-09-30 evening: the
* ancestry-cover / prefer-fewest-premises pass that
* strips redundant derivations — core/graph.ts)
2026-08-31 22:36:41 +03:00
*
2026-09-28 16:54:44 +03:00
* The events ledger is GONE (2026-09-27): quiz outcomes and understanding
* observations are no longer formalized in the graph — the graph is purely
2026-10-04 21:34:24 +03:00
* extracted reasoning chains (nodes + edges).
2026-09-28 16:54:44 +03:00
*
* The extractor is a single-tool-call worker (one TypeBox-validated submit
* call — a pi agent purely so the model can retry a schema-invalid call).
* The auditor is a small AGENTIC loop: exploration tools plus one submit.
2026-08-31 22:36:41 +03:00
*
* Created via createExtractionPipeline(deps) — the composition root
* (chat-handler) injects the storage ports + models; this module never
* imports an adapter. runExtraction is single-flight per chat: a second
* trigger while one run is in flight is a no-op (the next trigger picks up
* whatever the in-flight run missed). On failure nothing is checkpointed —
* the next trigger re-runs the whole thing.
2026-09-05 21:36:40 +03:00
*
2026-09-13 02:08:55 +03:00
* The FORMAT-AGNOSTIC FRONT DOOR (2026-09-13): importNotes() runs the SAME
* three stages over raw personal notes (arbitrary format — no chat, no
* session, no message ids). A notes-mode extractor prompt replaces the
* session one; the auditor and merger are shared (identity + deterministic
* appends don't care where text came from). Provenance is the file itself:
2026-09-28 16:54:44 +03:00
* source ids ("src_" + path hash) play the chatId role in mappings and
* checkpoints, and the source record (graphStore sources seam) carries
* the file path + the run's usage.
2026-09-13 02:08:55 +03:00
*
2026-09-05 21:36:40 +03:00
* Observability (§5 baseline): every stage logs one line per step, its
* start, and its duration; the pipeline logs a failure line before
* rethrowing, so the server log reads start → stage timings → merged
* stats (or failed) for every run.
2026-08-31 22:36:41 +03:00
*/
2026-09-28 16:54:44 +03:00
// ─── the source view (what the extractor sees) ──────────────────────────────
2026-08-31 22:36:41 +03:00
/**
2026-09-28 22:37:18 +03:00
* The extraction view: the session as TEACHER PROSE ONLY (owner ruling,
2026-10-13 08:26:31 +03:00
* 2026-09-28 — supersedes the 2026-09-27 whole-session markdown view),
* each passage labeled with its actual message id (2026-10-13). The
* shared markdown engine (core/session-markdown.ts) keeps the full session
* format for the future user-facing export; the extractor no longer uses
* it.
2026-09-28 22:37:18 +03:00
*/
/** The whole-record view (used by callers that render a full session;
2026-10-13 08:26:31 +03:00
* the pipeline itself batches). TEACHER PROSE ONLY (owner, 2026-09-28),
* passages labeled with their message ids (2026-10-13).
2026-08-31 22:36:41 +03:00
*/
export function transcriptView ( record : ChatRecord ) : string {
2026-09-28 22:37:18 +03:00
return proseView ( record . messages , record . title );
}
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
* ONLY the teacher agent's text blocks — no learner messages, no
2026-10-13 08:26:31 +03:00
* thinking, no tool calls or results, no quiz blocks — each labeled with
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
* lines the coverage fingerprints hash, so the extractor can anchor
* every proposition to the passage it came from (the session submit's
* required messageId). The shared markdown engine stays reserved for the
* future user-facing export. */
2026-09-28 22:37:18 +03:00
function proseView ( messages : ReadonlyArray < LhMessage >, title? : string ) : string {
2026-10-13 08:26:31 +03:00
const lines = messages . flatMap (( message ) => messageViewLines ( message , message . id ));
2026-09-28 22:37:18 +03:00
const head = title && title . length > 0 ? `# ${ title } \ n \ n` : "" ;
2026-10-13 08:26:31 +03:00
return head + lines . join ( "\n\n" );
2026-08-31 22:36:41 +03:00
}
// ─── the extractor ──────────────────────────────────────────────────────────
2026-09-25 23:32:13 +03:00
const propositionProps = {
2026-09-30 17:44:37 +03:00
id : Type.String ({ description : 'Local id: "p1", "p2", … — referenced by chains in this call.' }),
2026-09-25 23:32:13 +03:00
name : Type.String ({
description :
2026-09-30 02:09:25 +03:00
"A very short label — established terminology, the term the discipline itself uses, a glossary " +
"headword a textbook would print ('Will to power', 'Divine command theory'). Not a sentence, " +
"needn't be unique or self-contained: this becomes the node's global label in the graph." ,
2026-09-25 23:32:13 +03:00
}),
statement : Type.String ({
description :
2026-09-30 17:44:37 +03:00
"One true, self-contained, world-facing core idea at full precision. Never about the " +
2026-09-30 02:09:25 +03:00
'learner, the teacher, or the reading: not "the learner understands X" but "X". A misconception ' +
"enters as its TRUE correction." ,
2026-09-25 23:32:13 +03:00
}),
2026-09-28 19:04:39 +03:00
aliases : Type.Optional (
Type . Array ( Type . String (), {
2026-09-30 02:09:25 +03:00
description : "Verbatim phrasings the source used for the same proposition, if any." ,
2026-09-28 19:04:39 +03:00
}),
),
2026-09-25 23:32:13 +03:00
};
2026-09-30 02:09:25 +03:00
// The extractor redesign (owner, 2026-09-30): the extractor submits
2026-09-30 03:27:10 +03:00
// REASONING CHAINS — premises + conclusion — stored AS-IS (chains-primary,
// same evening: chains.jsonl is the truth; edges are a derived view, and
// supports edges are retired entirely).
2026-09-30 17:44:37 +03:00
const endpoint = ( role : string ) =>
Type . String ({
description :
`The id this ${ role } — EITHER an existing graph idea id from THE GRAPH SO FAR in the prompt, ` +
'verbatim ("n_…"), OR the local id of a proposition in this submission ("p1", …).' ,
});
2026-09-30 02:09:25 +03:00
const chainSchema = Type . Object ({
2026-09-30 17:44:37 +03:00
premises : Type.Array ( endpoint ( "premise uses" ), {
2026-09-30 02:09:25 +03:00
minItems : 1 ,
description :
2026-09-30 17:44:37 +03:00
"Premise ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains." ,
2026-09-25 23:32:13 +03:00
}),
2026-09-30 17:44:37 +03:00
conclusion : endpoint ( "conclusion is" ),
2026-09-25 23:32:13 +03:00
});
// Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` /
// `.default({ quiz: {} })` on required-but-empty fields becomes Type.Optional
// here + a ??-fill at the submit seam (fillFindings/fillAudit below) — same
// contract on the wire (omission tolerated, empty never means "invalid"),
// no zod left in the agent band.
2026-10-13 08:26:31 +03:00
const propositionsDescription =
"Only the NEW core ideas this stretch contributes — ideas the prompt's THE GRAPH SO FAR already " +
"holds are referenced by id in chains, never restated here." ;
const chainsDescription =
"Every argument this stretch makes — including arguments that join new ideas to established " +
"ones; conclusions reusable by id as premises of later chains; bare ideas (no argument) ride " +
"the propositions list alone; pass [] when none — never omit the field." ;
const findingsSchema = Type . Object ({
2026-09-25 23:32:13 +03:00
propositions : Type.Array ( Type . Object ( propositionProps ), {
2026-10-13 08:26:31 +03:00
description : propositionsDescription ,
2026-09-25 23:32:13 +03:00
}),
2026-09-30 02:09:25 +03:00
chains : Type.Array ( chainSchema , {
2026-10-13 08:26:31 +03:00
description : chainsDescription ,
}),
});
// THE SESSION EXTENSION (owner, 2026-10-13): every session proposition
// carries a REQUIRED messageId — the anchor of the passage it came from,
// copied from the view's "TEACHER [id]" label (validated at the submit
// seam against the CURRENT batch). The NOTES door has no messages: its
// schema stays the base shape above, prompt and wire unchanged.
const sessionPropositionProps = {
... propositionProps ,
messageId : Type.String ({
2026-09-28 21:28:39 +03:00
description :
2026-10-13 08:26:31 +03:00
"The message id of the ONE passage in this stretch that most directly states or explains this " +
'proposition — copy it EXACTLY from that passage\'s "TEACHER [id]" label.' ,
2026-09-28 21:28:39 +03:00
}),
2026-09-25 23:32:13 +03:00
};
2026-09-08 22:19:37 +03:00
2026-10-13 08:26:31 +03:00
const sessionFindingsSchema = Type . Object ({
propositions : Type.Array ( Type . Object ( sessionPropositionProps ), {
description : propositionsDescription ,
}),
chains : Type.Array ( chainSchema , {
description : chainsDescription ,
}),
});
2026-09-08 22:19:37 +03:00
2026-09-30 02:09:25 +03:00
/** What the extractor SUBMITS (the redesign's wire shape) — propositions
* + chains. fillFindings normalizes it; validateFindingsChains checks
2026-09-30 03:27:10 +03:00
* the chain laws; asSessionFindings lifts it straight into
* SessionFindings — chains are stored AS-IS (chains-primary, owner
* 2026-09-30: no edge translation at any seam). */
2026-09-30 02:09:25 +03:00
type FindingsSubmit = Static < typeof findingsSchema >;
2026-10-13 08:26:31 +03:00
/** The SESSION wire — the notes shape plus the REQUIRED messageId anchor
* on every proposition (owner, 2026-10-13). */
type SessionFindingsSubmit = Static < typeof sessionFindingsSchema >;
2026-09-28 19:22:58 +03:00
// VERDICTS-ONLY (owner, 2026-09-28): the auditor never alters the
2026-09-29 01:16:32 +03:00
// extractor's findings, so it does not restate them — links are its
// ENTIRE deliverable (owner, 2026-09-28 evening: the edges argument is
// GONE; the auditor never mints edges. The graph connects across sources
// solely through identified equivalences — a link, never an inference).
2026-09-28 19:22:58 +03:00
const auditSchema = Type . Object ({
2026-09-28 21:28:39 +03:00
links : Type.Array (
Type . Object ({
id : Type.String ({ description : 'The proposition id from the findings ("p1", …).' }),
linkTo : Type.String ({
description :
"The id of the EXISTING graph node this proposition IS — your identity verdict. EQUIVALENCE BIAS (2026-09-27): paraphrases, rewordings, and compressions of the same claim MERGE; only genuinely distinct propositions stay apart." ,
2026-09-28 19:22:58 +03:00
}),
}),
2026-09-28 21:28:39 +03:00
{ description : "Identity verdicts — which findings propositions ARE existing graph nodes. A proposition absent from here = create a new node. Pass [] when none — never omit the field." },
2026-09-28 19:22:58 +03:00
),
});
2026-09-25 23:32:13 +03:00
2026-10-13 08:26:31 +03:00
// The notes front door's wire shape is the session shape MINUS the
// messageId anchor (owner, 2026-10-13: only the SESSION door demands
// passage anchors — notes have no messages, so their prompt and schema
// stay exactly as they were): one base findings type for both doors,
// the anchor riding as an optional field.
2026-09-28 16:54:44 +03:00
type NotesFindings = SessionFindings ;
2026-09-28 19:22:58 +03:00
type NotesAuditResult = AuditVerdicts ;
2026-09-25 23:32:13 +03:00
2026-09-30 02:09:25 +03:00
// ── the submit seam (owner, 2026-09-28; redesigned 2026-09-30) ──
2026-09-28 21:28:39 +03:00
//
2026-09-29 01:16:32 +03:00
// Every top-level field on each submit tool is REQUIRED — a payload
2026-09-30 02:09:25 +03:00
// missing `chains` (findings) or `links` (review) fails validation
// instead of silently merging structure-less (the degraded-retry hole:
// the model's first truncated attempt failed validation, then the retry
// passed with propositions only — optional fields ??-filled [] — merging
// a pile of nodes with no structure). Only the per-item aliases stay
// optional with a ??-fill.
2026-09-25 23:32:13 +03:00
2026-10-13 08:26:31 +03:00
function fillFindings < T extends { propositions : Array < { aliases ?: string [] }>; chains : unknown } > (
raw : T ,
) : T {
2026-09-25 23:32:13 +03:00
return {
2026-10-13 08:26:31 +03:00
... raw ,
2026-09-28 19:04:39 +03:00
propositions : raw.propositions.map (( p ) => ({ ... p , aliases : p.aliases ?? [] })),
2026-09-30 02:09:25 +03:00
};
}
2026-10-13 08:26:31 +03:00
/** The notes fill: the shared alias ??-fill PLUS the door's boundary — a
* stray messageId (the notes schema has none; extra properties pass
* schema validation) is stripped so notes provenance stays chat-level
* (the file is the anchor, owner 2026-09-13). */
function fillNotesFindings ( raw : FindingsSubmit ) : FindingsSubmit {
return fillFindings ({
... raw ,
propositions : raw.propositions.map (( p ) => {
const stripped = { ... p } as typeof p & { messageId? : unknown };
delete stripped . messageId ;
return stripped ;
}),
});
}
2026-09-30 17:44:37 +03:00
/** The chain laws (extractor redesign, 2026-09-30; digest-aware
* 2026-09-30 evening) — the LOUD channel: runSubmitAgent turns a
* problem string into an error tool result and the model re-submits
2026-10-04 21:34:24 +03:00
* within its turns. The MIXED NAMESPACE: an
2026-09-30 17:44:37 +03:00
* endpoint resolves when it is a submitted proposition id OR an
* existing graph node id (the extractor continues a partly-extracted
* source and references digest ids verbatim). Identical (premise-set,
* conclusion) chains are NOT errors — they dedupe silently at the
* merge (chainKey): restating an argument is a no-op. */
function validateFindingsChains ( value : FindingsSubmit , existingIds : ReadonlySet < string >) : string | null {
2026-09-30 02:09:25 +03:00
const ids = new Set ( value . propositions . map (( p ) => p . id ));
for ( const chain of value . chains ) {
if ( chain . premises . length === 0 ) {
return "chains: premises must name at least one proposition — a proposition with no argument stays in the propositions list alone." ;
}
2026-09-30 17:44:37 +03:00
const unknown = [... chain . premises , chain . conclusion ]. find (( id ) => ! ids . has ( id ) && ! existingIds . has ( id ));
2026-09-30 02:09:25 +03:00
if ( unknown !== undefined ) {
2026-09-30 17:44:37 +03:00
return `chains: " ${ unknown } " is neither a submitted proposition id nor an existing idea id from THE GRAPH SO FAR — every premise and conclusion must resolve.` ;
2026-09-30 02:09:25 +03:00
}
if ( chain . premises . includes ( chain . conclusion )) {
return "chains: a conclusion cannot be its own premise." ;
}
}
return null ;
}
2026-10-13 08:26:31 +03:00
/** The SESSION anchor law (owner, 2026-10-13): every proposition names a
* passage of the CURRENT batch — nonempty, and a teacher-prose message
* id present in the stretch this extractor is reading (the same ids the
* view's TEACHER [id] labels carry). Refused anchors ride the existing
* error-back channel: no merge, no coverage checkpoint, until a valid
* submit lands. The boundary is strict on purpose — an id from a LATER
* or already-covered batch, another chat, or a learner/tool message is
* refused even when it is a real message id of this chat. */
function validateSessionAnchors ( value : SessionFindingsSubmit , batchIds : ReadonlySet < string >) : string | null {
for ( const p of value . propositions ) {
if ( p . messageId . trim (). length === 0 ) {
return `propositions: " ${ p . id } " is missing its messageId — copy the message id from the brackets of the passage (TEACHER [id]) that most directly states or explains it.` ;
}
if ( ! batchIds . has ( p . messageId )) {
return `propositions: " ${ p . id } " names messageId " ${ p . messageId } ", which is not a passage of THIS stretch — copy the id from the brackets of one of this stretch's own TEACHER [id] passages.` ;
}
}
return null ;
}
2026-09-30 03:22:52 +03:00
/** (chains-primary, owner 2026-09-30) the wire IS the pipeline shape
* now: propositions + chains, no translation — the stores keep the
* chains and the derived edge view is recomputed on read. The fill
* already ran inside runSubmitAgent; this only re-asserts the
2026-10-13 08:26:31 +03:00
* aliases-filled type (and preserves the session submit's messageId
* anchor, 2026-10-13 — both doors lift through here). */
const asSessionFindings = (
raw : {
propositions : Array < { id : string ; name : string ; statement : string ; aliases? : string []; messageId? : string } > ;
chains : Array < { premises : string []; conclusion : string } > ;
},
) : SessionFindings => ({
2026-09-30 03:22:52 +03:00
propositions : raw.propositions.map (( p ) => ({ ... p , aliases : p.aliases ?? [] })),
chains : raw.chains ,
});
2026-09-25 23:32:13 +03:00
2026-09-28 19:22:58 +03:00
function fillAudit ( raw : Static < typeof auditSchema >) : AuditVerdicts {
2026-09-25 23:32:13 +03:00
return {
2026-09-28 21:28:39 +03:00
links : raw.links ,
2026-09-25 23:32:13 +03:00
};
}
2026-09-30 02:09:25 +03:00
// ── the extractor's system prompt (owner redesign, 2026-09-30) ──
//
// GOAL-FIRST, NO RULE ARMOR: the old doctrine's defensive lists (DROP
// lists, merge-your-own-output, name walls, when-in-doubt-omit, the
// faithfulness paragraph) are ALL CUT — the prompt states the goal and
// the submission mechanics; the tool's field descriptions carry the
// details. One shared core + two contextualizations, assembled here —
// session and notes share the core by construction. (The old opening
// describing Quiz/Recall-check/Question blocks was stale since the
// 2026-09-28 teacher-prose ruling and is simply omitted.)
2026-09-13 02:08:55 +03:00
2026-10-13 08:26:31 +03:00
/** The session-only anchor instruction (owner, 2026-10-13, verbatim):
* passages arrive labeled "TEACHER [id]" and every proposition must
* name the ONE passage it came from — the anchor the merger stamps
* into message-granular provenance. Notes have no passages: their
* contextualization stays untouched. */
export const SESSION_MESSAGE_ID_RULE =
"For each new proposition, copy the message ID of the passage that most directly states or explains it into messageId." ;
2026-09-30 02:09:25 +03:00
export const SESSION_PREAMBLE =
2026-09-30 17:44:37 +03:00
"You are the extraction stage of a learning application. You read one stretch of a source — a teaching " +
"session, rendered as the teacher's prose in reading order — and report what it teaches as findings " +
"for the learner's global knowledge graph, building on everything already extracted from this source." ;
2026-09-13 02:08:55 +03:00
2026-09-30 02:09:25 +03:00
export const NOTES_PREAMBLE =
2026-09-30 17:44:37 +03:00
"You are the extraction stage of a learning application. You read one stretch of a source — the " +
2026-09-30 02:09:25 +03:00
"author's personal notes, rendered in reading order — and report what it teaches as findings for the " +
2026-09-30 17:44:37 +03:00
"learner's global knowledge graph, building on everything already extracted from this source." ;
2026-09-13 02:08:55 +03:00
2026-09-30 17:44:37 +03:00
export const EXTRACTION_CORE = `THE GOAL: uncover the source's CORE IDEAS and show how everything derives from the foundations. The
source teaches by arguing — ideas worked out from other ideas, down to a foundation the rest stands on.
Your deliverable is twofold: the core ideas themselves, and the chains of reasoning that derive them.
Each chain is premises that JOINTLY entail a conclusion. Entailment is the only link that earns a
chain — where the real connection is historical or narrative (who argued what, how a debate or
exposition unfolds), there is no chain; keep the pieces as bare ideas or drop them. The direction of
the graph is upward: foundational ideas at the bottom, derived ideas above them, each chain showing
how its conclusion follows from what is more established — do not submit connections that carry no
derivation.
2026-09-13 02:08:55 +03:00
2026-09-30 17:44:37 +03:00
The graph holds TRUE ideas about the world, nothing else — never anything about the learner, the
2026-09-30 02:09:25 +03:00
teacher, or this reading: not "the learner understands X" but X. A misconception enters as its TRUE
2026-09-30 17:44:37 +03:00
correction. Each statement is one self-contained idea at full precision.
2026-09-13 02:08:55 +03:00
2026-09-30 17:44:37 +03:00
Extract the load-bearing ideas of the subject — what a reference on it would teach — not the full run
of the discourse. An idea earns its place only when the source's arguments run through it — examples,
notation glosses, and restatements that no argument needs are not core ideas. Every idea must stand
alone for a reader who never saw this source: names and statements may not lean on the source's own
scaffolding (claim numbering, axiom tags, arc labels); a claim that only matters as part of how this
document is organized is not knowledge — drop it.
2026-09-30 05:36:28 +03:00
2026-09-30 17:44:37 +03:00
When the prompt carries THE GRAPH SO FAR, you are continuing a source already partly extracted: build
on it. Reference an existing idea by its id ("n_…") instead of restating it, and prefer chains that
lean on established conclusions as premises — derive new material from what is already derived. Do
not re-derive a conclusion the graph already holds unless the route is genuinely different.
SUBMIT with one submit_findings call: every NEW core idea with a short name and a precise statement,
and every chain as premises + conclusion. A chain's premises and conclusion may be ids from this
submission OR existing ids from THE GRAPH SO FAR, verbatim. Premises within one chain are used
TOGETHER — independent arguments for the same conclusion are separate chains. A conclusion may
reappear, by id, as a premise of later chains. A bare idea that no argument uses rides the
propositions list alone. Include every field even when empty — "chains": [] when the source makes no
arguments. The tool's field descriptions carry the details.` ;
2026-09-13 02:08:55 +03:00
2026-10-13 08:26:31 +03:00
export const EXTRACTOR_INSTRUCTIONS = ` ${ SESSION_PREAMBLE } \ n \ n ${ EXTRACTION_CORE } \ n \ n ${ SESSION_MESSAGE_ID_RULE } ` ;
2026-09-30 02:09:25 +03:00
export const NOTES_EXTRACTOR_INSTRUCTIONS = ` ${ NOTES_PREAMBLE } \ n \ n ${ EXTRACTION_CORE } ` ;
2026-08-31 22:36:41 +03:00
2026-09-30 17:44:37 +03:00
// ── the same-source digest (owner, 2026-09-30 evening) ──
//
// Serialized digest-aware extraction REPLACED the spine stage (owner
// ruling after the live test: the post-hoc assembly pass added
// complexity without perceptible gain — the extractor itself is the
// assembler when it can see what its source has already yielded). Pure
// string fold over the derived graph; "" when the source owns nothing
// yet (the first stretch runs bare). Ids ride VERBATIM — that is what
// the extractor references in its chains (the merger's mixed-namespace
// resolution passes them through).
export function sourceDigest ( graph : DerivedGraph , sourceId : string ) : string {
const nameOf = new Map ( graph . nodes . map (( n ) => [ n . id , n . name ]));
const ours = graph . nodes . filter (( n ) => n . provenance . some (( pt ) => pt . chatId === sourceId ));
const ourChains = graph . chains . filter (( c ) => c . provenance . some (( pt ) => pt . chatId === sourceId ));
if ( ours . length === 0 && ourChains . length === 0 ) return "" ;
const lines : string [] = [];
lines . push ( "THE GRAPH SO FAR — everything already extracted from this source (reference these ids verbatim):" );
for ( const n of ours ) lines . push ( ` ${ n . id } | ${ n . name } | ${ n . statement . replace ( /\s+/g , " " ) } ` );
if ( ourChains . length > 0 ) {
lines . push ( "DERIVATIONS ALREADY RECORDED (build on these; do not re-derive them):" );
for ( const c of ourChains ) {
const names = c . premises . map (( p ) => nameOf . get ( p ) ?? p ). join ( " ∧ " );
lines . push ( ` [ ${ c . premises . join ( " + " ) } ⇒ ${ c . conclusion } ] ${ names } ⇒ ${ nameOf . get ( c . conclusion ) ?? c . conclusion } ` );
}
}
return lines . join ( "\n" );
}
2026-10-13 08:26:31 +03:00
/** The extractor over ONE session transcript (the chat front door).
* `batchIds` — the CURRENT batch's teacher-prose message ids — is the
* anchor boundary: every submitted proposition's messageId must name
* one of them (validateSessionAnchors at the submit seam). */
2026-09-13 02:08:55 +03:00
function runExtractor (
engine : PiEngine | undefined ,
2026-10-04 20:21:18 +03:00
sourceId : string ,
2026-09-13 02:08:55 +03:00
prompt : string ,
2026-09-30 17:44:37 +03:00
existingIds : ReadonlySet < string >,
2026-10-13 08:26:31 +03:00
batchIds : ReadonlySet < string >,
2026-09-13 02:08:55 +03:00
signal : AbortSignal | undefined ,
onStep : ( usage : Usage , model : string , step : number , finishReason : string , tools : string ) => void ,
) : Promise < SessionFindings > {
2026-09-30 17:44:37 +03:00
// Chains in (the wire), chains out (the store) — no translation seam
// since chains-primary. Validation runs at the submit seam (error-back)
2026-10-13 08:26:31 +03:00
// over the MIXED namespace: local ids + existing graph ids, plus the
// session anchor law (messageId ∈ the current batch, 2026-10-13).
return runSubmitAgent < SessionFindingsSubmit >({
2026-09-13 02:08:55 +03:00
role : "extractor" ,
2026-10-04 20:21:18 +03:00
// Debug recorder key: the source id (the run's ledger key — the
// chat's chatId for chat runs, the src id for notes).
chatId : sourceId ,
2026-09-13 02:08:55 +03:00
toolName : "submit_findings" ,
toolDescription : "Submit the session findings." ,
2026-10-13 08:26:31 +03:00
parameters : sessionFindingsSchema ,
2026-09-25 23:32:13 +03:00
fill : fillFindings ,
2026-10-13 08:26:31 +03:00
validate : ( value ) => validateSessionAnchors ( value , batchIds ) ?? validateFindingsChains ( value , existingIds ),
2026-09-13 02:08:55 +03:00
systemPrompt : EXTRACTOR_INSTRUCTIONS ,
prompt ,
engine ,
signal ,
onStep ,
neverSubmitted : "the extractor never submitted findings" ,
2026-09-29 17:57:53 +03:00
prepareArguments : healStringifiedArrays ,
2026-09-30 03:22:52 +03:00
}). then ( asSessionFindings );
2026-09-13 02:08:55 +03:00
}
/** The extractor over ONE source of raw personal notes (the format-agnostic
2026-10-13 08:26:31 +03:00
* front door): same single-submit shape, notes-mode prompt + schema —
* the BASE wire (no messageId: notes have no passages, and a stray one
* is stripped at the fill seam). */
2026-09-13 02:08:55 +03:00
function runNotesExtractor (
engine : PiEngine | undefined ,
2026-10-04 20:21:18 +03:00
sourceId : string ,
2026-09-13 02:08:55 +03:00
prompt : string ,
2026-09-30 17:44:37 +03:00
existingIds : ReadonlySet < string >,
2026-09-13 02:08:55 +03:00
signal : AbortSignal | undefined ,
onStep : ( usage : Usage , model : string , step : number , finishReason : string , tools : string ) => void ,
) : Promise < NotesFindings > {
2026-09-30 02:09:25 +03:00
return runSubmitAgent < FindingsSubmit >({
2026-09-13 02:08:55 +03:00
role : "extractor" ,
2026-10-04 20:21:18 +03:00
// Debug recorder key: the notes' source id (the ledger key).
chatId : sourceId ,
2026-09-13 02:08:55 +03:00
toolName : "submit_findings" ,
toolDescription : "Submit the notes findings." ,
2026-09-28 16:54:44 +03:00
parameters : findingsSchema ,
2026-10-13 08:26:31 +03:00
fill : fillNotesFindings ,
2026-09-30 17:44:37 +03:00
validate : ( value ) => validateFindingsChains ( value , existingIds ),
2026-09-13 02:08:55 +03:00
systemPrompt : NOTES_EXTRACTOR_INSTRUCTIONS ,
prompt ,
engine ,
signal ,
onStep ,
neverSubmitted : "the extractor never submitted findings" ,
2026-09-29 17:57:53 +03:00
prepareArguments : healStringifiedArrays ,
2026-09-30 03:22:52 +03:00
}). then ( asSessionFindings );
2026-09-13 02:08:55 +03:00
}
2026-08-31 22:36:41 +03:00
// ─── the auditor ───────────────────────────────────────────────────────────
2026-09-28 16:54:44 +03:00
const AUDITOR_INSTRUCTIONS = `You are the linking stage of a learning application's extraction pipeline. An extractor
has read a source (a teaching session, or the author's personal notes) and reported findings: propositions and
2026-09-30 03:22:52 +03:00
their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check
2026-09-28 16:54:44 +03:00
its work. Your job is IDENTITY: connecting those findings to the CURRENT knowledge graph, preparing the merge.
2026-09-13 02:08:55 +03:00
2026-09-29 04:10:38 +03:00
You explore the graph through your tools: list_sessions, list_propositions, and find (glob over names +
statements + aliases; each hit also says which session it was born in). The graph is SMALL and FINITE — a handful of
sessions — and your job is done as soon as you have SEEN the plausible candidates. FOLLOW THIS PROCEDURE:
1. GROUND yourself first: call list_sessions ONCE. This one call shows the entire space — every session that ever
contributed propositions, and how many. Now you know the extent of what you are searching; nothing is hidden.
2. READ the plausibly-related sessions: for each session whose topic could overlap these findings, call
list_propositions — every proposition born there, with full statement text. A few calls and you have read
every realistic identity candidate in the graph.
3. find is your targeted sweep: use it for phrasings, synonyms, and neighboring ideas the session reads made you
wonder about — its hits point back at sessions you can read whole.
4. SUBMIT. Once every plausibly-related session has been read (and stray finds come back empty), you are DONE.
Do not re-search ground you have already seen; more patterns over the same nodes are wasted work.
2026-09-08 22:19:37 +03:00
2026-09-28 19:22:58 +03:00
IDENTITY — links, per proposition:
2026-09-29 04:10:38 +03:00
- For each proposition: does an EXISTING node — one you have now SEEN — already state this same proposition?
2026-09-28 19:22:58 +03:00
- If yes: add { "id": <its proposition id>, "linkTo": <that node's id> } to "links". The session's phrasings join
its aliases and its provenance; the node keeps its existing name.
- If no: add nothing — a new node is created with the extractor's name as its label.
2026-09-28 16:54:44 +03:00
- EQUIVALENCE BIAS (owner ruling): paraphrases, rewordings, and compressions of the same claim MERGE. When two
statements are the same claim at different precision or from different angles, LINK them. Only genuinely distinct
propositions — different claims about the world — stay apart. You are NOT guarding against wrong merges; you are
hunting for right ones.
2026-08-31 22:36:41 +03:00
2026-09-30 03:22:52 +03:00
Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions —
the merger already has them. You NEVER create relations between propositions — they come from the
2026-09-29 01:16:32 +03:00
extractor alone, and the batch connects to the existing graph ONLY through the equivalences you identify. Your
entire deliverable is ONE submit_review call.
2026-08-31 22:36:41 +03:00
2026-09-29 01:16:32 +03:00
Include the "links" field even when empty — "links": [] when nothing links. NEVER omit it because it is empty.` ;
2026-09-08 22:19:37 +03:00
2026-09-28 16:54:44 +03:00
/** The auditor — one shape for both front doors now (the notes schema was
* identical once attribution died). An agentic loop: graph-exploration
* tools plus the one submit call. */
2026-09-13 02:08:55 +03:00
function runAuditor (
2026-09-12 02:34:18 +03:00
engine : PiEngine | undefined ,
2026-10-04 20:21:18 +03:00
sourceId : string ,
2026-09-12 02:34:18 +03:00
prompt : string ,
signal : AbortSignal | undefined ,
onStep : ( usage : Usage , model : string , step : number , finishReason : string , tools : string ) => void ,
2026-09-28 16:54:44 +03:00
tools : Record < string , AgentTool < any >>,
2026-09-28 19:22:58 +03:00
) : Promise < AuditVerdicts > {
2026-09-13 02:08:55 +03:00
return runSubmitAgent ({
role : "auditor" ,
2026-10-04 20:21:18 +03:00
// Debug recorder key: same source id as the extractor run.
chatId : sourceId ,
2026-09-13 02:08:55 +03:00
toolName : "submit_review" ,
2026-09-29 01:16:32 +03:00
toolDescription : "Submit the identity verdicts ONLY (links) — never the propositions themselves." ,
2026-09-25 23:32:13 +03:00
parameters : auditSchema ,
fill : fillAudit ,
2026-09-13 02:08:55 +03:00
systemPrompt : AUDITOR_INSTRUCTIONS ,
prompt ,
engine ,
signal ,
onStep ,
neverSubmitted : "the auditor never submitted a review" ,
2026-09-28 16:54:44 +03:00
extraTools : tools ,
maxTurns : 16 ,
2026-09-29 17:57:53 +03:00
prepareArguments : healStringifiedArrays ,
2026-09-13 02:08:55 +03:00
});
2026-08-31 22:36:41 +03:00
}
2026-09-09 01:26:49 +03:00
// ─── the pipeline status registry ─────────────────────────────────────────────
2026-09-19 13:16:34 +03:00
/** The pipeline phases, in order — what the home status endpoint shows.
2026-09-23 15:37:34 +03:00
* The post-batch embedding tail was its own phase (accuracy wave,
* 2026-09-19) until wave 7 killed the embedder seam wholesale (this
* interlude) — birth-session clusters need no post-merge pass. The
2026-09-29 00:34:21 +03:00
* phases tell the truth about what the job is actually waiting on.
* "queued" (owner, 2026-09-28): the global extract semaphore — 3
* concurrent extractor calls process-wide; the job is waiting for an
* extract slot, not for the model.
* "waiting" (owner 2026-09-28): the serialized-audit gate — the job's
* audit+merge critical section is queued behind another job's; the
2026-09-28 18:00:56 +03:00
* extractor is done but the auditor may not start until the graph is
* stable. */
2026-09-30 17:44:37 +03:00
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "auditing" | "merging" ;
2026-09-09 01:26:49 +03:00
2026-09-11 03:25:25 +03:00
/** One in-flight pipeline run. Module-level: one process, one registry.
* Exported for the status payload's consumer typing (the dock). */
export interface RunningExtraction {
2026-09-29 00:34:21 +03:00
/** The SOURCE id (chat id / notes src id) — the payload's identity and
* the abort key. The registry KEY adds "#i" for chunk jobs (a
* multi-chunk source owns several entries); this field stays the
* source id so the feed's reveal logic and abort stay source-scoped. */
2026-09-09 01:26:49 +03:00
chatId : string ;
2026-09-29 00:34:21 +03:00
/** PER-CHUNK JOBS (owner, 2026-09-28): which chunk of how many this
* job is — present only when the source fanned out (n > 1). Drives
* the feed row's "· i/N" suffix. */
part ?: { i : number ; n : number };
2026-09-13 02:08:55 +03:00
/** Which front door the run came through — label + usage-persistence
* depend on it (chats bill their chat record; notes bill the source
* record). */
kind : "chat" | "notes" ;
/** Display label (the file path for notes imports); the status route
* falls back to the chat store for chat runs. */
label? : string ;
2026-09-09 01:26:49 +03:00
phase : ExtractionPhase ;
startedAt : number ;
2026-09-11 03:25:25 +03:00
/** Live per-stage usage, accumulated as steps bill (the dock's cost /
2026-09-23 15:37:34 +03:00
* context-window blobs read this through /api/graph/status). */
2026-09-23 03:53:57 +03:00
usage : {
extractor? : ExtractionStageUsage ;
auditor? : ExtractionStageUsage ;
};
2026-09-11 03:25:25 +03:00
/** True-cancellation handle (the dock's abort button): one controller per
* run, shared across phases; abort() cancels the in-flight model call. */
controller : AbortController ;
2026-09-09 01:26:49 +03:00
}
2026-09-19 13:16:34 +03:00
interface LhExtractionGlobal {
__lhExtractionRuns ?: {
/** In-flight pipeline runs, keyed chatId (chat) / src id (notes). */
running : Map < string , RunningExtraction >;
/** The single-flight guard, shared by every pipeline instance. */
inFlight : Set < string >;
/** Last minted startedAt — kept monotonic so two runs starting in the
* same millisecond never collide on the usage idempotency key. */
lastStartedAt : number ;
};
2026-09-28 18:00:56 +03:00
/** The serialized-audit mutex (owner 2026-09-28): a FIFO promise
2026-09-29 00:34:21 +03:00
* chain — the audit+merge critical section runs one job at a time,
2026-09-28 18:00:56 +03:00
* process-wide. globalThis for the same module-instance reason as
* the run registries. */
__lhAuditLock ?: { tail : Promise < void > };
2026-09-29 00:34:21 +03:00
/** The EXTRACT SEMAPHORE (owner, 2026-09-28): 3 concurrent extractor
* calls process-wide — the provider-facing rate stance (the import
* route's old 3-wide pool doctrine, now global). Per-chunk jobs fan
* out in parallel but queue here. */
2026-09-19 13:16:34 +03:00
}
2026-09-09 01:26:49 +03:00
2026-09-19 13:16:34 +03:00
/** THE RUN REGISTRIES live on globalThis, not module level — the
* turn-liveness lesson: Next dev compiles each route entry as its own
* module instance, so a module-level Map is a different object in the
* /api/activity route than in the pipeline's chat-handler instance —
* the status routes would read an always-empty registry. One process,
* one truth — the same idiom as the turn, wake, and abort registries
* (moved 2026-09-19, the observability-accuracy wave; also makes the
* single-flight guard cross route instances). */
function runs () : NonNullable < LhExtractionGlobal [ "__lhExtractionRuns" ] > {
const g = globalThis as typeof globalThis & LhExtractionGlobal ;
return ( g . __lhExtractionRuns ??= { running : new Map (), inFlight : new Set (), lastStartedAt : 0 });
}
2026-09-13 02:08:55 +03:00
2026-09-09 01:26:49 +03:00
/** In-flight pipeline runs (oldest first) — the /api/graph/status payload. */
export function extractionStatuses () : RunningExtraction [] {
2026-09-19 13:16:34 +03:00
return [... runs (). running . values ()]. sort (( a , b ) => a . startedAt - b . startedAt );
2026-09-09 01:26:49 +03:00
}
2026-09-11 03:25:25 +03:00
/** True cancellation of the in-flight run for a chat (the dock's abort
* button): the pending model call receives the signal — the provider
* cancels the request, spend stops — and the run ends like a failure:
* nothing merged, nothing checkpointed, partial usage still persisted.
* Returns false when no run is in flight (idempotent no-op). */
export function abortExtraction ( chatId : string ) : boolean {
2026-09-29 00:34:21 +03:00
// PER-CHUNK JOBS (owner, 2026-09-28): a source may own several registry
// entries (one per chunk/batch) — aborting the source aborts EVERY
// job of its fan-out.
let found = false ;
for ( const run of runs (). running . values ()) {
if ( run . chatId !== chatId ) continue ;
run . controller . abort ();
found = true ;
}
return found ;
2026-09-11 03:25:25 +03:00
}
2026-08-31 22:36:41 +03:00
// ─── the pipeline ───────────────────────────────────────────────────────────
export interface ExtractionModels {
2026-09-22 13:59:16 +03:00
/** Test seam: a scripted engine per stage — checked FIRST, ahead of the
* engineFor chain (production wires no direct values). */
2026-09-12 02:34:18 +03:00
extractor? : PiEngine ;
auditor? : PiEngine ;
2026-09-22 16:58:25 +03:00
/** Settings-resolved engine per role — the same slot treatment the
* job runner got, read FRESH per stage call (a settings PUT between
* stages lands). BYOK (owner, 2026-09-22): the chain (role override →
* slot → NOTHING) may end EMPTY → undefined; the front doors refuse
* with ModelsNotConfiguredError before any stage runs. */
engineFor ?: ( role : ModelRole ) => PiEngine | undefined ;
2026-09-24 22:51:12 +03:00
/** Key-presence gate (provider-key interlude, 2026-09-25): given a
* stage engine's provider + the role's slot tier, the injected check
* returns the refusal envelope when the provider's key is missing
* (null = run). Same injection discipline as the job runner's seam
* (the agent layer may not read the key store); absent in tests that
* inject engines directly (test engines never owe a key). A keyless
* stage refuses with ProviderKeyMissingError BEFORE any batch runs. */
providerKeyMissing ?: ( provider : string , tier : ModelSlotName ) => ProviderKeyMissing | null ;
2026-08-31 22:36:41 +03:00
}
2026-09-24 15:12:24 +03:00
export interface ExtractionPipelineDeps extends GraphDeps , ExtractionModels {
/** The chunk budget — the transcript-token share of the extractor's
* context; batches fill to it (the trigger fires on the same number,
* read fresh per turn by the composition root). OPTIONAL with the
* factory default (4096) so test fixtures stay minimal; the server
* callers wire the live effective settings (extraction-UX redesign,
* 2026-09-24). */
getChunkTokens ?: () => number ;
}
2026-08-31 22:36:41 +03:00
2026-09-13 02:08:55 +03:00
export interface NotesImportInput {
/** The source's file path — its identity (deterministic src id) AND its
* provenance label in the detail card. */
filePath : string ;
/** The file's raw content, VERBATIM — any format. */
text : string ;
}
2026-10-23 04:43:56 +03:00
/** A notes import did not finish. Earlier graph writes and billed usage are
* retained; partialStats describe known merges, not an all-or-nothing result. */
export class NotesImportError extends Error {
constructor (
public readonly outcome : "failed" | "partial" | "aborted" | "busy" ,
public readonly partialStats : EmissionStats ,
message : string ,
options? : ErrorOptions ,
) {
super ( message , options );
this . name = "NotesImportError" ;
}
}
2026-08-31 22:36:41 +03:00
export interface ExtractionPipeline {
/** Extract + audit + merge ONE chat. Single-flight per chat; resolves to
* the merger's append stats (or throws — no checkpoint on failure). */
runExtraction ( chatId : string ) : Promise < EmissionStats >;
2026-09-13 02:08:55 +03:00
/** The format-agnostic front door (2026-09-13): run the same pipeline
* over ONE raw notes file. The source id is deterministic per file
* path, so re-importing the same file re-runs extraction against the
2026-09-28 16:54:44 +03:00
* same source identity (mappings dedupe by it). */
2026-09-13 02:08:55 +03:00
importNotes ( input : NotesImportInput ) : Promise < EmissionStats >;
2026-08-31 22:36:41 +03:00
}
2026-09-19 20:55:35 +03:00
/** pi's per-step usage, flattened to the persisted CallUsage fields
* (the money channel's capture shape, contracts/chat.ts). Exported for
* the cluster namer — the same capture shape (2026-09 graph cost
* metrics wave). Module scope: no closure deps, pure projection. */
export function callUsage ( usage : Usage ) : CallUsage {
return {
inputTokens : usage.input ,
outputTokens : usage.output ,
cacheReadTokens : usage.cacheRead ,
cacheWriteTokens : usage.cacheWrite ,
2026-10-24 19:05:02 +03:00
cacheWrite1hTokens : usage.cacheWrite1h ?? 0 ,
2026-09-19 20:55:35 +03:00
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens : usage.reasoning ?? 0 ,
};
}
2026-08-31 22:36:41 +03:00
export function createExtractionPipeline ( deps : ExtractionPipelineDeps ) : ExtractionPipeline {
2026-09-23 15:37:34 +03:00
const { chatStore , graphStore } = deps ;
2026-09-22 16:58:25 +03:00
// Stage-engine resolution: a direct test value wins; otherwise the
// engineFor chain (role override → slot → nothing, resolved FRESH per
// stage call server-side) — the job runner's exact treatment. undefined
// = the BYOK chain came up empty; the front-door guards turn that into
// an honest refusal.
2026-09-22 13:59:16 +03:00
const stageEngine = ( role : ModelRole , override? : PiEngine ) : PiEngine | undefined =>
override ?? deps . engineFor ? .( role );
2026-09-22 16:58:25 +03:00
/** The per-need refusal guard: which slot tiers the pipeline's stages
2026-09-24 22:51:12 +03:00
* found unset (test values count as configured — they ARE engines),
* and whether a configured stage's provider key is gone (provider-key
* interlude, 2026-09-25) — both refuse BEFORE any batch runs. */
function refuseIfStagesUnready () : void {
const stages : readonly [ ModelRole , PiEngine | undefined ][] = [
[ "extractor" , stageEngine ( "extractor" , deps . extractor )],
[ "auditor" , stageEngine ( "auditor" , deps . auditor )],
];
2026-09-22 16:58:25 +03:00
const missing = new Set < ModelSlotName >();
2026-09-24 22:51:12 +03:00
for ( const [ role , engine ] of stages ) if ( ! engine ) missing . add ( ROLE_SLOT [ role ]);
2026-09-22 16:58:25 +03:00
if ( missing . size > 0 ) {
console . info ( `[extraction] refused — no model configured for: ${ [... missing ]. join ( ", " ) } ` );
throw new ModelsNotConfiguredError ([... missing ]);
}
2026-09-24 22:51:12 +03:00
// Key-presence parity: first offender surfaces (extractor before
// auditor); fixing that key re-gates and the next run surfaces the
// next offender, if any.
for ( const [ role , engine ] of stages ) {
const refusal = deps . providerKeyMissing ? .( engine ! . model . provider , ROLE_SLOT [ role ]);
if ( refusal ) {
console . info ( `[extraction] refused — ${ JSON . stringify ( refusal ) } ` );
throw new ProviderKeyMissingError ( refusal . provider , refusal . slots );
}
}
2026-09-22 16:58:25 +03:00
}
2026-09-19 13:16:34 +03:00
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
// instance (route entries in dev, tests) sees the same runs, the same
// single-flight guard. No module-level state left in this factory.
const runningExtractions = runs (). running ;
const inFlight = runs (). inFlight ;
2026-08-31 22:36:41 +03:00
2026-09-29 00:34:21 +03:00
function setPhase (
jobKey : string ,
phase : ExtractionPhase ,
kind : "chat" | "notes" ,
label? : string ,
part ?: { i : number ; n : number },
) : void {
2026-09-19 13:16:34 +03:00
const R = runs ();
2026-09-29 00:34:21 +03:00
const prev = runningExtractions . get ( jobKey );
2026-09-19 13:16:34 +03:00
if ( ! prev ) R . lastStartedAt = Math . max ( Date . now (), R . lastStartedAt + 1 );
2026-09-29 00:34:21 +03:00
runningExtractions . set ( jobKey , {
chatId : prev?.chatId ?? jobKey . split ( "#" )[ 0 ] ! ,
part : prev?.part ?? part , // phase changes never lose the part
2026-09-13 02:08:55 +03:00
kind ,
label : prev?.label ?? label , // phase changes never lose the label
2026-09-09 01:26:49 +03:00
phase ,
2026-09-19 13:16:34 +03:00
startedAt : prev?.startedAt ?? R . lastStartedAt ,
2026-09-11 03:25:25 +03:00
usage : prev?.usage ?? {}, // phase changes never lose usage
controller : prev?.controller ?? new AbortController (),
2026-09-09 01:26:49 +03:00
});
2026-10-02 17:31:35 +03:00
// Every phase transition announces the dock (push S5, 2026-10-02):
// notify-after-write — the registry is the feed's truth.
emit ( "activity-changed" , {});
}
/** Run over (done, failed, aborted — every exit is a finally): the row
* leaves the feed. ONE seam for all delete sites so no exit path can
* miss the announce (push S5, 2026-10-02). */
function finishExtraction ( jobKey : string ) : void {
runningExtractions . delete ( jobKey );
emit ( "activity-changed" , {});
2026-09-09 01:26:49 +03:00
}
2026-09-11 03:25:25 +03:00
/** Accumulate one billed step into the run's registry entry (live). */
2026-09-30 17:44:37 +03:00
function recordStep ( jobKey : string , stage : "extractor" | "auditor" , usage : CallUsage , model : string ) : void {
2026-09-29 00:34:21 +03:00
const run = runningExtractions . get ( jobKey );
2026-09-11 03:25:25 +03:00
if ( ! run ) return ; // not running (tests driving stages directly)
run . usage [ stage ] ??= { model , steps : [] };
run . usage [ stage ] ! . steps . push ( usage );
}
2026-09-28 18:00:56 +03:00
/** SERIALIZED AUDIT (owner 2026-09-28): the auditor explores the LIVE
* graph through its tools and stamps identity verdicts against what
* it finds — so the audit+merge section is a process-wide critical
* section. Two concurrent audits would each link into a graph the
* other is about to change: the structural mint of duplicate nodes.
* The extractor stays parallel (source-local, graph-blind). The
* check runs BEFORE any LLM is invoked or the graph read; the lock
* is FIFO, held through the merge, reacquired per batch, and
* abort-aware on both sides of the queue (a waiter that aborts
* leaves without auditing — its chain link still resolves, so
* successors never hang). */
2026-09-29 00:34:21 +03:00
async function withAuditLock < T >( jobKey : string , kind : "chat" | "notes" , label : string | undefined , critical : () => Promise < T >) : Promise < T > {
setPhase ( jobKey , "waiting" , kind , label );
2026-09-28 18:00:56 +03:00
const g = globalThis as typeof globalThis & LhExtractionGlobal ;
const lock = ( g . __lhAuditLock ??= { tail : Promise.resolve () });
const prev = lock . tail ;
let release !: () => void ;
lock . tail = new Promise < void >(( resolve ) => ( release = resolve ));
2026-09-29 00:34:21 +03:00
const signal = runningExtractions . get ( jobKey ) ? . controller . signal ;
2026-09-28 18:00:56 +03:00
let onAbort : (() => void ) | undefined ;
const aborted = new Promise < never >(( _ , reject ) => {
if ( ! signal ) return ;
onAbort = () => {
const err = new Error ( "extraction aborted while waiting for the audit gate" );
err . name = "AbortError" ;
reject ( err );
};
if ( signal . aborted ) onAbort ();
else signal . addEventListener ( "abort" , onAbort , { once : true });
});
try {
await Promise . race ([ prev , aborted ]);
signal ? . throwIfAborted (); // aborted exactly at handoff
return await critical ();
} finally {
if ( onAbort && signal && ! signal . aborted ) signal . removeEventListener ( "abort" , onAbort );
release ();
}
}
2026-10-13 08:26:31 +03:00
async function extract (
jobKey : string ,
sourceId : string ,
view : string ,
batchIds : ReadonlySet < string >,
) : Promise < SessionFindings > {
2026-09-29 00:34:21 +03:00
const signal = runningExtractions . get ( jobKey ) ? . controller . signal ;
signal ? . throwIfAborted (); // an abort during a sync gap ends the job here
2026-09-30 17:44:37 +03:00
// THE DIGEST (owner, 2026-09-30 evening): the extractor continues a
// partly-extracted source — it sees everything this source has
// already yielded (propositions + chains, ids verbatim) and builds
// on it. Serialized batches make this read the post-merge graph of
// every earlier batch (cross-source runs stay parallel — the
// digest is source-scoped, so only SAME-source order matters).
const graph = getGraph ( graphStore );
const digest = sourceDigest ( graph , sourceId );
const prompt = digest === "" ? view : ` ${ digest } \ n \ n ${ view } ` ;
console . info (
`[extraction ${ jobKey } ] extractor starting ( ${ view . split ( "\n" ). length } markdown lines` +
` ${ digest === "" ? "" : `, digest ${ digest . split ( "\n" ). length } lines` } )` ,
);
2026-09-05 21:36:40 +03:00
const t0 = performance . now ();
2026-08-31 22:36:41 +03:00
2026-09-12 02:34:18 +03:00
const findings = await runExtractor (
2026-09-22 13:59:16 +03:00
stageEngine ( "extractor" , deps . extractor ),
2026-10-04 20:21:18 +03:00
sourceId ,
2026-09-30 17:44:37 +03:00
prompt ,
new Set ( graph . nodes . map (( n ) => n . id )),
2026-10-13 08:26:31 +03:00
batchIds ,
2026-09-12 02:34:18 +03:00
signal ,
( usage , model , step , finishReason , tools ) => {
2026-09-29 00:34:21 +03:00
recordStep ( jobKey , "extractor" , callUsage ( usage ), model );
2026-09-12 02:34:18 +03:00
console . info ( `[extraction] extractor step ${ step } : ${ finishReason } , tools: ${ tools } ` );
2026-09-11 03:25:25 +03:00
},
2026-09-12 02:34:18 +03:00
);
2026-09-29 00:34:21 +03:00
console . info ( `[extraction ${ jobKey } ] extractor done in ${ Math . round ( performance . now () - t0 ) } ms` );
2026-09-12 02:34:18 +03:00
return findings ;
2026-08-31 22:36:41 +03:00
}
2026-10-04 20:21:18 +03:00
async function audit ( jobKey : string , sourceId : string , findings : SessionFindings ) : Promise < AuditVerdicts > {
2026-09-29 00:34:21 +03:00
const signal = runningExtractions . get ( jobKey ) ? . controller . signal ;
2026-09-11 03:25:25 +03:00
signal ? . throwIfAborted (); // an abort during the extract→audit gap ends here
2026-09-08 22:19:37 +03:00
const graph = getGraph ( graphStore );
2026-09-01 01:47:47 +03:00
console . info (
2026-09-30 03:22:52 +03:00
`[extraction ${ jobKey } ] auditor starting ( ${ findings . propositions . length } propositions, ` +
2026-09-28 16:54:44 +03:00
` ${ graph . nodes . length } graph nodes)` ,
2026-09-01 01:47:47 +03:00
);
2026-09-05 21:36:40 +03:00
const t0 = performance . now ();
2026-08-31 22:36:41 +03:00
2026-09-28 16:54:44 +03:00
// The auditor NEVER sees the source (owner 2026-09-27): findings
// JSON + identity instructions, and the graph reached through
2026-10-13 08:26:31 +03:00
// exploration tools instead of an injected dump. The proposition
// projection omits messageId (2026-10-13): the auditor is
// IDENTITY-only and never sees the passages, so anchors are useless
// to it — they add cognitive load, not signal.
2026-09-12 02:34:18 +03:00
const auditResult = await runAuditor (
2026-09-22 13:59:16 +03:00
stageEngine ( "auditor" , deps . auditor ),
2026-10-04 20:21:18 +03:00
sourceId ,
2026-09-30 03:22:52 +03:00
`Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted; the propositions only — the reasoning chains are the merger's business, owner 2026-09-30): \ n \ n` +
2026-10-13 08:26:31 +03:00
` ${ JSON . stringify (
{
propositions : findings.propositions.map (( p ) => ({
id : p.id ,
name : p.name ,
statement : p.statement ,
aliases : p.aliases ,
} )),
},
null,
2,
)} \ n \ n` +
2026-09-28 16:54:44 +03:00
`The current knowledge graph has ${ graph . nodes . length } node(s). Explore it with your tools and link what links.` ,
2026-09-12 02:34:18 +03:00
signal ,
( usage , model , step , finishReason , tools ) => {
2026-09-29 00:34:21 +03:00
recordStep ( jobKey , "auditor" , callUsage ( usage ), model );
2026-09-12 02:34:18 +03:00
console . info ( `[extraction] auditor step ${ step } : ${ finishReason } , tools: ${ tools } ` );
2026-09-11 03:25:25 +03:00
},
2026-09-28 16:54:44 +03:00
auditorGraphTools ({ chatStore , graphStore }),
2026-09-12 02:34:18 +03:00
);
2026-09-01 01:47:47 +03:00
console . info (
2026-09-29 00:34:21 +03:00
`[extraction ${ jobKey } ] audit: ${ findings . propositions . length } propositions through, ` +
2026-09-29 01:16:32 +03:00
` ${ auditResult . links . length } linked to existing nodes` ,
2026-09-01 01:47:47 +03:00
);
2026-09-29 00:34:21 +03:00
console . info ( `[extraction ${ jobKey } ] auditor done in ${ Math . round ( performance . now () - t0 ) } ms` );
2026-09-08 22:19:37 +03:00
return auditResult ;
2026-08-31 22:36:41 +03:00
}
2026-09-29 00:34:21 +03:00
2026-09-29 20:42:33 +03:00
/** THE EXTRACT SLOT (unified 2026-09-29): extraction chunk jobs run
* under the SHARED background-LLM semaphore — one process-wide pool
2026-10-04 21:34:24 +03:00
* of 3 permits (see bg-llm-semaphore.ts). The
2026-09-29 20:42:33 +03:00
* job's registry-row signal kills a queued ticket (dead-ticket
* handoff). The audit side needs no slot: audits already serialize
* behind their own global lock. */
2026-09-29 00:34:21 +03:00
async function withExtractSlot < T >( jobKey : string , critical : () => Promise < T >) : Promise < T > {
2026-09-29 20:42:33 +03:00
const signal = runningExtractions . get ( jobKey ) ? . controller . signal ;
return withBgLlmSlot ( signal , critical );
2026-09-29 00:34:21 +03:00
}
2026-09-29 20:42:33 +03:00
/** Append the run's usage record to the chat's extractions log — success
2026-09-11 03:25:25 +03:00
* or failure: a failed run still spent money, and its partial usage is
* exactly what the record should show (stats absent = never merged).
* Idempotent per startedAt (the store dedupes). */
2026-09-29 00:34:21 +03:00
function persistRunUsage ( jobKey : string , durationMs : number , stats : EmissionStats | undefined ) : void {
const run = runningExtractions . get ( jobKey );
2026-09-23 02:41:04 +03:00
if ( ! run ) return ; // not running (tests driving stages directly)
2026-09-29 00:34:21 +03:00
// PER-CHUNK JOBS (owner, 2026-09-28): the registry KEY may carry a
// "#i" suffix — the usage LEDGERS key by SOURCE (run.chatId): one
// job's record per chunk, all appended to the source's log.
2026-09-23 02:41:04 +03:00
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run has no
// usage log to append to — the record's death is the user's choice
// and its partial spend logs with it (appendSourceUsage below
// already no-ops for a vanished source; the chat path guards here).
2026-09-29 00:34:21 +03:00
if ( run . kind === "chat" && ! chatStore . getChat ( run . chatId )) return ;
2026-09-13 02:08:55 +03:00
const entry : ExtractionRunUsage = {
2026-09-11 03:25:25 +03:00
startedAt : run.startedAt ,
at : new Date (). toISOString (),
durationMs ,
stats ,
stages : {
extractor : run.usage.extractor ?? { model : "unknown" , steps : [] },
auditor : run.usage.auditor ?? { model : "unknown" , steps : [] },
},
2026-09-13 02:08:55 +03:00
};
2026-09-29 00:34:21 +03:00
if ( run . kind === "notes" ) graphStore . appendSourceUsage ( run . chatId , entry );
else chatStore . appendExtractionUsage ( run . chatId , entry );
}
/** One chunk/batch JOB (per-chunk jobs, owner 2026-09-28): the registry
* entry's lifecycle — minted with its part, usage persisted per job
* (the ledger shows each chunk's cost), removed on any exit. The body
* runs the full queued → extract → audit-locked merge lifecycle;
* failure isolation lives at the fan-out site (one bad chunk never
* kills its siblings). */
async function runJob (
source : { id : string ; kind : "chat" | "notes" ; label? : string ; part ?: { i : number ; n : number } },
body : ( jobKey : string ) => Promise < EmissionStats >,
) : Promise < EmissionStats > {
const jobKey = source . part ? ` ${ source . id } # ${ source . part . i } ` : source . id ;
const t0 = performance . now ();
try {
const stats = await body ( jobKey );
persistRunUsage ( jobKey , Math . round ( performance . now () - t0 ), stats );
return stats ;
} catch ( err ) {
persistRunUsage ( jobKey , Math . round ( performance . now () - t0 ), undefined );
throw err ;
} finally {
2026-10-02 17:31:35 +03:00
finishExtraction ( jobKey );
2026-09-29 00:34:21 +03:00
}
}
/** The fan-out aggregation (per-chunk jobs, owner 2026-09-28): successes
* count (earlier chunks keep their merges — checkpoints make aborts
* cheap), aborts log and pass as partial stats, isolated failures log
* per job and never throw — the source's run returns what actually
* merged. */
function aggregateJobs ( sourceId : string , settled : PromiseSettledResult < EmissionStats >[]) : EmissionStats {
2026-09-30 03:22:52 +03:00
const stats : EmissionStats = { nodes : 0 , chains : 0 };
2026-09-29 00:34:21 +03:00
let aborted = false ;
let failed = 0 ;
for ( const result of settled ) {
if ( result . status === "fulfilled" ) {
stats . nodes += result . value . nodes ;
2026-09-30 03:22:52 +03:00
stats . chains ! += result . value . chains ?? 0 ;
2026-09-30 17:44:37 +03:00
// Simplification maintenance, summed like appends — but ABSENT
// when nothing dropped (deep-equal pins read cleaner).
if ( result . value . droppedChains )
stats . droppedChains = ( stats . droppedChains ?? 0 ) + result . value . droppedChains ;
2026-09-29 00:34:21 +03:00
continue ;
}
const err = result . reason ;
if ( err instanceof Error && err . name === "AbortError" ) aborted = true ;
else {
failed ++ ;
console . error ( `[extraction ${ sourceId } ] job failed (isolated):` , err );
}
}
if ( aborted )
console . info (
`[extraction ${ sourceId } ] aborted — merged chunks keep their coverage, the rest stay uncovered for the next run` ,
);
if ( failed > 0 )
console . error ( `[extraction ${ sourceId } ] ${ failed } job(s) failed; successful jobs still merged` );
return stats ;
2026-09-11 03:25:25 +03:00
}
2026-08-31 22:36:41 +03:00
async function runExtraction ( chatId : string ) : Promise < EmissionStats > {
2026-09-30 03:22:52 +03:00
if ( inFlight . has ( chatId )) return { nodes : 0 , chains : 0 };
2026-08-31 22:36:41 +03:00
inFlight . add ( chatId );
2026-09-05 21:36:40 +03:00
const t0 = performance . now ();
2026-09-15 17:41:56 +03:00
// COVERAGE (vision-extraction-coverage.md, 2026-09-15): extract ONLY
// what the per-chat coverage ledger doesn't already hold — never the
// whole session. Batches are token-budgeted runs of the oldest
// uncovered messages; each merged batch checkpoints its messages'
// coverage ATOMICALLY (inside the merger's step 4), so an abort or
// error below loses at most the in-flight batch — every earlier
// batch stays covered, the rest stays visibly uncovered.
2026-08-31 22:36:41 +03:00
try {
2026-09-13 02:08:55 +03:00
const record = chatStore . getChat ( chatId );
if ( ! record ) throw new Error ( `Chat not found: ${ chatId } ` );
2026-09-24 22:51:12 +03:00
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): the write channel needs extractor + auditor
// engines AND their providers' keys; a refusal lands BEFORE any
2026-09-22 16:58:25 +03:00
// stage runs (typed — the trigger skips, routes surface 503).
2026-09-24 22:51:12 +03:00
refuseIfStagesUnready ();
2026-09-26 01:52:47 +03:00
const covered = coverageOf ( record . messages , graphStore . readCoverage (), chatId );
2026-09-15 17:41:56 +03:00
if ( covered . uncovered . length === 0 ) {
console . info ( `[extraction ${ chatId } ] nothing uncovered — no-op` );
2026-09-30 03:22:52 +03:00
return { nodes : 0 , chains : 0 };
2026-09-15 17:41:56 +03:00
}
2026-09-24 15:12:24 +03:00
const chunkTokens = deps . getChunkTokens ? .() ?? FACTORY_SETTINGS . graph . extractionChunkTokens ;
2026-09-26 01:52:47 +03:00
// The batcher's one rule (owner, Wave D): a tool-call message and
// its result message(s) are INSEPARABLE — one chunk, covered
// together, read together (core/coverage.ts batchRuns).
const batches = batchRuns ( covered . slots , covered . uncovered , chunkTokens );
2026-09-15 17:41:56 +03:00
console . info (
`[extraction ${ chatId } ] pipeline start — ${ covered . uncoveredTokens } uncovered tok, ` +
2026-09-24 15:12:24 +03:00
` ${ covered . uncoveredCount } messages, ${ batches . length } batch(es) of ≤ ${ chunkTokens } tok` ,
2026-09-15 17:41:56 +03:00
);
2026-09-30 17:44:37 +03:00
// PER-CHUNK JOBS (owner, 2026-09-28), SERIALIZED PER SOURCE (owner,
// 2026-09-30 evening): every batch is its own job — registry row,
// because each batch's extractor continues the source's digest
// (everything the earlier batches merged). Cross-source runs stay
// parallel; the 3-slot extract semaphore still caps concurrent
// extracts process-wide ("queued"); audits serialize through the
// process-wide gate ("waiting"). A batch that fails is ISOLATED
// (its messages stay uncovered — honestly re-runnable); its
// siblings keep merging.
const settled : PromiseSettledResult < EmissionStats >[] = [];
for ( const [ batchIndex , batch ] of batches . entries ()) {
const part = batches . length > 1 ? { i : batchIndex + 1 , n : batches.length } : undefined ;
try {
settled . push ({
status : "fulfilled" ,
value : await runJob ({ id : chatId , kind : "chat" , part }, async ( jobKey ) => {
// The batch's source view: TEACHER PROSE ONLY (owner,
2026-10-13 08:26:31 +03:00
// 2026-09-28), each passage labeled with its ACTUAL message
// id (2026-10-13) — the view lines ARE the coverage
// fingerprints' messageViewLines ("TEACHER [id] text"), one
// shared seam.
const messages = batch . map (( slot ) => slot . message );
const view = proseView ( messages , record . title );
// The batch's teacher-prose ids — the ONLY anchors this
// job's submit may name (validateSessionAnchors at the
// submit seam refuses anything else: ids from other
// batches — even real ones of this chat — other chats,
// and non-prose messages).
const batchIds = new Set (
messages . filter (( m ) => messageViewLines ( m , m . id ). length > 0 ). map (( m ) => m . id ),
2026-09-29 00:34:21 +03:00
);
2026-09-30 17:44:37 +03:00
setPhase ( jobKey , "queued" , "chat" , undefined , part );
const findings = await withExtractSlot ( jobKey , async () => {
setPhase ( jobKey , "extracting" , "chat" , undefined , part );
2026-10-13 08:26:31 +03:00
return extract ( jobKey , chatId , view , batchIds );
2026-09-30 17:44:37 +03:00
});
// SERIALIZED AUDIT (owner, 2026-09-28): audit + merge are the
// graph-mutating critical section — one job at a time,
// process-wide, so the auditor explores the graph as it
// stands AFTER every earlier job's merge.
return withAuditLock ( jobKey , "chat" , undefined , async () => {
setPhase ( jobKey , "auditing" , "chat" , undefined , part );
2026-10-04 20:21:18 +03:00
const auditResult = await audit ( jobKey , chatId , findings );
2026-09-30 17:44:37 +03:00
setPhase ( jobKey , "merging" , "chat" , undefined , part );
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
// merge. The deletion route aborts this run first and runs its purge
// as one sync block; this closes the last window — a run that slipped
// past the wind-down finds the chat gone HERE and rides the existing
// abort path (info, not error): nothing merged, nothing checkpointed.
if ( ! chatStore . getChat ( chatId )) {
const gone = new Error ( `chat deleted mid-run: ${ chatId } ` );
gone . name = "AbortError" ;
throw gone ;
}
// The batch's messages, snapshotted when the run computed
// coverage — if a message mutates mid-run (a quiz answer
// merges in), the stored fingerprint won't match its new
// one and it re-opens: honest by construction.
const foldedAt = new Date (). toISOString ();
const batchStats = mergeFindings ( chatId , findings , auditResult , { chatStore , graphStore }, {
foldedAt ,
coverage : batch
. filter (( slot ) => messageViewLines ( slot . message , slot . message . id ). length > 0 )
. map (( slot ) => ({ chatId , fp : slot.fp , tokens : slot.tokens , foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
// next snapshot fetch must already reflect the new nodes).
settleGraphLayout ( graphStore );
console . info (
`[extraction ${ jobKey } ] merged: ${ batchStats . nodes } nodes, ${ batchStats . chains ?? 0 } chains` ,
);
return batchStats ;
});
}),
2026-09-28 18:00:56 +03:00
});
2026-09-30 17:44:37 +03:00
} catch ( err ) {
settled . push ({ status : "rejected" , reason : err });
// A source-wide abort (user abort / session deletion)
// ends the WHOLE run: later batches would mint fresh,
// un-aborted jobs and hang — stop here. Isolated
// FAILURES keep going (siblings still merge).
if ( err instanceof Error && err . name === "AbortError" ) break ;
}
}
2026-09-29 00:34:21 +03:00
const stats = aggregateJobs ( chatId , settled );
2026-09-05 21:36:40 +03:00
console . info (
2026-09-30 03:22:52 +03:00
`[extraction ${ chatId } ] merged: ${ stats . nodes } nodes, ${ stats . chains ?? 0 } chains ` +
2026-09-29 00:34:21 +03:00
`( ${ Math . round ( performance . now () - t0 ) } ms total, ${ batches . length } batch job(s))` ,
2026-09-05 21:36:40 +03:00
);
2026-08-31 22:36:41 +03:00
return stats ;
2026-09-05 21:36:40 +03:00
} catch ( err ) {
2026-09-29 00:34:21 +03:00
// Pre-fan-out failures only (stage gate, coverage read): aborts
// and job-level failures are absorbed at the fan-out site, per
// job — merged batches stay covered, partial usage persists per
// job, the aggregate returns what actually merged.
console . error ( `[extraction ${ chatId } ] pipeline failed ( ${ Math . round ( performance . now () - t0 ) } ms):` , err );
2026-09-05 21:36:40 +03:00
throw err ;
2026-08-31 22:36:41 +03:00
} finally {
inFlight . delete ( chatId );
2026-10-02 17:31:35 +03:00
finishExtraction ( chatId ); // done or failed — no longer running
2026-08-31 22:36:41 +03:00
}
}
2026-09-13 02:08:55 +03:00
// ── the notes front door (format-agnostic: raw text in, same graph out) ──
/** The raw source view the notes stages see: the file path + the text
* VERBATIM. No message ids — provenance is the file itself. */
2026-09-28 21:28:39 +03:00
function notesView ( input : { filePath : string ; text : string ; part ?: { i : number ; n : number } }) : string {
const part = input . part && input . part . n > 1 ? ` (part ${ input . part . i } / ${ input . part . n } )` : "" ;
return `SOURCE (the author's personal notes): ${ input . filePath }${ part } \ n \ n ${ input . text } ` ;
2026-09-13 02:08:55 +03:00
}
2026-09-28 21:28:39 +03:00
/** Chunk the notes text under the same budget the chat path uses
* (owner, 2026-09-28: the whole-file-in-one-call design collapsed a
* weak model — 26 propositions, structure bleed mid-emission).
* Greedy by line, chars/4 like every other estimate in the app; a
* single over-budget line rides alone (never split a line). */
function chunkNotes ( text : string , budget : number ) : string [] {
if ( text . trim (). length === 0 ) return [];
const out : string [] = [];
let current = "" ;
for ( const line of text . split ( "\n" )) {
const candidate = current . length === 0 ? line : current + "\n" + line ;
if ( current . length > 0 && Math . ceil ( candidate . length / 4 ) > budget ) {
out . push ( current );
current = line ;
} else {
current = candidate ;
}
}
if ( current . trim (). length > 0 ) out . push ( current );
return out ;
}
2026-09-29 00:34:21 +03:00
async function notesExtract (
jobKey : string ,
input : { id : string ; filePath : string ; text : string ; part ?: { i : number ; n : number } },
) : Promise < NotesFindings > {
// jobKey (per-chunk jobs, owner 2026-09-28): the registry key —
// "src#i" for chunk jobs — so the abort signal reaches the call.
// input.id stays the SOURCE id for the view and store ledgers.
const signal = runningExtractions . get ( jobKey ) ? . controller . signal ;
signal ? . throwIfAborted (); // an abort during a sync gap ends the job here
2026-09-13 02:08:55 +03:00
const view = notesView ( input );
2026-09-30 17:44:37 +03:00
// Same digest law as the chat door (owner, 2026-09-30 evening): the
// chunk extractor continues its own source, ids verbatim.
const graph = getGraph ( graphStore );
const digest = sourceDigest ( graph , input . id );
const prompt = digest === "" ? view : ` ${ digest } \ n \ n ${ view } ` ;
console . info (
`[extraction ${ jobKey } ] notes extractor starting ( ${ view . split ( "\n" ). length } source lines` +
` ${ digest === "" ? "" : `, digest ${ digest . split ( "\n" ). length } lines` } )` ,
);
2026-09-13 02:08:55 +03:00
const t0 = performance . now ();
const findings = await runNotesExtractor (
2026-09-22 13:59:16 +03:00
stageEngine ( "extractor" , deps . extractor ),
2026-10-04 20:21:18 +03:00
input . id ,
2026-09-30 17:44:37 +03:00
prompt ,
new Set ( graph . nodes . map (( n ) => n . id )),
2026-09-13 02:08:55 +03:00
signal ,
( usage , model , step , finishReason , tools ) => {
2026-09-29 00:34:21 +03:00
recordStep ( jobKey , "extractor" , callUsage ( usage ), model );
2026-09-13 02:08:55 +03:00
console . info ( `[extraction] extractor step ${ step } : ${ finishReason } , tools: ${ tools } ` );
},
);
2026-09-29 00:34:21 +03:00
console . info ( `[extraction ${ jobKey } ] notes extractor done in ${ Math . round ( performance . now () - t0 ) } ms` );
2026-09-13 02:08:55 +03:00
return findings ;
}
2026-09-28 16:54:44 +03:00
// The notes auditor is the SAME identity-linker as the chat one (one
// schema now that attribution is gone) — audit() above, verbatim.
2026-09-13 02:08:55 +03:00
async function runNotesExtraction ( input : { id : string ; filePath : string ; text : string ; importedAt : string }) : Promise < EmissionStats > {
const { id } = input ;
2026-10-23 04:43:56 +03:00
// importNotes owns the source exclusion, including metadata and cleanup.
2026-09-13 02:08:55 +03:00
const t0 = performance . now ();
2026-10-23 04:43:56 +03:00
const mergedStats : EmissionStats = { nodes : 0 , chains : 0 };
2026-09-13 02:08:55 +03:00
try {
2026-09-24 22:51:12 +03:00
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): same refusal as the chat front door — no
// extractor/auditor engines, or a stage key gone, no run.
refuseIfStagesUnready ();
2026-09-28 21:28:39 +03:00
// CHUNKED like the chat path (owner, 2026-09-28): each chunk is a
// standalone extract → audit → merge, so no single call carries a
// whole large file. Abort mid-run keeps earlier chunks merged
// (same doctrine as chat batches).
const chunkTokens = deps . getChunkTokens ? .() ?? FACTORY_SETTINGS . graph . extractionChunkTokens ;
const chunks = chunkNotes ( input . text , chunkTokens );
console . info (
`[extraction ${ id } ] notes pipeline start ( ${ input . filePath } ) — ${ chunks . length } chunk(s) of ≤ ${ chunkTokens } tok` ,
);
2026-09-30 17:44:37 +03:00
// PER-CHUNK JOBS (owner, 2026-09-28), SERIALIZED PER SOURCE (owner,
// 2026-09-30 evening): same law as the chat door — chunks run
// STRICTLY IN ORDER, each chunk's extractor continuing the
// source's digest (everything earlier chunks merged). Cross-source
// runs stay parallel; the semaphore caps concurrent extracts
// process-wide; audits serialize through the gate as before. The
// part rides the registry entry (feed suffix "· i/N") only when
// the file actually chunked.
const settled : PromiseSettledResult < EmissionStats >[] = [];
for ( const [ chunkIndex , chunk ] of chunks . entries ()) {
const part = chunks . length > 1 ? { i : chunkIndex + 1 , n : chunks.length } : undefined ;
2026-10-23 04:43:56 +03:00
// Keep the actual signal after runJob tears down its registry row:
// usage persistence can throw and mask the body's AbortError.
let chunkSignal : AbortSignal | undefined ;
2026-09-30 17:44:37 +03:00
try {
settled . push ({
status : "fulfilled" ,
value : await runJob ({ id , kind : "notes" , label : input.filePath , part }, async ( jobKey ) => {
setPhase ( jobKey , "queued" , "notes" , input . filePath , part );
2026-10-23 04:43:56 +03:00
chunkSignal = runningExtractions . get ( jobKey ) ? . controller . signal ;
2026-09-30 17:44:37 +03:00
const findings = await withExtractSlot ( jobKey , async () => {
setPhase ( jobKey , "extracting" , "notes" , input . filePath , part );
return notesExtract ( jobKey , { ... input , text : chunk , part });
});
// SERIALIZED AUDIT (owner, 2026-09-28): audit + merge one
// at a time, process-wide — the auditor explores the graph
// as it stands AFTER every earlier job's merge. Reacquired
// per chunk so cross-source runs interleave fairly.
return withAuditLock ( jobKey , "notes" , input . filePath , async () => {
setPhase ( jobKey , "auditing" , "notes" , input . filePath , part );
2026-10-04 20:21:18 +03:00
const auditResult = await audit ( jobKey , input . id , findings );
2026-09-30 17:44:37 +03:00
setPhase ( jobKey , "merging" , "notes" , input . filePath , part );
const merged = mergeFindings (
id ,
findings ,
auditResult ,
{ chatStore , graphStore },
{ foldedAt : input.importedAt },
);
2026-10-23 04:43:56 +03:00
// Capture the committed merge BEFORE layout/usage can fail.
mergedStats . nodes += merged . nodes ;
mergedStats . chains ! += merged . chains ?? 0 ;
if ( merged . droppedChains )
mergedStats . droppedChains = ( mergedStats . droppedChains ?? 0 ) + merged . droppedChains ;
2026-09-30 17:44:37 +03:00
// The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk.
settleGraphLayout ( graphStore );
console . info ( `[extraction ${ jobKey } ] merged: ${ merged . nodes } nodes, ${ merged . chains ?? 0 } chains` );
return merged ;
});
}),
2026-09-29 00:34:21 +03:00
});
2026-09-30 17:44:37 +03:00
} catch ( err ) {
2026-10-23 04:43:56 +03:00
let failure = err ;
if ( chunkSignal ? . aborted && ! ( err instanceof Error && err . name === "AbortError" )) {
// Notes-only: never start a fresh chunk after user cancellation,
// even when usage cleanup replaced the original abort rejection.
// Keep the secondary failure visible in the final import error.
const aborted = new Error ( `Notes import aborted; job cleanup failed: ${ err instanceof Error ? err.message : String ( err ) } ` , { cause : err });
aborted . name = "AbortError" ;
failure = aborted ;
}
settled . push ({ status : "rejected" , reason : failure });
2026-09-30 17:44:37 +03:00
// Same source-wide-abort law as the chat door: stop the
// run; isolated failures keep going.
2026-10-23 04:43:56 +03:00
if ( failure instanceof Error && failure . name === "AbortError" ) break ;
2026-09-30 17:44:37 +03:00
}
}
2026-09-29 00:34:21 +03:00
const stats = aggregateJobs ( id , settled );
2026-10-23 04:43:56 +03:00
// Completion is a chunk-status contract, never a node-count test.
// Zero findings (and an empty file after the readiness gate) are valid.
const rejected = settled . filter (( r ) => r . status === "rejected" );
if ( rejected . length > 0 || settled . length !== chunks . length ) {
const aborted = rejected . some (( r ) => r . reason instanceof Error && r . reason . name === "AbortError" );
const outcome = aborted ? "aborted" : settled . some (( r ) => r . status === "fulfilled" ) ? "partial" : "failed" ;
const details = rejected . map (( r ) => r . reason instanceof Error ? r.reason.message : String ( r . reason )). join ( "; " );
throw new NotesImportError ( outcome , mergedStats , details || "Notes import did not complete" );
}
2026-09-13 02:08:55 +03:00
console . info (
2026-09-30 03:22:52 +03:00
`[extraction ${ id } ] merged: ${ stats . nodes } nodes, ${ stats . chains ?? 0 } chains ` +
2026-09-29 00:34:21 +03:00
`( ${ Math . round ( performance . now () - t0 ) } ms total, ${ chunks . length } chunk job(s))` ,
2026-09-13 02:08:55 +03:00
);
return stats ;
} catch ( err ) {
console . error ( `[extraction ${ id } ] notes pipeline failed ( ${ Math . round ( performance . now () - t0 ) } ms):` , err );
2026-10-23 04:43:56 +03:00
if ( err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError ) throw err ;
throw new NotesImportError ( err instanceof Error && err . name === "AbortError" ? "aborted" : "failed" , mergedStats ,
err instanceof Error ? err.message : String ( err ), { cause : err });
2026-09-13 02:08:55 +03:00
} finally {
2026-10-02 17:31:35 +03:00
finishExtraction ( id ); // done or failed — no longer running
2026-09-13 02:08:55 +03:00
}
}
2026-10-23 04:43:56 +03:00
/** Every explicit import processes the whole file. Identity, source digest,
* auditor and usage history remain source-scoped; this is not exactly-once. */
2026-09-13 02:08:55 +03:00
async function importNotes ( input : NotesImportInput ) : Promise < EmissionStats > {
const id = `src_ ${ crypto . createHash ( "sha256" ). update ( input . filePath ). digest ( "hex" ). slice ( 0 , 12 ) } ` ;
2026-10-23 04:43:56 +03:00
// Acquire BEFORE source upsert: overlapping contents must not alter metadata.
if ( inFlight . has ( id )) throw new NotesImportError ( "busy" , { nodes : 0 , chains : 0 }, "This file is already being imported. Try again when it finishes." );
inFlight . add ( id );
try {
const importedAt = new Date (). toISOString ();
graphStore . upsertSource ({ id , filePath : input.filePath , importedAt });
return await runNotesExtraction ({ id , ... input , importedAt });
} catch ( err ) {
if ( err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError ) throw err ;
throw new NotesImportError ( "failed" , { nodes : 0 , chains : 0 }, err instanceof Error ? err.message : String ( err ), { cause : err });
} finally {
inFlight . delete ( id );
2026-09-29 02:25:32 +03:00
}
2026-09-13 02:08:55 +03:00
}
return { runExtraction , importNotes };
2026-08-31 22:36:41 +03:00
}
2026-09-08 22:19:37 +03:00