extraction cleanup: events ledger dies, markdown source view, auditor-as-linker (2026-09-27 rulings)

The graph becomes raw reasoning chains only. Quiz outcomes and
understanding observations are no longer formalized; assessment
moves to the future practice module.

Events ledger removed end-to-end: LedgerEvent/LedgerEventKind/
UnderstandingLevel/ExtractionAttribution/AuditResult.dropped and
snapshot nodeObservations deleted from contracts; quizEvent builder
and the events step of mergeFindings gone; appendEvent/readEvents
dropped from the port and store; LEDGER_FILES loses events.jsonl
with a one-time purge of a stale file next to the nodes ledger;
graph-deletion and the delete dialog lose event purge + quiz-answer
impact counts; GraphSessionProposition becomes a pure birth list.

Whole-session markdown source view: new core/session-markdown.ts is
the shared formatting engine (id-anchored speaker blocks, quiz/
recall/question blocks, tool one-liners) — the extractor's prompt
view and the future user-facing export render from one engine.
Coverage fingerprints deliberately stay on the old line format
(content-hash semantics; re-formatting never re-opens covered
history). The whole session goes to the extractor — no phase cut.

Auditor refocused as the identity linker: never sees the source;
explores the graph via new auditor-specific tools in
agent/tools/graph.ts (find — glob over name/statement/aliases;
list_sessions; list_propositions with full statement text; no
timestamps) instead of an injected full-graph dump; equivalence
bias — paraphrases merge, only genuinely distinct propositions stay
apart; no drop channel, the extractor is fully trusted; may draw
bridging edges (typed follows/supports) between existing nodes and
the batch, resolved by the merger with chat-scoped message-less
provenance. Agentic loop, maxTurns 16.

UI: the node detail card gains an edges section above the sessions
list (follows-from / supported-by / what-follows / what-it-supports,
rendered only when present, click-to-select neighbors); the
understanding-note chips are gone.

