Part 1 — the ledger + remnants interlude (master-dispatched, owner ruling 2026-09-23: nothing configures the graph beyond chat models): - The embedding ledger surface is gone: readEmbeddings/writeEmbeddings ports, the EmbeddingsLedger contract, EMBEDDINGS_FILE (config, store impls, graphRevision source list), and graph-deletion's vector-pruning step. On-disk data files stay untouched (inert history). - The extraction embedder seam dies wholesale: ExtractionPipelineDeps drops the embedder dep (chat-handler + backfill/import-notes stubs go with it), the 'embedding' phase leaves ExtractionPhase, the usage capture/persist helpers die, and stages.embedding leaves ExtractionRunUsage (historical records keep their shape — reads are lenient). Extraction is two stages again, like the local-embedder era. - Consumer-less remnants: latestQuizOutcomes + QUIZ_EVENT_KINDS, clusterContentId, matchClusterLabelings + Community + ClusterLabeling, the readClusterLabels/appendClusterLabel port seams + store impls + LEDGER_FILES.clusterLabels. NamingRunUsage/readNamingRuns STAY (standing ruling — the cost pill renders historical naming spend); clusterHue + MIN_CLUSTER_SIZE stay (live). - The 'labeler' role dies: BACKGROUND_MODEL_ROLES, ROLE_SLOT, ModelRole, the catalog pin projections, and the UI roster strings. RolePins keys shrink with the union. The notes birth fallback (owner-approved 2026-09-23, surfaced by the interlude's live smoke: production notes imports create provenance-EMPTY nodes (importNotes passes messages: null — the file's anchor is the mapping line), so the provenance-only birth rule left every imported file unclustered and invisible to the prober's session list, against the locked file-birth ruling. A provenance-empty node now births under its FIRST mapping line's chatId (ledger order); only notes-born nodes ever hit the fallback (chat nodes always carry provenance; a re-encountered node's link-append re-opens the provenance rule). Deletion-safe: mapping lines of deleted chats are stripped alongside their provenance. Live-verified on port 3004: a fresh notes import clusters under its src_ id with the file path as the label, end to end with no embedder. 845/845 tests, typecheck/lint/build green. EOF )
202 lines
8.1 KiB
TypeScript
202 lines
8.1 KiB
TypeScript
// THE PROBER'S GRAPH TOOLS (wave 7) — 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.
|
|
// 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 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";
|
|
|
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
|
|
const node = (
|
|
id: string,
|
|
name: string,
|
|
provenance: Array<{ chatId: string; messageId: string }>,
|
|
): GraphNode => ({
|
|
id,
|
|
name,
|
|
statement: `statement of ${name}`, // must NEVER leak into tool output
|
|
aliases: [],
|
|
createdAt: "2026-09-23T00:00:00.000Z",
|
|
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">>;
|
|
} = {},
|
|
): { chatStore: ChatStore; graphStore: GraphStore } {
|
|
const edges: GraphEdge[] = [];
|
|
const graphStore: GraphStore = {
|
|
readNodes: () => nodes,
|
|
readEdges: () => edges,
|
|
readEvents: () => over.events ?? [],
|
|
readMappings: () => [],
|
|
readCheckpoints: () => [],
|
|
readCoverage: () => [],
|
|
readSources: () => [],
|
|
readPositions: () => ({}),
|
|
writePositions: () => {},
|
|
readClusterLabels: () => [],
|
|
appendClusterLabel: () => {},
|
|
appendNamingRun: () => {},
|
|
readNamingRuns: () => [],
|
|
} as unknown as GraphStore;
|
|
const chatStore: ChatStore = {
|
|
listChats: () =>
|
|
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
|
|
getChat: (id: string) =>
|
|
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
|
|
} as unknown as ChatStore;
|
|
return { chatStore, graphStore };
|
|
}
|
|
|
|
const stubJobStore = {
|
|
createJob: (() => ({})) as never,
|
|
getJob: (() => undefined) as never,
|
|
listJobs: (() => []) as never,
|
|
updateJob: (() => {}) as never,
|
|
} as unknown as JobStore;
|
|
|
|
const baseCtx = {
|
|
chatId: "c1",
|
|
deliver: () => {},
|
|
jobStore: stubJobStore,
|
|
spawn: () => {},
|
|
};
|
|
|
|
// ─── registration (the factory gate) ────────────────────────────────────────
|
|
|
|
describe("prober graph tools — registration", () => {
|
|
it("no graphStore in the context → the tools are absent (gate decided server-side)", () => {
|
|
const { chatStore } = fakeStores([]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore });
|
|
assert.equal("list_sessions" in tools, false);
|
|
assert.equal("list_propositions" in tools, false);
|
|
});
|
|
|
|
it("graphStore present → list_sessions + list_propositions join the registry beside the usual set", () => {
|
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
// The standing set (fix wave 2026-09-20: research + pdf are
|
|
// phase-omnipresent) plus the two graph-map readers.
|
|
assert.deepEqual(Object.keys(tools).sort(), [
|
|
"ask_user_question",
|
|
"find",
|
|
"grep",
|
|
"list_propositions",
|
|
"list_sessions",
|
|
"ls",
|
|
"pdf",
|
|
"probe_done",
|
|
"quiz",
|
|
"read",
|
|
"research",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── output shape + the no-prose rule ──────────────────────────────────────
|
|
|
|
describe("list_sessions", () => {
|
|
it("names, kinds, counts, dates, ids — in the prober's voice, no statements", async () => {
|
|
const { chatStore, graphStore } = fakeStores(
|
|
[
|
|
node("n1", "Water cycle", [pt("c1")]),
|
|
node("n2", "Evaporation", [pt("c1")]),
|
|
node("n3", "Nietzsche", [pt("c-file")]),
|
|
],
|
|
{
|
|
chats: [
|
|
{
|
|
id: "c1",
|
|
title: "First lesson",
|
|
createdAt: "2026-09-20T10:00:00.000Z",
|
|
updatedAt: "2026-09-21T10:00:00.000Z",
|
|
},
|
|
],
|
|
events: [],
|
|
},
|
|
);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string;
|
|
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
|
|
// The chat: name, kind, born count, dates, id — the full resume.
|
|
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
|
|
// The unrecorded birth session falls back honestly: the id, no fake date.
|
|
assert.match(out, /c-file \(file, 1 proposition, born unknown date\) — id: c-file/);
|
|
// The owner ruling: the map's prose never reaches the prober.
|
|
assert.ok(!out.includes("statement of"));
|
|
});
|
|
|
|
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
|
|
const { chatStore, graphStore } = fakeStores([]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string;
|
|
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
|
|
});
|
|
});
|
|
|
|
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"),
|
|
],
|
|
},
|
|
);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) 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.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: [],
|
|
});
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) as string;
|
|
assert.match(out, /- Water cycle — never evaluated/);
|
|
assert.ok(!out.includes("(as of"));
|
|
});
|
|
|
|
it("an unknown session id — honest redirect, not a throw", async () => {
|
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!({ session: "no-such" }, { toolCallId: "tc" })) as string;
|
|
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
|
});
|
|
});
|