Prober list_propositions reworded to what a session covered —
names only, no levels. docs/architecture.md + README updated to
the as-built pipeline. Gates: typecheck, 1059/1059, lint, build.
This commit is contained in:
amosblomqvist
2026-09-28 16:54:44 +03:00
parent cd09c12b56
commit 1b50013551
22 changed files with 909 additions and 1064 deletions
+66 -114
View File
@@ -1,18 +1,20 @@
/**
* The extraction pipeline tests (vision §1 construction, 2026-09-02 redesign):
* extractor → auditor → merger, the sole graph writer — tree-blind,
* free-form, with the auditor owning faithfulness and identity.
* The extraction pipeline tests (vision §1 construction, 2026-09-02 redesign;
* relinked 2026-09-27): extractor → auditor → merger, the sole graph writer —
* the extractor alone sees the source (whole-session markdown), the auditor
* never does (identity linking through graph tools), the merger is
* deterministic.
*
* transcriptView — the compact view both LLM stages see (pure):
* message-id-tagged verbatim text + quiz lines, NO tree
* transcriptView — the markdown rendering the extractor sees (pure):
* id-anchored Teacher/Learner blocks + check blocks
* pipeline — scripted faux engines drive both agents: findings out,
* audit verdict (with linkTo identity) in, deterministic
* merge applied; failure paths checkpoint nothing;
* single-flight per chat
*
* The merger itself (identity verdicts, event rebuild, idempotency) is
* tested in tests/graph.test.ts directly against mergeFindings — here it is
* the integration: what one full pipeline run does to the ledgers.
* The merger itself (identity verdicts, idempotency) is tested in
* tests/graph.test.ts directly against mergeFindings — here it is the
* integration: what one full pipeline run does to the ledgers.
*
* Ledger: in-memory (the GraphStore port) — this suite never touches
* data/graph. Chats: real ChatRecords in the real chat store (created and
@@ -33,7 +35,6 @@ import type {
ExtractionRunUsage,
GraphEdge,
GraphNode,
LedgerEvent,
NodeMapping,
NotesSource,
SessionCheckpoint,
@@ -42,7 +43,7 @@ import type {
import type { GraphStore } from "../src/core/ports.ts";
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
import { batchTag, coverageOf, coverageSlots, messageViewLines } from "../src/core/coverage.ts";
import { coverageOf, messageViewLines } from "../src/core/coverage.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
@@ -64,7 +65,6 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
function memoryLedgers() {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const events: LedgerEvent[] = [];
const mappings: NodeMapping[] = [];
const checkpoints: SessionCheckpoint[] = [];
const coverage: CoverageEntry[] = [];
@@ -75,8 +75,6 @@ function memoryLedgers() {
readNodes: () => nodes.map((n) => ({ ...n })),
appendEdge: (e: GraphEdge) => void edges.push(e),
readEdges: () => edges.map((e) => ({ ...e })),
appendEvent: (e: LedgerEvent) => void events.push(e),
readEvents: () => events.map((e) => ({ ...e })),
appendMapping: (m: NodeMapping) => void mappings.push(m),
readMappings: () => mappings.map((m) => ({ ...m })),
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
@@ -110,7 +108,6 @@ function memoryLedgers() {
rewriteLedgers: (next) => {
if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })));
if (next.edges) edges.splice(0, edges.length, ...next.edges.map((e) => ({ ...e, provenance: [...e.provenance] })));
if (next.events) events.splice(0, events.length, ...next.events.map((e) => ({ ...e })));
if (next.mappings) mappings.splice(0, mappings.length, ...next.mappings.map((m) => ({ ...m })));
if (next.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c })));
if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.map((c) => ({ ...c })));
@@ -224,7 +221,7 @@ after(() => {
});
/** What the extractor would produce for the fixture session — tree-blind,
* free-form, with message-granular provenance + an understanding note. */
* free-form, with message-granular provenance. */
const FINDINGS: SessionFindings = {
propositions: [
{
@@ -233,8 +230,7 @@ const FINDINGS: SessionFindings = {
statement: "The derivative measures how fast a function changes",
aliases: [],
messages: ["a2"],
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
understanding: "solid",
evidence: ["The derivative measures how fast a function changes."],
},
{
id: "p2",
@@ -242,7 +238,7 @@ const FINDINGS: SessionFindings = {
statement: "The chain rule differentiates composed functions by multiplying local rates.",
aliases: [],
messages: ["a2"],
evidence: ["LEARNER [u1]: teach me the chain rule"],
evidence: ["teach me the chain rule"],
},
],
edges: [
@@ -251,10 +247,9 @@ const FINDINGS: SessionFindings = {
to: "p2",
type: "follows",
messages: ["a2"],
evidence: ["LEARNER [u1]: teach me the chain rule"],
evidence: ["teach me the chain rule"],
},
],
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
};
/** An engine whose ONE response is the `name` tool call with the given
@@ -301,27 +296,30 @@ function silentModel(): PiEngine {
// ─── transcriptView (pure) ───────────────────────────────────────────────────
describe("transcriptView (pure)", () => {
it("shows message-id-tagged verbatim conversation + quiz lines — tree-blind, plan ops folded", () => {
describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27)", () => {
it("renders id-anchored speaker blocks + check blocks — tree-blind, plan ops folded", () => {
const { record } = fixtureChat();
const view = transcriptView(record);
assert.match(view, /SESSION: Extraction fixture/);
// The document header is the session title.
assert.match(view, /^# Extraction fixture$/m);
// Conversation verbatim, each line tagged with its message id —
// the provenance anchors the extractor reports back.
assert.match(view, /LEARNER \[u1\] teach me the chain rule/);
assert.match(view, /TEACHER \[a2\] The derivative measures how fast a function changes\./);
// Conversation verbatim, each block headed by its speaker + the
// message-id anchor — the provenance the extractor reports back.
assert.match(view, /### Learner · u1\n\nteach me the chain rule/);
assert.match(view, /### Teacher · a2\n\nThe derivative measures how fast a function changes\./);
// Structured events, attribution-ready.
assert.match(view, /QUIZ \[probe-1\] What is a derivative, roughly\? — the learner answered incorrectly/);
assert.match(view, /QUIZ \[check-1\] Check — the learner answered correctly/);
// Checks render as readable blocks (question + outcome).
assert.match(view, /### Quiz · r_probe-1/);
assert.match(view, /\*\*Q:\*\* What is a derivative, roughly\?/);
assert.match(view, /The learner answered incorrectly\./);
assert.match(view, /The learner answered correctly\./);
// TREE-BLIND: no plan tree, no tree edges, no mark_done lines — plan
// ops fold to the generic one-liner like every other non-quiz tool.
assert.doesNotMatch(view, /FINAL PLAN TREE/);
assert.doesNotMatch(view, /TREE EDGES/);
assert.match(view, /\(plan_init tool result delivered\)/);
assert.match(view, /\*plan_init result delivered\*/);
assert.doesNotMatch(view, /output-available/);
});
@@ -341,9 +339,9 @@ describe("transcriptView (pure)", () => {
]),
].flat());
const view = transcriptView(chatStore.getChat(chat.id)!);
assert.match(view, /\(research tool result delivered\)/);
assert.match(view, /\*research result delivered\*/);
assert.doesNotMatch(view, /long brief/);
assert.doesNotMatch(view, /TEACHER: {2}/);
assert.doesNotMatch(view, /### Teacher/);
});
});
@@ -355,7 +353,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const ledgers = memoryLedgers();
// The auditor passes p1 through and LINKS it to a pre-existing node
// (identity verdict), drops p2 (unbacked) + the edge with it.
// (identity verdict); p2 stays a verdict-free create.
const existingId = "n_existing";
ledgers.appendNode({
id: existingId,
@@ -370,8 +368,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
{ ...FINDINGS.propositions[0]!, linkTo: existingId },
],
edges: [],
attribution: { quiz: { "probe-1": "p1" } },
dropped: [{ ref: "p2", reason: "evidence not found" }, { ref: "p1→p2", reason: "endpoint dropped" }],
};
const pipeline = createExtractionPipeline({
@@ -382,38 +378,18 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
});
const stats = await pipeline.runExtraction(chatId);
// The linked proposition created NO new node; the understanding note
// became an observation; both quizzes became events.
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 3 });
// The linked proposition created NO new node.
assert.deepEqual(stats, { nodes: 0, edges: 0 });
// Link-append: the phrasing joined the existing node's aliases and
// the provenance unioned; no second node line defines a new node.
assert.equal(ledgers.readNodes().length, 2); // the pre-seeded line + the link line
assert.deepEqual(ledgers.readMappings(), [{ chatId, sourceId: "p1", graphNodeId: existingId }]);
// Events rebuilt deterministically: quizzes in transcript order, then
// the observation. check-1's attribution pointed at the DROPPED p2 →
// unresolvable → null (the question text is kept for later attribution).
// The observation ref is BATCH-TAGGED (obs:<tag>:p1) so a re-run of
// the same batch dedupes while a re-opened message's update lands as
// a new event (vision-extraction-coverage.md).
const events = ledgers.readEvents();
const record = chatStore.getChat(chatId)!;
const expectedTag = batchTag(coverageSlots(record.messages));
assert.deepEqual(
events.map((e) => [e.kind, e.ref, e.nodeId]),
[
["miss", "probe-1", existingId],
["correct", "check-1", null],
["observation", `obs:${expectedTag}:p1`, existingId],
],
);
assert.equal(events[0]!.nodeId, existingId, "probe attributed via the verdict's mapping");
assert.equal(events[2]!.note, "solid", "the understanding note joins the ledger");
// COVERAGE checkpointed per message (not a whole-chat checkpoint):
// one entry per transcript message, fingerprinted content each —
// the same turns are never extracted twice.
const record = chatStore.getChat(chatId)!;
const covered = ledgers.readCoverage();
// Wave D: the coverage unit is the VISIBLE native message — bonded
// riders with no view lines (a textless call message) are context,
@@ -498,12 +474,10 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
it("the LLM cluster-namer is DEAD (wave 7): a merge mints no naming runs — labels are session names now", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
// Identity-pass verdict: p1 becomes a new node, p2 dropped (no links).
// Identity-pass verdict: p1 becomes a new node, p2 unlinked.
const verdict: AuditResult = {
propositions: [{ ...FINDINGS.propositions[0]! }],
edges: [],
attribution: { quiz: { "probe-1": "p1" } },
dropped: [{ ref: "p2", reason: "evidence not found" }],
};
const pipeline = createExtractionPipeline({
chatStore,
@@ -512,7 +486,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
auditor: submitModel("submit_review", verdict),
});
const stats = await pipeline.runExtraction(chatId);
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 3 });
assert.deepEqual(stats, { nodes: 1, edges: 0 });
// Wave 7 (950ddfd): birth-session clusters carry session-name labels,
// so the post-merge naming garnish is gone — no model spend, no
// naming-run records, no label writes.
@@ -525,9 +499,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const ledgers = memoryLedgers();
const verdict: AuditResult = {
propositions: FINDINGS.propositions,
edges: [], // the edge is dropped with it
attribution: FINDINGS.attribution,
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
edges: [],
};
const consulted: string[] = [];
const pipeline = createExtractionPipeline({
@@ -558,8 +530,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const verdict: AuditResult = {
propositions: FINDINGS.propositions,
edges: [],
attribution: FINDINGS.attribution,
dropped: [{ ref: "p1→p2", reason: "endpoint dropped" }],
};
const pipeline = createExtractionPipeline({
chatStore,
@@ -593,7 +563,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: silentModel(),
auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }),
auditor: submitModel("submit_review", FINDINGS),
});
await assert.rejects(failingExtractor.runExtraction(chatId), /the extractor never submitted findings/);
assert.equal(ledgers.readCheckpoints().length, 0);
@@ -616,8 +586,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const verdict: AuditResult = {
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
edges: [],
attribution: { quiz: {} },
dropped: [{ ref: "p2", reason: "evidence not found" }],
};
const pipeline = createExtractionPipeline({
chatStore,
@@ -648,8 +616,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const verdict: AuditResult = {
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
edges: [],
attribution: { quiz: {} },
dropped: [{ ref: "p2", reason: "evidence not found" }],
};
const pipeline = createExtractionPipeline({
chatStore,
@@ -675,7 +641,6 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
assert.equal(record.extractions?.length, 1);
const run = record.extractions![0]!;
assert.equal(run.stats?.nodes, 0); // linked, not created
assert.equal(run.stats?.events, 3);
// the scripted engine's model, provider-qualified (model slots wave)
assert.equal(run.stages.extractor.model, "faux:faux-1");
assert.equal(run.stages.extractor.steps.length, 1); // the submit call — the run STOPS after a clean submit
@@ -696,7 +661,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
chatStore,
graphStore: ledgers,
extractor: hangingModel(), // hangs until aborted — no step finishes
auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }),
auditor: submitModel("submit_review", { propositions: [], edges: [] }),
});
const runPromise = pipeline.runExtraction(chatId);
@@ -706,7 +671,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const stats = await runPromise;
// A deliberate stop, not a failure: zero emissions, no throw.
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 });
assert.deepEqual(stats, { nodes: 0, edges: 0 });
assert.equal(extractionStatuses().length, 0, "the registry clears");
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed — the next trigger re-runs");
@@ -751,7 +716,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const first = pipeline.runExtraction(chatId);
const second = await pipeline.runExtraction(chatId); // in-flight → zeros
assert.deepEqual(second, { nodes: 0, edges: 0, events: 0 });
assert.deepEqual(second, { nodes: 0, edges: 0 });
release();
await assert.rejects(first, /the extractor never submitted findings/);
@@ -800,7 +765,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
);
const g = globalThis as { __lhExtractionRuns?: { running: Map<string, unknown> } };
assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs");
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0, events: 0 }, "single-flight spans pipeline instances");
assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0 }, "single-flight spans pipeline instances");
release();
await assert.rejects(first, /the extractor never submitted findings/);
@@ -829,8 +794,8 @@ const NOTES_TEXT = `> [!quote] YOU
> [!abstract] PI
> Vim always starts in Normal mode — keys are commands, not text.`;
/** What the notes-mode extractor submits: same shapes minus attribution,
* and messages always empty (the file is the provenance). */
/** What the notes-mode extractor submits: same shapes as session findings,
* messages always empty (the file is the provenance). */
const NOTES_FINDINGS = {
propositions: [
{
@@ -840,7 +805,6 @@ const NOTES_FINDINGS = {
aliases: [],
messages: [],
evidence: ["Vim always starts in Normal mode — keys are commands, not text."],
understanding: "solid",
},
],
edges: [],
@@ -849,11 +813,10 @@ const NOTES_FINDINGS = {
const NOTES_VERDICT = {
propositions: [NOTES_FINDINGS.propositions[0]],
edges: [],
dropped: [],
} as const;
describe("the notes front door (importNotes — format-agnostic)", () => {
it("imports raw notes end to end: source record, file-scoped provenance, no quiz events", async () => {
it("imports raw notes end to end: source record, file-scoped provenance", async () => {
const ledgers = memoryLedgers();
const seen: { kind?: string; label?: string }[] = [];
const pipeline = createExtractionPipeline({
@@ -868,7 +831,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
});
const stats = await pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
assert.deepEqual(stats, { nodes: 1, edges: 0 });
// The run surfaced as a NOTES run labeled with the file path.
assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]);
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
@@ -888,14 +851,6 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
assert.equal(node.statement, "Vim starts in Normal mode, where keys are commands.");
assert.deepEqual(node.provenance, []);
assert.deepEqual(ledgers.readMappings(), [{ chatId: source.id, sourceId: "p1", graphNodeId: node.id }]);
// One observation event, zero quiz events — outcomes inside notes
// surface only through understanding observations (user-decided).
const events = ledgers.readEvents();
assert.deepEqual(
events.map((e) => [e.kind, e.chatId, e.ref, e.note]),
[["observation", source.id, "obs:p1", "solid"]],
);
// The checkpoint stamps the import.
assert.equal(ledgers.readCheckpoints()[0]!.chatId, source.id);
});
@@ -910,10 +865,10 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
auditor: submitModel("submit_review", NOTES_VERDICT),
});
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
assert.deepEqual(first, { nodes: 1, edges: 0, events: 1 });
assert.deepEqual(first, { nodes: 1, edges: 0 });
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
assert.deepEqual(second, { nodes: 0, edges: 0, events: 0 }, "the same source id dedupes everything");
assert.deepEqual(second, { nodes: 0, edges: 0 }, "the same source id dedupes everything");
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "both runs billed their usage");
@@ -932,7 +887,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
assert.equal(abortExtraction(srcId), true);
assert.deepEqual(await runPromise, { nodes: 0, edges: 0, events: 0 });
assert.deepEqual(await runPromise, { nodes: 0, edges: 0 });
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
@@ -943,8 +898,8 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
// ── the chunked pipeline (extraction coverage — vision-extraction-coverage.md) ──
/** Pull the pipeline's prompt view out of a model call: the pipeline sends
* `Transcript:\n\n<view>` as the user turn, so the LAST user message's
* text IS what the extractor saw. */
* the session's markdown document itself as the user turn, so the LAST
* user message's text IS what the extractor saw. */
function capturedView(context: unknown): string {
const messages = (context as { messages?: { role: string; content: string }[] }).messages ?? [];
const last = [...messages].reverse().find((m) => m.role === "user");
@@ -972,8 +927,6 @@ function passThroughAuditStep(): FauxResponseStep {
{
propositions: [{ ...FINDINGS.propositions[0]! }],
edges: [],
attribution: { quiz: {} },
dropped: [{ ref: "p2", reason: "evidence not found" }],
} as unknown as ToolCall["arguments"],
{ id: "review-call" },
),
@@ -1014,15 +967,16 @@ describe("the chunked pipeline (extraction coverage)", () => {
// Two batches: the first held messages 1-3, the second 4-5 — a
// message is never split, batches are consecutive oldest-first.
assert.equal(views.length, 2, "one extractor call per batch");
assert.ok(views[0]!.includes("LEARNER [u1]") && views[0]!.includes("LEARNER [u3]"));
assert.ok(!views[0]!.includes("LEARNER [u4]"), "the batch stops at the token budget");
assert.ok(views[1]!.includes("LEARNER [u4]") && views[1]!.includes("LEARNER [u5]"));
assert.ok(!views[1]!.includes("LEARNER [u3]"));
assert.ok(views[0]!.includes("### Learner · u1") && views[0]!.includes("### Learner · u3"));
assert.ok(!views[0]!.includes("### Learner · u4"), "the batch stops at the token budget");
assert.ok(views[1]!.includes("### Learner · u4") && views[1]!.includes("### Learner · u5"));
assert.ok(!views[1]!.includes("### Learner · u3"));
// The document header is the session title.
assert.ok(views[0]!.startsWith("# Chunky coverage fixture"));
// p1 was a fresh node in batch 1; batch 2's p1 mapped to the same
// node (chat-scoped mapping) — but its understanding note is a NEW
// batch-tagged observation.
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 2 });
// node (chat-scoped mapping).
assert.deepEqual(stats, { nodes: 1, edges: 0 });
// Per-batch coverage: every message checkpointed, nothing legacy.
const coverage = ledgers.readCoverage();
@@ -1034,7 +988,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
// new usage entry (no money spent on covered ground).
const before = views.length;
const again = await pipeline.runExtraction(chatId);
assert.deepEqual(again, { nodes: 0, edges: 0, events: 0 });
assert.deepEqual(again, { nodes: 0, edges: 0 });
assert.equal(views.length, before);
assert.equal(chatStore.getChat(chatId)!.extractions?.length, 1, "the no-op run persists no usage");
});
@@ -1079,7 +1033,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
const stats = await runPromise;
// Batch 1's emissions are kept and its messages stay covered.
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
assert.deepEqual(stats, { nodes: 1, edges: 0 });
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
@@ -1095,8 +1049,8 @@ describe("the chunked pipeline (extraction coverage)", () => {
});
await finisher.runExtraction(chatId);
assert.equal(reRunViews.length, 1);
assert.ok(reRunViews[0]!.includes("LEARNER [u4]") && reRunViews[0]!.includes("LEARNER [u5]"));
assert.ok(!reRunViews[0]!.includes("LEARNER [u1]"), "the covered remainder never re-enters the prompt");
assert.ok(reRunViews[0]!.includes("### Learner · u4") && reRunViews[0]!.includes("### Learner · u5"));
assert.ok(!reRunViews[0]!.includes("### Learner · u1"), "the covered remainder never re-enters the prompt");
assert.equal(ledgers.readCoverage().length, 5, "the re-run closed the gap");
});
@@ -1152,7 +1106,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
});
await reOpener.runExtraction(chatId);
assert.equal(reRunViews.length, 1, "one batch: the single re-opened message");
assert.ok(reRunViews[0]!.includes("LEARNER [s1]") && !reRunViews[0]!.includes("LEARNER [s2]"));
assert.ok(reRunViews[0]!.includes("### Learner · s1") && !reRunViews[0]!.includes("### Learner · s2"));
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0, "re-covered");
});
});
@@ -1170,7 +1124,7 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
chatStore,
graphStore: ledgers,
extractor: submitModel("submit_findings", FINDINGS),
auditor: submitModel("submit_review", { propositions: FINDINGS.propositions, edges: [], attribution: { quiz: {} }, dropped: [] }, () => chatStore.deleteChat(chatId)),
auditor: submitModel("submit_review", { propositions: FINDINGS.propositions, edges: [] }, () => chatStore.deleteChat(chatId)),
});
const stats = await pipeline.runExtraction(chatId);
@@ -1179,13 +1133,11 @@ describe("the session-deletion guard (chat deleted mid-run)", () => {
assert.ok(stats);
assert.equal(stats.nodes, 0);
assert.equal(stats.edges, 0);
assert.equal(stats.events, 0);
// Nothing merged, nothing checkpointed — the deleted chat leaves no
// trace for a later snapshot to resurrect.
assert.equal(ledgers.readNodes().length, 0);
assert.equal(ledgers.readEdges().length, 0);
assert.equal(ledgers.readEvents().length, 0);
assert.equal(ledgers.readMappings().length, 0);
assert.equal(ledgers.readCoverage().length, 0);
assert.equal(ledgers.readCheckpoints().length, 0);
+76 -41
View File
@@ -1,19 +1,21 @@
// THE PROBER'S GRAPH TOOLS (wave 7) — pinned behaviors:
// THE PROBER'S GRAPH TOOLS (wave 7; understanding levels removed
// 2026-09-27) — pinned behaviors:
// 1. REGISTRATION: the factory registers list_sessions/list_propositions
// on the prober ONLY when the composition root passes a graphStore
// (the graphToolsEnabled gate lives server-side; absent store = closed
// gate — the factory must not reach for settings itself).
// 2. OUTPUT: names + levels + dating only — the owner ruling bars
// statement text and session contents from the prober's view of the map.
// 2. OUTPUT: names only — the owner ruling bars statement text and session
// contents from the prober's view of the map.
// 3. HONESTY: empty graph, unknown session id — the tools answer in
// their own voice, never throw, never guess.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createProberTools } from "../src/agent/tools.ts";
import { auditorGraphTools } from "../src/agent/tools/graph.ts";
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
import type { ChatSummary } from "../src/core/contracts/chat.ts";
import type { GraphEdge, GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts";
import type { GraphEdge, GraphNode } from "../src/core/contracts/graph.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
@@ -31,27 +33,18 @@ const node = (
provenance,
});
const observation = (
nodeId: string,
chatId: string,
note: string,
at: string,
): LedgerEvent =>
({ kind: "observation", nodeId, chatId, note, at }) as unknown as LedgerEvent;
function fakeStores(
nodes: GraphNode[],
over: {
events?: LedgerEvent[];
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
} = {},
): { chatStore: ChatStore; graphStore: GraphStore } {
const edges: GraphEdge[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readEvents: () => over.events ?? [],
readMappings: () => [],
readMappings: () => over.mappings ?? [],
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () => [],
@@ -134,7 +127,6 @@ describe("list_sessions", () => {
updatedAt: "2026-09-21T10:00:00.000Z",
},
],
events: [],
},
);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
@@ -157,38 +149,28 @@ describe("list_sessions", () => {
});
describe("list_propositions", () => {
it("names + latest understanding + that evaluation's date — the latest event wins", async () => {
const { chatStore, graphStore } = fakeStores(
[node("n1", "Water cycle", [pt("c1")]), node("n2", "Evaporation", [pt("c1")])],
{
events: [
// The birth session's OWN evaluations only — a later
// session's observation must not leak in here.
observation("n1", "c1", "shaky", "2026-09-20T12:00:00.000Z"),
observation("n1", "c1", "solid", "2026-09-22T12:00:00.000Z"),
observation("n1", "c2", "resisted", "2026-09-23T12:00:00.000Z"),
observation("n2", "c1", "not yet engaged", "2026-09-21T12:00:00.000Z"),
],
},
);
it("names only — what the session covered (2026-09-27: no levels, no dating)", async () => {
const { chatStore, graphStore } = fakeStores([
node("n1", "Water cycle", [pt("c1")]),
node("n2", "Evaporation", [pt("c1")]),
]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
assert.match(out, /2 proposition\(s\) born in this session:/);
assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/);
assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/);
// Never the other session's word, never the statement text.
assert.ok(!out.includes("resisted"));
assert.match(out, /2 proposition\(s\) covered in this session:/);
assert.match(out, /- Water cycle/);
assert.match(out, /- Evaporation/);
// The owner ruling: no levels, no dating, never the statement text.
assert.ok(!out.includes("as of"));
assert.ok(!out.includes("solid"));
assert.ok(!out.includes("statement of"));
});
it("a proposition with no observation reads 'never evaluated', undated", async () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])], {
events: [],
});
it("no evaluation machinery at all — the covered list is the whole output", async () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
assert.match(out, /- Water cycle — never evaluated/);
assert.ok(!out.includes("(as of"));
assert.match(out, /- Water cycle/);
assert.ok(!out.includes("never evaluated"));
});
it("an unknown session id — honest redirect, not a throw", async () => {
@@ -198,3 +180,56 @@ describe("list_propositions", () => {
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
});
});
// ─── the auditor's toolset (2026-09-27 relink) ───────────────────────────────
describe("auditor graph tools", () => {
const nodes = [
{ ...node("n1", "Will to power", [pt("c1")]), statement: "The will to power is Nietzsche's central drive.", aliases: ["power drive"] },
{ ...node("n2", "Eternal recurrence", [pt("c1")]), statement: "Eternal recurrence is the thought experiment of living again.", aliases: [] },
];
const { chatStore, graphStore } = fakeStores(nodes);
it("find: glob over names + statements + aliases, returning id + name + statement", async () => {
const tools = auditorGraphTools({ chatStore, graphStore });
const out = (await tools.find!.execute!("tc", { query: "*will to power*" })).details as string;
assert.match(out, /1 match\(es\):/);
assert.match(out, /- n1 :: Will to power/);
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
// Statements and aliases hit too — and a bare term gets implicit stars.
const byStatement = (await tools.find!.execute!("tc", { query: "living again" })).details as string;
assert.match(byStatement, /n2 :: Eternal recurrence/);
const byAlias = (await tools.find!.execute!("tc", { query: "power drive" })).details as string;
assert.match(byAlias, /n1/);
const miss = (await tools.find!.execute!("tc", { query: "*nothing matches this*" })).details as string;
assert.equal(miss, 'No existing proposition matches "*nothing matches this*".');
});
it("list_sessions: names, kinds, counts, ids — NO timestamps (identity work needs content, not chronology)", async () => {
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
chats: [{ id: "c1", title: "Nietzsche seminar", createdAt: "2026-09-20T10:00:00.000Z", updatedAt: "2026-09-21T10:00:00.000Z" }],
});
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
assert.match(out, /1 session\(s\):/);
assert.match(out, /Nietzsche seminar \(chat, 2 propositions\) — id: c1/);
assert.ok(!out.includes("2026-09-20"), "no dates in the auditor's view");
});
it("list_propositions: FULL statement text (identity work), no dates", async () => {
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
mappings: [
{ chatId: "c1", sourceId: "p1", graphNodeId: "n1" },
{ chatId: "c1", sourceId: "p2", graphNodeId: "n2" },
],
});
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
assert.match(out, /2 proposition\(s\) born in this session:/);
assert.match(out, /- n1 :: Will to power/);
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
assert.ok(!out.includes("2026-09"), "no dates in the auditor's view");
const miss = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
assert.equal(miss, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
});
});
+43 -132
View File
@@ -1,9 +1,11 @@
/**
* Graph merger + trigger + derivation tests (vision §1, 2026-09-02 redesign).
* Graph merger + trigger + derivation tests (vision §1, 2026-09-02 redesign;
* events cleanup 2026-09-27).
* The merger (core/graph.ts mergeFindings) is the pipeline's deterministic
* apply stage: AUDITED VERDICTS in, ledger appends out — link-or-create
* identity (the auditor's linkTo, never string matching), deterministic quiz
* events, understanding observations, and the idempotency guarantees.
* identity (the auditor's linkTo, never string matching) and the
* idempotency guarantees. No events ledger: quiz outcomes and
* understanding observations are no longer formalized in the graph.
*
* Ledger isolation: PATHS is fixed at import time, so the five data/graph
* ledgers are backed up and cleared before the suite and restored after
@@ -30,15 +32,12 @@ import { LEDGER_FILES, PATHS, SOURCES_FILE } from "../config.ts";
/** The wired fs adapters, exactly what src/server/stores.ts passes in production. */
const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
/** The session merge source for a chat — what the pipeline passes: the live
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
* messages: null — see the notes-merger test below.) */
function sessionSource(chatId: string): { messages: LhMessage[]; foldedAt: string } {
// Wave B: the record is native history — the merger's transcript view
// reads through the one-way projection (same shape the extraction
// pipeline passes: its batch slots are projected messages).
/** The session merge source for a chat — what the pipeline passes: the
* fold stamp only (the merger reads no transcript; quiz-event rebuilding
* died with the events ledger, 2026-09-27). */
function sessionSource(chatId: string): { foldedAt: string } {
const record = chatStore.getChat(chatId)!;
return { messages: record.messages, foldedAt: record.updatedAt };
return { foldedAt: record.updatedAt };
}
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
@@ -215,9 +214,8 @@ function fixtureMessages(): LhMessage[] {
].flat();
}
/** The auditor's verdict for the fixture chat: three verified propositions
* (no linkTo — the graph starts empty), one follows edge, all quizzes
* attributed, one understanding note. */
/** The auditor's verdict for the fixture chat: three propositions
* (no linkTo — the graph starts empty), one follows edge. */
function fixtureAudit(): AuditResult {
return {
propositions: [
@@ -228,7 +226,6 @@ function fixtureAudit(): AuditResult {
aliases: ["alpha feel"],
messages: ["a1", "a3"],
evidence: ["fixture evidence"],
understanding: "solid",
},
{
id: "p2",
@@ -256,8 +253,6 @@ function fixtureAudit(): AuditResult {
evidence: ["fixture evidence"],
},
],
attribution: { quiz: { "call-1": "p1", "call-2": null, "call-3": "p1", "call-4": "p2", "call-5": "p3" } },
dropped: [],
};
}
@@ -283,14 +278,13 @@ function scopedGraph(chatIds: string[]) {
return {
nodes: graph.nodes.filter((n) => nodeIds.has(n.id)),
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
events: graph.events.filter((e) => chatIds.includes(e.chatId)),
};
}
describe("the merger (mergeFindings)", () => {
it("creates nodes from the verdicts, rebuilds quiz events deterministically, records observations", () => {
it("creates nodes from the verdicts with message-granular provenance", () => {
const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id));
assert.deepEqual(stats, { nodes: 3, edges: 1, events: 6 });
assert.deepEqual(stats, { nodes: 3, edges: 1 });
const graph = scopedGraph([chat1.id]);
assert.equal(graph.nodes.length, 3);
@@ -321,49 +315,14 @@ describe("the merger (mergeFindings)", () => {
graphStore.readMappings().map((m) => m.sourceId),
["p1", "p2", "p3"],
);
// Events — kinds, refs, notes, and the attribution paths.
assert.equal(graph.events.length, 6);
const byRef = new Map(graph.events.map((e) => [e.ref, e]));
const miss = byRef.get("call-1")!;
assert.equal(miss.kind, "miss");
assert.equal(miss.nodeId, idByStatement.get("Alpha intuition"), "attributed quiz");
const dontKnow = byRef.get("call-2")!;
assert.equal(dontKnow.kind, "dont-know");
assert.equal(dontKnow.nodeId, null, "unattributable → null");
assert.equal(dontKnow.question, "Q2 text");
const correct = byRef.get("call-3")!;
assert.equal(correct.kind, "correct");
assert.equal(correct.nodeId, idByStatement.get("Alpha intuition"), "attribution wins over the ignored input.nodeId");
assert.equal(correct.note, "recalled the diagram");
const miss2 = byRef.get("call-4")!;
assert.equal(miss2.kind, "miss");
assert.equal(miss2.nodeId, idByStatement.get("Beta rule"));
const dontKnow2 = byRef.get("call-5")!;
assert.equal(dontKnow2.kind, "dont-know");
assert.equal(dontKnow2.nodeId, idByStatement.get("Gamma application"));
const observation = byRef.get("obs:p1")!;
assert.equal(observation.kind, "observation");
assert.equal(observation.nodeId, idByStatement.get("Alpha intuition"));
assert.equal(observation.note, "solid");
// The dismissed quiz and the ask question became nothing.
assert.equal([...byRef.keys()].filter((r) => r === "call-6" || r === "call-7").length, 0);
});
it("is idempotent — re-merging the same verdicts appends nothing", () => {
const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id));
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 });
assert.deepEqual(stats, { nodes: 0, edges: 0 });
const graph = scopedGraph([chat1.id]);
assert.equal(graph.nodes.length, 3);
assert.equal(graph.edges.length, 1);
assert.equal(graph.events.length, 6);
// exactly one checkpoint line per merge, though — checkpoints are a log
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === chat1.id).length, 2);
});
@@ -398,13 +357,11 @@ describe("the merger (mergeFindings)", () => {
},
],
edges: [],
attribution: { quiz: { "call-9": "q1" } },
dropped: [],
},
graphDeps,
sessionSource(chat2.id),
);
assert.deepEqual(stats, { nodes: 0, edges: 0, events: 1 });
assert.deepEqual(stats, { nodes: 0, edges: 0 });
const after = scopedGraph([chat1.id, chat2.id]);
assert.equal(after.nodes.length, 3, "no duplicate node");
@@ -436,23 +393,18 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
messages: [],
evidence: ["fixture evidence"],
understanding: "solid",
},
],
edges: [],
attribution: { quiz: {} },
dropped: [],
},
graphDeps,
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
{ foldedAt: "2026-09-13T00:00:00.000Z" },
);
const snap = graphSnapshot(graphDeps);
const node = snap.nodes.find((n) => n.statement === "Vim registers map single keys to editing commands.")!;
assert.equal(snap.fileSources["src_snapfixture"], "/vault/learn vim.md");
assert.deepEqual(snap.nodeSessions[node.id], ["src_snapfixture"], "the file entry joins the node's sessions list");
assert.equal(snap.sessions["src_snapfixture"], undefined, "a notes source is never a chat session");
// The understanding observation joins under the source id like any session's.
assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid");
});
it("derives BIRTH-SESSION clusters in graphSnapshot, labeled with the session's name", () => {
@@ -478,11 +430,9 @@ describe("the merger (mergeFindings)", () => {
{ from: "p2", to: "p3", type: "follows", messages: [], evidence: ["e"] },
{ from: "p1", to: "p3", type: "follows", messages: [], evidence: ["e"] },
],
attribution: { quiz: {} },
dropped: [],
},
graphDeps,
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
{ foldedAt: "2026-09-13T00:00:00.000Z" },
);
assert.equal(merge.nodes, 3);
const snap = graphSnapshot(graphDeps);
@@ -496,12 +446,14 @@ describe("the merger (mergeFindings)", () => {
assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path");
});
it("derives sessions and per-session understanding observations in graphSnapshot", () => {
it("derives the sessions index in graphSnapshot", () => {
const snap = graphSnapshot(graphDeps);
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
// No node-state classes anymore (2026-09-08): the snapshot carries
// only the objective graph + sessions; quiz outcomes stay ledger data.
// No node-state classes (2026-09-08) and no understanding
// observations (2026-09-27 cleanup): the snapshot carries only the
// objective graph + sessions.
assert.equal("nodeStates" in snap, false);
assert.equal("nodeObservations" in snap, false);
assert.equal(snap.sessions[chat1.id], "Graph fixture");
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
@@ -511,12 +463,6 @@ describe("the merger (mergeFindings)", () => {
);
// Gamma was only ever touched by chat1.
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]);
// Understanding observations: (node → chat → the extractor's note).
// Alpha carries chat1's "solid" level; chat2's q1 has no level.
const alphaObs = snap.nodeObservations[idByStatement.get("Alpha intuition")!] ?? {};
assert.equal(alphaObs[chat1.id], "solid");
assert.equal(alphaObs[chat2Id], undefined);
});
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
@@ -537,8 +483,6 @@ describe("the merger (mergeFindings)", () => {
},
],
edges: [],
attribution: { quiz: {} },
dropped: [],
},
graphDeps,
sessionSource(chat3.id),
@@ -547,10 +491,9 @@ describe("the merger (mergeFindings)", () => {
assert.equal(getGraph(graphStore).nodes.filter((n) => n.statement === "Alpha intuition").length, 2);
});
it("merges the notes front door: source id as chatId, no quiz events, checkpoint at import time", () => {
// Raw personal notes carry no transcript — the merger gets no
// message parts, so NO quiz events are rebuilt; the understanding
// observation still lands, keyed to the source id.
it("merges the notes front door: source id as chatId, checkpoint at import time", () => {
// Raw personal notes carry no transcript — no message anchors; the
// file is the provenance (and no events exist to build, 2026-09-27).
const stats = mergeFindings(
"src_notesfixture",
{
@@ -562,17 +505,14 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
messages: [], // notes carry no message anchors — the file is the provenance
evidence: ["fixture evidence"],
understanding: "solid",
},
],
edges: [],
attribution: { quiz: {} }, // normalized by the notes pipeline — nothing to attribute
dropped: [],
},
graphDeps,
{ messages: null, foldedAt: "2026-09-13T00:00:00.000Z" },
{ foldedAt: "2026-09-13T00:00:00.000Z" },
);
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
assert.deepEqual(stats, { nodes: 1, edges: 0 });
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
assert.deepEqual(node.provenance, [], "no message anchors — the mapping line carries the source");
@@ -581,12 +521,6 @@ describe("the merger (mergeFindings)", () => {
sourceId: "p1",
graphNodeId: node.id,
});
// The observation event is file-scoped; no quiz events exist.
const events = graphStore.readEvents().filter((e) => e.chatId === "src_notesfixture");
assert.deepEqual(
events.map((e) => [e.kind, e.ref, e.note]),
[["observation", "obs:p1", "solid"]],
);
// The checkpoint stamps the import time.
assert.equal(graphStore.readCheckpoints().find((c) => c.chatId === "src_notesfixture")?.foldedAt, "2026-09-13T00:00:00.000Z");
});
@@ -673,12 +607,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
].flat());
const auditA: AuditResult = {
propositions: [
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"] },
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], messages: ["du1"], evidence: ["e"] },
],
edges: [{ from: "p1", to: "p2", type: "follows", messages: ["du1"], evidence: ["e"] }],
attribution: { quiz: { "d-call-1": "p1" } },
dropped: [],
};
mergeFindings(dA.id, auditA, graphDeps, sessionSource(dA.id));
// Resolve through THIS session's mappings — the shared ledger holds
@@ -701,12 +633,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
].flat());
const auditB: AuditResult = {
propositions: [
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId },
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], messages: ["eu1"], evidence: ["e"] },
],
edges: [{ from: "q1", to: "q2", type: "follows", messages: ["eu1"], evidence: ["e"] }],
attribution: { quiz: { "e-call-1": "q1" } },
dropped: [],
};
mergeFindings(dB.id, auditB, graphDeps, sessionSource(dB.id));
const zId = graphStore.readMappings().find((m) => m.chatId === dB.id && m.sourceId === "q2")!.graphNodeId;
@@ -730,7 +660,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.deepEqual(stats, {
nodesRemoved: 1,
edgesRemoved: 1,
eventsRemoved: 2,
mappingsRemoved: 2,
coverageRemoved: 2,
checkpointsRemoved: 1,
@@ -753,11 +682,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
// Edges: dA's assertion X→Y died; dB's X→Z survived.
const scoped = scopedGraph([dA.id, dB.id]);
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "eu1" }] }]);
// Events: only dB's quiz + observation remain.
assert.deepEqual(scoped.events.map((e) => [e.kind, e.chatId === dB.id]), [
["miss", true],
["observation", true],
]);
// Mappings: dA's audit trail died; dB's stays.
const mappings = graphStore.readMappings().filter((m) => [dA.id, dB.id].includes(m.chatId));
assert.deepEqual(mappings, [{ chatId: dB.id, sourceId: "q1", graphNodeId: xId }, { chatId: dB.id, sourceId: "q2", graphNodeId: zId }]);
@@ -768,15 +692,16 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
// The snapshot resolves X to dB only — the deleted chat left no ghost.
const snapshot = graphSnapshot(graphDeps);
assert.deepEqual(snapshot.nodeSessions[xId], [dB.id]);
assert.deepEqual(snapshot.nodeObservations[xId], { [dB.id]: "shaky" });
assert.ok(snapshot.sessions[dB.id] && !snapshot.sessions[dA.id]);
// Atomic rewrites left no temp files, and appends still work.
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
const eventsBefore = graphStore.readEvents().length;
graphStore.appendEvent({ nodeId: null, kind: "correct", chatId: "chat_del_append_probe", at: "2026-09-23T00:04:00.000Z" });
assert.equal(graphStore.readEvents().length, eventsBefore + 1);
const nodesBefore = graphStore.readNodes().length;
// Foreign provenance: the probe is another chat's data, so later
// deletions must leave it untouched (the foreign-data doctrine).
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
// Idempotent: a second run finds nothing.
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, mappingsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
});
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
@@ -784,7 +709,6 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
const before = {
nodes: graphStore.readNodes(),
edges: graphStore.readEdges(),
events: graphStore.readEvents(),
mappings: graphStore.readMappings(),
positions: graphStore.readPositions(),
};
@@ -792,14 +716,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.deepEqual(stats, {
nodesRemoved: 0,
edgesRemoved: 0,
eventsRemoved: 0,
mappingsRemoved: 0,
coverageRemoved: 2,
checkpointsRemoved: 1,
} satisfies ChatDeletionStats);
assert.deepEqual(graphStore.readNodes(), before.nodes);
assert.deepEqual(graphStore.readEdges(), before.edges);
assert.deepEqual(graphStore.readEvents(), before.events);
assert.deepEqual(graphStore.readMappings(), before.mappings);
assert.deepEqual(graphStore.readPositions(), before.positions);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
@@ -853,7 +775,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
// Clean slate per test: each test in this describe builds its own
// scenario from empty ledgers (the suite's isolation covers the
// whole file; this covers describe-internal stacking).
graphStore.rewriteLedgers({ nodes: [], edges: [], events: [], mappings: [] });
graphStore.rewriteLedgers({ nodes: [], edges: [], mappings: [] });
graphStore.writePositions({});
graphStore.appendNode({
id: "n_dead",
@@ -884,10 +806,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// Events: two sessions' evidence on X, one on the neighbor.
graphStore.appendEvent({ nodeId: "n_dead", kind: "correct", chatId: "chat_node_a", at: "2026-09-23T00:01:00.000Z", ref: "q1" });
graphStore.appendEvent({ nodeId: "n_dead", kind: "observation", chatId: "chat_node_b", at: "2026-09-23T00:02:00.000Z", note: "shaky", ref: "obs:p1" });
graphStore.appendEvent({ nodeId: "n_keep", kind: "miss", chatId: "chat_node_a", at: "2026-09-23T00:03:00.000Z", ref: "q2" });
// Mappings: the notes anchor + the chat's audit trail.
graphStore.appendMapping({ chatId: "src_nodefile", sourceId: "p1", graphNodeId: "n_dead" });
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p1", graphNodeId: "n_dead" });
@@ -900,11 +818,9 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
buildNodeScenario();
assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), {
edges: 2,
quizEvents: 1,
observations: 1,
// The file anchor, the re-encounter, and the second session's
// observation — every referencing session, deduped and sorted.
sessions: ["chat_node_a", "chat_node_b", "src_nodefile"],
// The file anchor + the re-encounter — every referencing
// session, deduped and sorted.
sessions: ["chat_node_a", "src_nodefile"],
});
// Unknown node: null (the route's 404).
assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null);
@@ -914,7 +830,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
buildNodeScenario();
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
const stats = deleteNodeTraces(graphStore, "n_dead");
assert.deepEqual(stats, { edgesRemoved: 2, eventsRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { edgesRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats);
// The node's EVERY line is gone; the neighbor is byte-identical
// (full death is node-scoped — other nodes' sessions keep their
@@ -930,11 +846,6 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
graphStore.readEdges().map((e) => [e.from, e.to]),
[["n_keep", "n_other"]],
);
// The evidence died with the thing it was about; the neighbor's stays.
assert.deepEqual(
graphStore.readEvents().map((e) => [e.nodeId, e.kind]),
[["n_keep", "miss"]],
);
// The audit trail: X's mappings gone, the neighbor's stay.
assert.deepEqual(graphStore.readMappings().map((m) => m.graphNodeId), ["n_keep"]);
// The resting position died with the node.
@@ -957,7 +868,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
});
const stats = deleteNodeTraces(graphStore, "n_solo");
assert.deepEqual(stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { edgesRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
});
@@ -1007,7 +918,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
const revisionBefore = graphRevision();
const outcome = deleteGraphNode("n_orch");
assert.ok(outcome.ok);
assert.deepEqual(outcome.stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 });
assert.deepEqual(outcome.stats, { edgesRemoved: 0, mappingsRemoved: 0 });
assert.ok(outcome.graphRevision > 0);
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
+8 -32
View File
@@ -11,7 +11,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { GraphEdge, GraphNode, LedgerEvent, NotesSource, ChatSummary } from "../src/core/contracts.ts";
import type { GraphEdge, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts";
import type { ChatStore, GraphStore } from "../src/core/ports.ts";
import {
graphSessionIndex,
@@ -36,7 +36,6 @@ const node = (id: string, name: string, provenance: Array<{ chatId: string; mess
function fakeStores(
nodes: GraphNode[],
over: {
events?: LedgerEvent[];
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
mappings?: Array<{ chatId: string; sourceId: string; graphNodeId: string }>;
@@ -46,7 +45,6 @@ function fakeStores(
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readEvents: () => over.events ?? [],
readMappings: () => over.mappings ?? [],
readCheckpoints: () => [],
readCoverage: () => [],
@@ -160,12 +158,8 @@ describe("the notes mapping-line fallback", () => {
], "the file births under its src id; the childless chat has no entry (birth sessions only)");
});
it("graphSessionPropositions lists the file's births under the src id", () => {
const events: LedgerEvent[] = [
{ nodeId: "a", kind: "observation", chatId: "src_abc", at: "2026-09-22T00:00:00.000Z", note: "solid", ref: "obs:p1" },
];
it("graphSessionPropositions lists the file's births under the src id (a pure birth list, 2026-09-27)", () => {
const deps = fakeStores([node("a", "Alpha", []), node("b", "Beta", [])], {
events,
mappings: [
{ chatId: "src_abc", sourceId: "p1", graphNodeId: "a" },
{ chatId: "src_abc", sourceId: "p2", graphNodeId: "b" },
@@ -173,8 +167,8 @@ describe("the notes mapping-line fallback", () => {
sources: [{ id: "src_abc", filePath: "/vault/learn vim.md", importedAt: "2026-09-22T00:00:00.000Z" }],
});
assert.deepEqual(graphSessionPropositions(deps, "src_abc"), [
{ nodeId: "a", name: "Alpha", understanding: "solid", evaluatedAt: "2026-09-22T00:00:00.000Z" },
{ nodeId: "b", name: "Beta", understanding: null, evaluatedAt: null },
{ nodeId: "a", name: "Alpha" },
{ nodeId: "b", name: "Beta" },
]);
});
});
@@ -272,34 +266,16 @@ describe("graphSessionIndex (the prober's session list)", () => {
});
describe("graphSessionPropositions (the per-session view)", () => {
it("birth propositions with the latest extractor evaluation AND that event's own timestamp", () => {
it("birth propositions — a pure list, no evaluations (2026-09-27 cleanup)", () => {
const nodes = [
node("a", "Alpha", [pt("chat1")]),
node("b", "Beta", [pt("chat1")]),
node("c", "Gamma", [pt("chat2")]), // born elsewhere — never listed under chat1
];
const events: LedgerEvent[] = [
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "shaky", ref: "obs:p1" },
// A LATER evaluation supersedes the earlier one for (a, chat1).
{ nodeId: "a", kind: "observation", chatId: "chat1", at: "2026-09-21T00:00:00.000Z", note: "solid", ref: "obs:p2" },
// Quiz evidence (owner ruling 2026-09-23): recorded in the ledger
// but it NEVER moves the evaluation date.
{ nodeId: "a", kind: "correct", chatId: "chat1", at: "2026-09-22T00:00:00.000Z", note: "got it" },
// Another session's evaluation of the same node is not ours.
{ nodeId: "a", kind: "observation", chatId: "chat2", at: "2026-09-23T00:00:00.000Z", note: "resisted" },
// Same-at events: ledger order breaks the tie (the snapshot's rule).
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "accepted", ref: "obs:p3" },
{ nodeId: "b", kind: "observation", chatId: "chat1", at: "2026-09-20T00:00:00.000Z", note: "not yet engaged", ref: "obs:p4" },
];
const deps = fakeStores(nodes, { events });
const deps = fakeStores(nodes);
assert.deepEqual(graphSessionPropositions(deps, "chat1"), [
{ nodeId: "a", name: "Alpha", understanding: "solid", evaluatedAt: "2026-09-21T00:00:00.000Z" },
{ nodeId: "b", name: "Beta", understanding: "not yet engaged", evaluatedAt: "2026-09-20T00:00:00.000Z" },
]);
// A node the session never evaluated shows nulls, never a guess.
const fresh = fakeStores([node("x", "X", [pt("chat1")])], { events });
assert.deepEqual(graphSessionPropositions(fresh, "chat1"), [
{ nodeId: "x", name: "X", understanding: null, evaluatedAt: null },
{ nodeId: "a", name: "Alpha" },
{ nodeId: "b", name: "Beta" },
]);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* The shared session-markdown engine (core/session-markdown.ts, owner ruling
* 2026-09-27): ONE renderer for the extractor's source view and the future
* user-facing session export — id-anchored speaker blocks, readable
* quiz/recall/question blocks, one-line notes for other tools. Pinned here:
* block shapes, anchor handling (on/off), title header, empty-message
* skipping, and content parity with the coverage line builder.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { LhMessage } from "../src/core/native-messages.ts";
import { messageMarkdown, sessionMarkdown } from "../src/core/session-markdown.ts";
import { messageViewLines } from "../src/core/coverage.ts";
import { coverageSlots } from "../src/core/coverage.ts";
function user(id: string, text: string): LhMessage {
return { id, role: "user", content: text, timestamp: Date.now() } as never;
}
function teacher(id: string, text: string): LhMessage {
return {
id,
role: "assistant",
content: [{ type: "text", text }],
api: "openai-completions",
provider: "openrouter",
model: "test",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
stopReason: "stop",
timestamp: Date.now(),
} as never;
}
function result(
id: string,
toolCallId: string,
toolName: string,
details: unknown,
isError = false,
): LhMessage {
return {
id,
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: "ok" }],
details,
isError,
timestamp: Date.now(),
} as never;
}
describe("messageMarkdown", () => {
it("renders speaker blocks with the id anchor", () => {
const md = messageMarkdown(user("u1", "teach me the chain rule"), "u1");
assert.equal(md, "### Learner · u1\n\nteach me the chain rule");
const md2 = messageMarkdown(teacher("a1", "The derivative measures change."), "a1");
assert.equal(md2, "### Teacher · a1\n\nThe derivative measures change.");
});
it("anchors off — the same document without the ids (the user export)", () => {
const md = messageMarkdown(user("u1", "hello"), "u1", false);
assert.equal(md, "### Learner\n\nhello");
});
it("quiz blocks: question + outcome + the learner's note", () => {
const md = messageMarkdown(
result("r1", "q1", "quiz", { questionText: "What is a derivative?", correct: false, dontKnow: false, note: "mixed up signs" }),
"r1",
)!;
assert.match(md, /^### Quiz · r1$/m);
assert.match(md, /\*\*Q:\*\* What is a derivative\?/);
assert.match(md, /The learner answered incorrectly\./);
assert.match(md, /\*\(his thinking: mixed up signs\)\*/);
});
it("explain results: the learner's own words are shown", () => {
const md = messageMarkdown(
result("r1", "e1", "explain", { questionText: "Why does Mercury stay near the Sun?", text: "it never strays far" }),
"r1",
)!;
assert.match(md, /^### Recall check · r1$/m);
assert.match(md, /The learner answered in his own words: it never strays far/);
});
it("ask blocks: question + chosen options + free text", () => {
const chosen = messageMarkdown(
result("r1", "a1", "ask_user_question", { questionText: "Which way?", answers: ["second"], other: "also the third" }),
"r1",
)!;
assert.match(chosen, /^### Question · r1$/m);
assert.match(chosen, /The learner chose: second, also the third/);
const free = messageMarkdown(
result("r2", "a2", "ask_user_question", { questionText: "Why?", text: "because" }),
"r2",
)!;
assert.match(free, /The learner answered: because/);
});
it("other clean tool results: one line; failed calls and markers: nothing", () => {
assert.equal(messageMarkdown(result("r1", "j1", "research", { brief: "long" }), "r1"), "### Tool · r1\n\n*research result delivered*");
assert.equal(messageMarkdown(result("r2", "j2", "research", {}, true), "r2"), null, "a failed call is retry context");
assert.equal(messageMarkdown(user("u3", " "), "u3"), null, "empty text renders nothing");
});
});
describe("sessionMarkdown", () => {
it("assembles blocks in order under the title header, skipping nothings", () => {
const md = sessionMarkdown([user("u1", "go"), teacher("a1", "First."), user("u2", " ")], { title: "My session" });
assert.equal(md, "# My session\n\n### Learner · u1\n\ngo\n\n### Teacher · a1\n\nFirst.");
});
it("no title → no header; anchors default on", () => {
const md = sessionMarkdown([user("u1", "go")]);
assert.equal(md, "### Learner · u1\n\ngo");
});
it("content parity with the coverage line builder (2026-09-27 split)", () => {
// Every message that produces view lines produces a markdown block,
// and every message that renders nothing renders nothing in either —
// same messages, two formats, one seam decision. The FINGERPRINT
// stays on the line format (stable — no mass re-extraction when
// presentation changes); markdown is presentation only.
const messages: LhMessage[] = [
user("u1", "teach me"),
teacher("a1", "The chain rule multiplies local rates."),
result("r1", "q1", "quiz", { questionText: "Q?", correct: true, dontKnow: false }),
result("r2", "j1", "research", { brief: "b" }),
result("r3", "j2", "research", {}, true),
];
for (const message of messages) {
const hasLines = messageViewLines(message, message.id).length > 0;
const hasMarkdown = messageMarkdown(message, message.id) !== null;
assert.equal(hasMarkdown, hasLines, `parity for ${message.id}`);
}
});
});
+2 -2
View File
@@ -243,7 +243,7 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl
startedAt,
at: "2026-09-09T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, edges: 2, events: 3 },
stats: { nodes: 1, edges: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
@@ -280,7 +280,7 @@ test("appendSourceUsage: appends to the source record, idempotent per startedAt,
startedAt,
at: "2026-09-13T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, edges: 2, events: 3 },
stats: { nodes: 1, edges: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },