From 6b13583e9643f1dc8ec8b21d69b59ba9784d84ac Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 15:24:48 +0300 Subject: [PATCH] =?UTF-8?q?wave=207:=20the=20prober=20reads=20its=20map=20?= =?UTF-8?q?=E2=80=94=20graph-context=20injection=20dies=20whole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's zero-configuration ruling (2026-09-23): nothing configures the graph beyond chat models. The prior-knowledge injection — its budget dial, its runner, its marker part, its dock/dispatch entries — is removed across all bands; the prober now reads the learner's persistent knowledge map on demand through two gated tools instead: - list_sessions: every birth session, names + kinds + counts + dates (graphSessionIndex, Platform's core seam) - list_propositions(session): what was born there + the session's own latest understanding level + that evaluation's date (graphSessionPropositions) — names/levels/dating ONLY, no statement text (owner ruling: the map's organization is the prober's business, its prose is not) Registered via createProberTools when chat-handler passes graphStore (the graphToolsEnabled gate decided server-side, per-turn fresh); the prober doctrine gains the map-review nudge at session start. Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS are plain records again), semantic-clusters.ts dies with its test, the embedder dep's doc tells the wave-7 stub story, and ports.ts's readClusterLabels/readNamingRuns surface stays untouched for Platform's ledger-port interlude. 848/848 tests, typecheck/lint/build green. --- components/activity-dock.tsx | 7 +- components/artifacts/dispatch-card.tsx | 3 - components/chat.tsx | 18 +- components/graph-context-control.tsx | 140 -------- components/graph-context-marker.tsx | 104 ------ components/home.tsx | 7 - components/new-session.tsx | 12 - src/agent/prompts/prober/10-probe-doctrine.md | 3 +- src/agent/subagents/extraction.ts | 36 +- src/agent/subagents/graph-context.ts | 222 ------------ src/agent/subagents/runner.ts | 22 +- src/agent/tools.ts | 23 +- src/agent/tools/graph.ts | 75 ++++ src/core/clusters.ts | 9 +- src/core/contracts/chat.ts | 66 ---- src/core/contracts/jobs.ts | 35 +- src/core/graph-context.ts | 184 ---------- src/core/graph.ts | 9 +- src/core/index.ts | 1 - src/core/ports.ts | 9 +- src/core/semantic-clusters.ts | 141 -------- src/core/ui-message.ts | 5 - src/server/chat-handler.ts | 5 + src/server/jobs.ts | 61 +--- src/server/pi/record-adapter.ts | 10 +- src/server/settings-body.ts | 24 +- tests/default-settings.test.ts | 11 - tests/extraction.test.ts | 14 +- tests/graph-context-core.test.ts | 293 --------------- tests/graph-context-job.test.ts | 333 ------------------ tests/graph-tools.test.ts | 203 +++++++++++ tests/merge-adopted.test.ts | 5 +- tests/model-catalog.test.ts | 6 +- tests/record-adapter.test.ts | 31 -- tests/render-activity-dock.test.tsx | 11 - tests/render-graph-context-marker.test.tsx | 59 ---- tests/render-new-session.test.tsx | 45 --- tests/semantic-clusters.test.ts | 118 ------- tests/settings-store.test.ts | 1 - tests/settings.test.ts | 2 +- 40 files changed, 373 insertions(+), 1990 deletions(-) delete mode 100644 components/graph-context-control.tsx delete mode 100644 components/graph-context-marker.tsx delete mode 100644 src/agent/subagents/graph-context.ts create mode 100644 src/agent/tools/graph.ts delete mode 100644 src/core/graph-context.ts delete mode 100644 src/core/semantic-clusters.ts delete mode 100644 tests/graph-context-core.test.ts delete mode 100644 tests/graph-context-job.test.ts create mode 100644 tests/graph-tools.test.ts delete mode 100644 tests/render-graph-context-marker.test.tsx delete mode 100644 tests/semantic-clusters.test.ts diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 1f02fcd..897c02c 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIcon, BookOpenIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; +import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import { CONTEXT_LIMITS, isModelsRefusal, jobCost } from "@/src/core"; @@ -236,7 +236,6 @@ const KIND_ICON: Record = { demo: PlayIcon, visual: PenToolIcon, pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf") - "graph-context": BookOpenIcon, }; /** Abort states per blob id: pending (fired) / failed (backend absent). */ @@ -353,9 +352,7 @@ function ActivityBlobRow({ ? "Figure maker" : blob.jobKind === "pdf" ? "PDF reader" - : blob.jobKind === "graph-context" - ? "Graph context" - : "Sub-agent"; + : "Sub-agent"; return (
= { demo: "Demo agent", visual: "Figure agent", pdf: "PDF reader", - // The injection is server-initiated — it has no tool part, so never a - // dispatch card. The entry exists only for the exhaustive record. - "graph-context": "Graph context", }; export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) { diff --git a/components/chat.tsx b/components/chat.tsx index 5ecf155..77e44f1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -24,7 +24,6 @@ import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; -import { GraphContextMarker } from "@/components/graph-context-marker"; import { useActivity } from "@/components/activity-dock"; import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; @@ -453,10 +452,10 @@ export function Chat({ setTimeout(() => { if (adoptionWindowOpenRef.current) { void adoptAppendOnly(); - // Turn-start nudge re-fire (accuracy wave): the graph-context - // job starts at send — the initial refresh above can race the - // server's job-record write; the window's ticks retry it so the - // dock always catches the job within the window (≤2s). + // Turn-start nudge re-fire (accuracy wave): extraction and + // sub-agent jobs start at send — the initial refresh above can + // race the server's job-record write; the window's ticks retry + // it so the dock always catches the job within the window (≤2s). refreshActivity(); } }, ms), @@ -945,14 +944,7 @@ export function Chat({ // PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene) // can still hold empty ids — the index keeps keys unique for them. - ) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? ( - // The prior-knowledge injection's delivery (vision-context-injection.md): - // a quiet, CLICKABLE line (owner observability ask, 2026-09-15) — - // the popover shows which propositions were delivered, in delivery - // order; the block itself was for the model (adapter flattening), - // the part data is the audit trail this reads from. - - ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? ( + ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? ( // The probe→teach handoff wake (custom-message scheme): quiet // system line, never a user bubble — the wake is scaffolding, // the "Planning" divider marks the seam, and the model-facing diff --git a/components/graph-context-control.tsx b/components/graph-context-control.tsx deleted file mode 100644 index b0b1227..0000000 --- a/components/graph-context-control.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { BookOpenIcon } from "lucide-react"; -import { - GRAPH_CONTEXT_TOKENS_DEFAULT, - GRAPH_CONTEXT_TOKENS_STOPS, - type ChatSettings, -} from "@/src/core"; -import { useSettingsSaved } from "@/components/settings-signal"; -import { useSettingsDialog } from "./settings/settings-dialog.tsx"; - -/** - * The knowledge-context budget control (vision-context-injection.md) — the - * "session data" section's one input, shown where sessions START: the home - * (graph) view's new-session dock and the /chat/new draft. It renders in - * the StatsBar's LEFT slot — the very place session data (context/cost) - * lives once a session exists, which pre-chat is free real estate (just - * "—"). Input before output: the panel that will later show what the - * session contributed to the graph asks first what the graph will - * contribute to the session. - * - * A DISCRETE 6-position slider (owner, 2026-09-15): off, then powers of four - * from 64 — a continuous 0–4096 dial was precision theater (a 128-token - * budget is ~2 propositions). The native range input walks positions 0–5 - * and maps through GRAPH_CONTEXT_TOKENS_STOPS; every stop is a real - * decision. Non-stop values from a stale client snap to the nearest stop - * for display. The readout shows the FULL number (no k-rounding). The - * value rides ChatSettings (the same lifted settings the model picker - * uses), so it persists pre-chat via the same deferred PUT. Sessions - * never show this control — the injection is one-shot at session start. - */ -export function GraphContextControl({ - value, - onChange, - testId = "graph-context-control", -}: { - value: number; - onChange: (tokens: number) => void; - testId?: string; -}) { - // Wave 6: context injection rides the embedding model — with none - // configured (graph.embeddingModel null) the control is inert, so it - // grays out and its hint deep-links to the Graph section's picker. - // Loading renders the normal control (the searchStatus precedent: - // never gray on null/unknown). Every settings save re-reads. - const { open: openSettingsSection } = useSettingsDialog(); - const [modelUnset, setModelUnset] = useState(false); - const [known, setKnown] = useState(false); - const readModel = useCallback(async () => { - try { - const resp = await fetch("/api/settings"); - if (!resp.ok) return; - const body = (await resp.json()) as { settings?: { graph?: { embeddingModel?: string | null } } }; - setModelUnset(!body.settings?.graph?.embeddingModel); - setKnown(true); - } catch { - // leave the last known state - } - }, []); - useEffect(() => { - void readModel(); - }, [readModel]); - useSettingsSaved(() => { - void readModel(); - }); - - if (known && modelUnset) { - return ( -
- - -
- ); - } - - // Snap for display only: a legacy non-stop value shows its nearest stop - // and the next drag lands on a real one. - const stop = nearestStop(value); - // indexOf on a readonly tuple rejects a plain number; the snap above - // always yields a member, so the cast is honest. - const index = (GRAPH_CONTEXT_TOKENS_STOPS as readonly number[]).indexOf(stop); - return ( -
- - Graph context - { - const next = (GRAPH_CONTEXT_TOKENS_STOPS as readonly number[])[Number(e.target.value)]; - if (next !== undefined) onChange(next); - }} - aria-label="Prior-knowledge token budget" - className="h-1 w-24 shrink-0 accent-primary" - data-testid="graph-context-slider" - /> - {/* Content-sized (owner, fix wave): the control hugs its value — - no fixed-width readout means no dead gap when "off", and the - dictation strip in the stats bar's middle always fills whatever - space the slider leaves. tabular-nums keeps the digits steady. */} - - {value === 0 ? "off" : `${value} tok`} - -
- ); -} - -/** The nearest stop to any budget value (display snapping). */ -export function nearestStop(value: number): number { - let best: number = GRAPH_CONTEXT_TOKENS_STOPS[0] ?? 0; - for (const s of GRAPH_CONTEXT_TOKENS_STOPS) { - if (Math.abs(s - value) < Math.abs(best - value)) best = s; - } - return best; -} - -/** The control's effective value for a chat's settings — absent = the - * default (on), never 0: the decided default is a modest slice of prior - * knowledge for every fresh session. */ -export function effectiveContextTokens( - settings: Pick | undefined, -): number { - return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT; -} \ No newline at end of file diff --git a/components/graph-context-marker.tsx b/components/graph-context-marker.tsx deleted file mode 100644 index 099e4ab..0000000 --- a/components/graph-context-marker.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { BookOpenIcon } from "lucide-react"; -import { graphContextOfMessage, type GraphContextData } from "@/src/core"; -import { formatTokens } from "@/components/usage-format"; - -/** - * The knowledge-context delivery marker (vision-context-injection.md): the - * quiet transcript line for the injected prior-knowledge block — CLICKABLE - * (owner observability ask, 2026-09-15): the popover shows exactly which - * propositions were delivered, IN DELIVERY ORDER (most similar first — - * the order the selection filled the budget), each with its folded current - * understanding level. The record part is the audit trail; this makes it - * readable at the seam where it landed. - * - * Same quiet-line discipline as the SystemLine family (centered, muted, - * small); same popover discipline as the stats-detail (close on outside - * pointerdown + Escape, anchored below the marker). - */ -export function GraphContextMarker({ - message, - testId = "graph-context", -}: { - /** The steer message (marker-only user message). */ - message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }; - testId?: string; -}) { - const [open, setOpen] = useState(false); - const data = graphContextOfMessage(message); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onPointerDown = (e: PointerEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - document.addEventListener("pointerdown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - const summary = data - ? `Graph context — ~${formatTokens(data.tokens)} · ${data.propositions.length} propositions` - : "Graph context"; - - return ( -
- - {open && data && } -
- ); -} - -/** The popover body — exported for render tests. Delivery order preserved. */ -export function GraphContextList({ data }: { data: GraphContextData }): ReactNode { - return ( -
-
- - Prior knowledge delivered - - - ~{formatTokens(data.tokens)} tok of {data.budget} budget - -
-

- Most similar first — the order the budget filled. The tags are your current - understanding level for each proposition. -

-
    - {data.propositions.map((p, i) => ( -
  1. - {i + 1}. - {p.level && ( - - {p.level} - - )} - {p.statement} -
  2. - ))} -
-
- ); -} \ No newline at end of file diff --git a/components/home.tsx b/components/home.tsx index 79a0cb0..aafb28e 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -6,7 +6,6 @@ import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SetupDialog } from "@/components/setup-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; -import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; import { useDefaultSettings } from "@/components/default-settings"; import { DictationBar, useDictation } from "@/components/dictation"; import { SidebarToggle } from "@/components/sidebar"; @@ -172,12 +171,6 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: } turns={[] as TurnUsage[]} - leftSlot={ - saveDraft({ ...settings, graphContextTokens: tokens })} - /> - } />
diff --git a/components/new-session.tsx b/components/new-session.tsx index 4e1a10f..e05ba58 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; -import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; import { useDefaultSettings } from "@/components/default-settings"; import { PhaseChip } from "@/components/phase-divider"; import { SidebarToggle } from "@/components/sidebar"; @@ -131,17 +130,6 @@ export function DraftShell({ } turns={[]} - // The session-data slot (owner, 2026-09-15): pre-chat the left - // side is free — the knowledge-context budget lives exactly - // where session data will sit once a session exists. - leftSlot={ - - onSettingsSaved({ ...settings, graphContextTokens: tokens }) - } - /> - } /> diff --git a/src/agent/prompts/prober/10-probe-doctrine.md b/src/agent/prompts/prober/10-probe-doctrine.md index b862070..5518773 100644 --- a/src/agent/prompts/prober/10-probe-doctrine.md +++ b/src/agent/prompts/prober/10-probe-doctrine.md @@ -23,4 +23,5 @@ Find out what he actually wants taught. With a subject he doesn't know yet, the Your questions are the substance of the session right now: they **open instantly** in the learner's dock, front and center — that immediacy is exactly what probing needs. Ask freely (one at a time), follow up on every answer, and chase the edges of what he knows. The probe is working when the questions are getting sharper, not easier. -If the learner references a local file (notes, a syllabus, code he's working on), the `read` tool can inspect it directly — ground your assessment in his actual material instead of asking him to paste contents. For his PDF material, the `pdf` tool dispatches a sub-agent that reads it for you and returns a cited brief — same grounding, no copy-pasting. And if pinning down a fact would genuinely sharpen a probe question (a date, a formula, a term you're not sure of), the `research` tool can verify it — but sparingly: probing is measurement, not exploration, and waiting on a research brief is never a reason to stall the questions. \ No newline at end of file +If the learner references a local file (notes, a syllabus, code he's working on), the `read` tool can inspect it directly — ground your assessment in his actual material instead of asking him to paste contents. For his PDF material, the `pdf` tool dispatches a sub-agent that reads it for you and returns a cited brief — same grounding, no copy-pasting. And if pinning down a fact would genuinely sharpen a probe question (a date, a formula, a term you're not sure of), the `research` tool can verify it — but sparingly: probing is measurement, not exploration, and waiting on a research brief is never a reason to stall the questions. +Before your first question, review what he already knows that the session itself cannot show you: his persistent knowledge map, read through `list_sessions` and `list_propositions` — the propositions he carries from earlier sessions change which edges are worth locating, and a strand he already holds at "solid" needs no re-probing. Re-check after your merges add new propositions. If the tools aren't there, the map is off — just probe. diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index dff0527..e4d0962 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -482,12 +482,12 @@ function runNotesAuditor( // ─── the pipeline status registry ───────────────────────────────────────────── /** The pipeline phases, in order — what the home status endpoint shows. - * `embedding` is its own phase (accuracy wave, 2026-09-19): the post-batch - * tail — embed every uncovered proposition, re-cluster, settle the layout - * — used to hide inside `merging`, so a run that had already merged every - * batch sat on "merging" for the whole embedding tail (model load + - * cold-start backfill on first runs = minutes). The phases must tell the - * truth about what the loop is actually waiting on. */ + * `embedding` was its own phase (accuracy wave, 2026-09-19): the post-batch + * embed-re-cluster tail used to hide inside `merging`. Wave 7 stubbed the + * tail to a no-op — the phase survives only until Platform's + * ledger-port interlude deletes the seam wholesale; it transitions + * instantly today. The phases must tell the truth about what the loop is + * actually waiting on. */ export type ExtractionPhase = "extracting" | "auditing" | "merging" | "embedding"; /** One in-flight pipeline run. Module-level: one process, one registry. @@ -575,16 +575,12 @@ export interface ExtractionModels { } export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels { - /** The post-merge embedding batch (semantic clusters run on it). - * INJECTED by the composition root (the agent layer never imports - * the server layer — architecture law): production passes the - * provider-embedder batch (server/embeddings.ts embedNewNodes — BYOK - * wave 6; null model = it skips itself with an info log), tests fake - * it. Failure inside it is non-fatal by contract, and since 02bd1b0 - * it fails BY RESULT — landed vectors + partial usage ride the - * return value instead of a throw. When the result carries a - * `usage` stage (the EmbedBatchResult contract), it is recorded - * into the run's money-channel capture. */ + /** The post-merge embedding batch — WAVE 7 STUB: the provider + * embedder and semantic clustering are dead (birth-session clusters + * + session-name labels), so production injects a no-op that + * reports skipped; tests fake writes to the (still-live) ledger + * port. The whole seam dies with Platform's ledger-port interlude + * — kept one round so that removal is a pure deletion. */ embedder: (graphStore: GraphStore) => Promise; } @@ -863,11 +859,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // The embedding tail gets its own phase — the honest label for what // the run is waiting on here (see ExtractionPhase above). setPhase(chatId, "embedding", "chat"); - // Semantic embeddings (DECIDED 2026-09-13): topics come from - // MEANING, so embed the new propositions BEFORE the settle and - // the namer look at clusters. Failure is non-fatal — the landed - // vectors persist (pay-once, 02bd1b0) and clustering runs with - // them; the next merge's retry embeds only what remains. + // The embedding tail (wave 7 stub — see ExtractionPipelineDeps): + // the seam stays until Platform's ledger-port interlude; the + // failure contract is unchanged (non-fatal, by-result). await captureEmbedUsage(chatId, await embed(graphStore).catch(recordEmbedFailure(chatId))); // Option A: the layout ledger evolves with the merge (sync — the // next snapshot fetch must already reflect the new nodes). diff --git a/src/agent/subagents/graph-context.ts b/src/agent/subagents/graph-context.ts deleted file mode 100644 index c3a21ca..0000000 --- a/src/agent/subagents/graph-context.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Graph context injection — the background runner - * (docs/vision-context-injection.md, settled 2026-09-14). - * - * One shot per chat, at session start: the first learner message is the - * session's TOPIC; it is embedded as a retrieval QUERY (the embedder's - * model-card format), the semantically closest graph propositions are - * selected under the session's token budget (pure core: selectGraphContext), - * and delivered as a marker-only STEER through the wake pipeline — the same - * gates as sub-agent results: it queues behind a pending quiz and rides the - * turn after the learner's answer. The prober typically opens with a run of - * quiz/ask calls, so delivery lands mid-probing, before plan_init — where - * prior knowledge matters most. - * - * No teacher-model call: the job's only model spend is ONE embedding - * query (the local embedder was free, provider ones are not — wave 6 - * records its prompt tokens on the job record). It still carries a - * JobRecord (kind "graph-context") so the dock shows it, and it - * registers its AbortController so the dock's END kills it — but an abort - * steers NOTHING: nothing was ever promised in the lesson (the injection is - * server-initiated scaffolding, unlike a teacher-dispatched sub-agent). - * - * The topic gate is structural: injection fires only while the session has - * EXACTLY ONE text-bearing user message — the topic. Old in-flight sessions - * (several typed messages) never inject (no retroactive injection), and a - * session whose first turn happened with injection off stays off even if - * the budget is raised later (set per-session, before the session starts). - */ - -import { - accumulateJobUsage, - buildGraphContextSteer, - steerDelivered, - type JobRecord, - type SteerMessage, -} from "../../core/contracts.ts"; -import { - GRAPH_CONTEXT_TOKENS_DEFAULT, - type ChatSettings, -} from "../../core/contracts/chat.ts"; -import { hasContext, selectGraphContext } from "../../core/graph-context.ts"; -import type { UIMessage } from "ai"; -import { registerJobAbort, unregisterJobAbort } from "./aborts.ts"; -import type { ChatStore, GraphStore, JobStore } from "../../core/ports.ts"; - -/** The embedder as the runner needs it (BYOK wave 6): the model slug the - * ledger must match (parity: one vector space) + the query seam. - * Resolved FRESH per run by the composition root (deps.embedder) so a - * settings PUT or a key save between dispatch and spawn lands; null - * model = no embedding configured — the run skips honestly. The query - * carries its prompt-token count so the job records its spend (the - * local embedder was free; provider ones are not). */ -export interface GraphContextEmbedder { - model: string | null; - embedQuery: (text: string) => Promise<{ vector: number[]; promptTokens?: number }>; -} - -export interface GraphContextDeps { - chatStore: Pick; - jobStore: Pick; - graphStore: Pick; - /** The embedder RESOLVER (wave 6): called once at run start — fresh - * setting + key reads each run. null = no embedding model configured. */ - embedder: () => GraphContextEmbedder | null; - /** The wake seam — jobs.ts deliverJobResult (resolves on delivery). */ - deliver: (steer: SteerMessage) => Promise; -} - -/** The injection job's synthetic toolCallId — no tool part exists; the id - * is deterministic per chat so findJobByToolCallId doubles as the one-shot - * guard, mirroring the spawn-dedup the tool jobs get for free. */ -export function contextToolCallId(chatId: string): string { - return `ctx_${chatId}`; -} - -/** The session's effective budget: settings override, else the default. - * Absent ≠ 0 — the decided default is ON (1024 tokens). */ -export function effectiveContextBudget(settings: ChatSettings | undefined): number { - return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT; -} - -/** The session's topic: its ONLY text-bearing user message, if exactly one - * exists. Steers (verdicts, handoffs, answers) carry no text part, so - * quiz rounds during probing never break the gate. */ -export function topicOf(messages: UIMessage[]): string | null { - let topic: string | null = null; - let count = 0; - for (const m of messages) { - if (m.role !== "user") continue; - const text = m.parts - .filter((p) => p.type === "text") - .map((p) => (p as { text?: unknown }).text) - .filter((t): t is string => typeof t === "string" && t.trim().length > 0) - .join("\n"); - if (text.length === 0) continue; - count++; - topic = text; - } - return count === 1 ? topic : null; -} - -/** - * The one-shot trigger (fire-and-forget, called from turn-service at every - * turn's onFinish): starts the injection job if and only if the session is - * at its start (exactly one typed message — the topic), the budget is on, - * and nothing was injected before (delivered steer OR any prior context - * job — success or failure: one shot means one shot). Returns the created - * job for tests; null when nothing was started. - */ -export function startGraphContextInjection(deps: GraphContextDeps, chatId: string): JobRecord | null { - const { chatStore, jobStore } = deps; - const chat = chatStore.getChat(chatId); - if (!chat) return null; - if (effectiveContextBudget(chat.settings) <= 0) return null; - if (topicOf(chat.messages) === null) return null; - // One per chat, ever: a delivered steer OR a prior job (any status). - if (steerDelivered(chat.messages, `steer_ctx_${chatId}`)) return null; - if (jobStore.findJobByToolCallId(chatId, contextToolCallId(chatId))) return null; - - const job = jobStore.createJob({ - kind: "graph-context", - chatId, - toolCallId: contextToolCallId(chatId), - activity: "searching knowledge graph", - }); - void runGraphContextJob(deps, job.id).catch((err: unknown) => { - console.error(`[chat ${chatId}] graph-context injection crashed:`, err); - }); - return job; -} - -/** Run one injection job to completion (background; never throws). Also the - * crash-recovery respawn seam: a job record orphaned "running" by a - * process crash re-runs from here (the record is the only input needed). */ -export async function runGraphContextJob(deps: GraphContextDeps, jobId: string): Promise { - const { chatStore, jobStore, graphStore, deliver } = deps; - const job = jobStore.getJob(jobId); - if (!job || job.status !== "running") return; - - const fail = (reason: string) => { - jobStore.updateJob(jobId, { status: "error", error: reason, activity: "failed" }); - }; - const chat = chatStore.getChat(job.chatId); - if (!chat) return fail(`chat not found: ${job.chatId}`); - // BYOK (wave 6): fresh resolution per run; null = honestly off (the - // dispatch gate already skips unconfigured sessions — this only fires - // on a config change racing a respawn, and the error says exactly why). - // The resolver returns null for BOTH an unset model and a set model - // whose key vanished, so the message names both, never a false "null". - const embedder = deps.embedder(); - if (!embedder || embedder.model === null) { - return fail("no embedding model available — injection skipped (graph.embeddingModel unset, or its provider key is missing)"); - } - const topic = topicOf(chat.messages); - if (topic === null) return fail("no injectable topic (session past its start)"); - const budget = effectiveContextBudget(chat.settings); - - // Real-kill wiring: the dock's END fires this controller; the runner - // checks it before every phase transition and unwinds silently. - const controller = new AbortController(); - registerJobAbort(jobId, controller); - try { - jobStore.updateJob(jobId, { activity: "embedding topic" }); - const ledger = graphStore.readEmbeddings(); - // Staleness is the MODEL identity (wave 6): a given model serves one - // dim by default, so model parity is space parity; the dim is checked - // defensively against the served vector below. - if (ledger.model !== embedder.model) { - return fail("embeddings ledger is stale (model mismatch) — injection skipped"); - } - if (Object.keys(ledger.vectors).length === 0) { - return fail("the knowledge graph has no embeddings yet — nothing to inject"); - } - const query = await embedder.embedQuery(topic); - if (controller.signal.aborted) return; - // The defensive dim half of the old parity check: a provider-side dim - // change (rare) must fail the query, never silently mismatch. - if (query.vector.length !== ledger.dim) { - return fail( - `embedding dim mismatch: query is ${query.vector.length}, ledger holds ${ledger.dim}`, - ); - } - // Money channel (wave 6): the one embedding call's prompt tokens fold - // into the job record — priced via the EMBEDDING_PRICES overlay. - if (query.promptTokens !== undefined) { - jobStore.updateJob(jobId, { - usage: accumulateJobUsage(job.usage, embedder.model, { - inputTokens: query.promptTokens, - outputTokens: 0, - }), - }); - } - - jobStore.updateJob(jobId, { activity: "selecting propositions" }); - const data = selectGraphContext({ - chatId: job.chatId, - query: query.vector, - vectors: ledger.vectors, - nodes: graphStore.readNodes(), - events: graphStore.readEvents(), - budget, - }); - if (!hasContext(data)) return fail("nothing relevant fit the budget"); - if (controller.signal.aborted) return; - - // The result rides the record (crash recovery re-delivers it); the - // wake pipeline flips the job done when the lesson has resumed. - jobStore.updateJob(jobId, { result: data, activity: "delivering context" }); - try { - await deliver(buildGraphContextSteer({ chatId: job.chatId, jobId }, data)); - } catch (err) { - console.error(`[chat ${job.chatId}] graph-context steer delivery failed:`, err); - fail("context delivery failed"); - } - } catch (err) { - if (controller.signal.aborted) return; // the abort path owns the record - console.error(`[chat ${job.chatId}] graph-context injection failed:`, err); - fail(err instanceof Error ? err.message : String(err)); - } finally { - unregisterJobAbort(jobId); - } -} \ No newline at end of file diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 1411f50..2a6671b 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -107,7 +107,7 @@ interface LoopContext { /** The kind→role table (model slots wave, 2026-09-20): which ModelRole's * engine each sub-agent kind runs — the engineFor injection key. */ -const KIND_ROLE: Record, ModelRole> = { +const KIND_ROLE: Record = { research: "researcher", demo: "demoBuilder", visual: "visualMaker", @@ -148,18 +148,14 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { return jobStore.getJob(jobId)?.status === "running"; } - /** The kind→runner map: one sub-agent loop per SUB-AGENT kind. - * graph-context is deliberately absent: injection jobs are not - * sub-agent loops (no model, graph deps instead) — their spawn and - * crash-respawn seam is subagents/graph-context.ts, and runJob refuses - * them below rather than guessing wrong. */ - // Each kind's runner narrows the union input itself (dispatch-time inputs - // are kind-correct by construction; transcript fallbacks are cast after). + /** The kind→runner map: one sub-agent loop per kind. Each kind's + * runner narrows the union input itself (dispatch-time inputs are + * kind-correct by construction; transcript fallbacks are cast after). */ /** The engine one job runs on: test override first (the old seam — * tests swap scripted engines without touching deps), then the injected * settings-resolved engineFor. undefined = the BYOK chain came up * empty (owner, 2026-09-22) — runJob refuses below, honestly. */ - function pickEngine(kind: Exclude): PiEngine | undefined { + function pickEngine(kind: JobKind): PiEngine | undefined { const testOverride: Record = { researcher: researchModelOverride, demoBuilder: demoModelOverride, @@ -170,7 +166,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { } const KIND_RUNNERS: Record< - Exclude, + JobKind, (jobId: string, engine: PiEngine, input: JobToolInput | undefined, loop: LoopContext) => Promise > = { research: (jobId, engine, input, loop) => runResearchJob(jobId, engine, input, loop), @@ -186,12 +182,6 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { return; } if (job.status !== "running") return; // already finished / re-run after crash recovery - if (job.kind === "graph-context") { - // Not a sub-agent: the injection's own respawn seam - // (setContextRespawn → runGraphContextJob) owns these records. - console.error(`[job ${jobId}] graph-context job reached the sub-agent runner — wrong seam`); - return; - } const run = KIND_RUNNERS[job.kind]; if (!run) { console.error(`[job ${jobId}] unknown kind: ${job.kind}`); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 239bc7b..8df7255 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -11,10 +11,12 @@ import type { JobStore } from "../core/ports.ts"; import { jobTools, type DemoInput, type PdfInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts"; import { planTools, type PlanToolContext } from "./tools/plan.ts"; import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts"; +import { proberGraphTools } from "./tools/graph.ts"; import { readTool } from "./tools/read.ts"; import { lsTool } from "./tools/ls.ts"; import { findTool } from "./tools/find.ts"; import { grepTool } from "./tools/grep.ts"; +import type { GraphStore } from "../core/ports.ts"; export { jobTools }; export type { ProbeDoneContext } from "./tools/probe.ts"; @@ -78,14 +80,20 @@ export interface ProberToolContext extends ProbeDoneContext { /** Search availability — the research dispatch tool's honest-disable * signal (same gate as the teacher's). Absent = available. */ searchAvailable?: () => boolean; + /** The prober's graph-map reader (wave 7): present = the two map tools + * (list_sessions / list_propositions) are registered. The composition + * root passes it only when graphToolsEnabled(effective) — the graph + * master switch AND the agent.graphTools toggle (settings-resolution). + * Absent = the gate is closed, no map tools (tests drive this seam). */ + graphStore?: GraphStore; } /** The prober's tool set: probe quiz (no nodeId) + ask + the learner-fs - * set + probe_done + the two phase-omnipresent job dispatch tools. - * `enabled` is the resolved settings seam (core/pending.ts): any tool - * explicitly false is dropped from the registry. probe_done stays - * always-on regardless — it is how the phase ENDS; a prober that can - * never conclude is a lockout. */ + * set + probe_done + the two phase-omnipresent job dispatch tools + + * (wave 7, gated) the graph-map reader. `enabled` is the resolved + * settings seam (core/pending.ts): any tool explicitly false is dropped + * from the registry. probe_done stays always-on regardless — it is how + * the phase ENDS; a prober that can never conclude is a lockout. */ export function createProberTools( ctx: ProberToolContext, enabled?: EnabledTools, @@ -122,6 +130,11 @@ export function createProberTools( // when no engine is live). ...(jobs.research ? { research: jobs.research } : {}), pdf: jobs.pdf!, + // The map reader (wave 7): present only when the settings gate + // opened — same registration-time drop as the search gate. + ...(ctx.graphStore + ? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore }) + : {}), ...done, }, enabled, diff --git a/src/agent/tools/graph.ts b/src/agent/tools/graph.ts new file mode 100644 index 0000000..5efadc1 --- /dev/null +++ b/src/agent/tools/graph.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { lhTool, type LhTool } from "../lh-tool.ts"; +import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts"; +import type { ChatStore, GraphStore } from "../../core/ports.ts"; + +/** + * THE PROBER'S GRAPH TOOLS (wave 7, owner-locked 2026-09-23): context + * injection is dead — zero configuration beyond chat models. The prober + * reads the learner's persistent knowledge map through two tools instead, + * replacing the old injected block with on-demand eyes. + * + * Names + levels + dating ONLY — no statement text, no session contents + * (owner ruling: the map's ORGANIZATION is the prober's business, its + * prose is not). The data comes from Platform's pure core seams + * (graphSessionIndex / graphSessionPropositions) over the same ports the + * snapshot reads, so the prober and the learner's graph view can never + * disagree. + * + * Registered on the prober ONLY when the settings gate opens + * (graphToolsEnabled — the graph master switch AND the agent.graphTools + * toggle); the composition root decides, the factory just receives the + * store (absent = the gate is closed, no map tools — tests drive this). + */ + +/** What the graph tools close over: the stores the session views read. */ +export interface GraphToolContext { + chatStore: ChatStore; + graphStore: GraphStore; +} + +export function proberGraphTools(ctx: GraphToolContext): Record> { + const deps = { chatStore: ctx.chatStore, graphStore: ctx.graphStore }; + return { + list_sessions: lhTool({ + name: "list_sessions", + description: + "List the sessions of the learner's persistent knowledge graph — every chat or imported-notes file that ever contributed a proposition, most recent first. The map is organized into birth-session clusters (propositions grouped by the session they were first learned in), and session names are those clusters' labels. " + + "Each line: the session's name, kind (chat/file), how many propositions were born in it, its dates, and its id — use that id with list_propositions. " + + "Reading this first shows what he has already covered and when, before you probe anything.", + schema: z.object({}), + execute: async (): Promise => { + const sessions = graphSessionIndex(deps); + if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet."; + const lines = sessions.map((s, i) => + `${i + 1}. ${s.name} (${s.kind}, ${s.propositions} proposition${s.propositions === 1 ? "" : "s"}, ` + + `born ${s.createdAt ?? "unknown date"}${s.updatedAt ? `, last active ${s.updatedAt}` : ""}) — id: ${s.id}`, + ); + return `${sessions.length} session(s) in the knowledge graph, most recent first:\n${lines.join("\n")}`; + }, + }), + list_propositions: lhTool({ + name: "list_propositions", + description: + "List the propositions BORN in one knowledge-graph session — what was first learned there — each with his latest understanding level on the scale solid / shaky / accepted / resisted / not yet engaged, and the date that level was set. " + + "Names, levels, and dating only — no statement text, no session contents. " + + "The level is that session's own latest evaluation; other sessions may have moved it since, so cross-session levels can differ.", + schema: z.object({ + session: z + .string() + .min(1) + .describe("The session's id exactly as list_sessions reported it (a chat id or a notes source id)"), + }), + execute: async ({ session }): Promise => { + const propositions = graphSessionPropositions(deps, session); + if (propositions.length === 0) { + return `No propositions born in session "${session}". Call list_sessions for the current ids.`; + } + const lines = propositions.map( + (p) => `- ${p.name} — ${p.understanding ?? "never evaluated"}${p.evaluatedAt ? ` (as of ${p.evaluatedAt})` : ""}`, + ); + return `${propositions.length} proposition(s) born in this session:\n${lines.join("\n")}`; + }, + }), + }; +} diff --git a/src/core/clusters.ts b/src/core/clusters.ts index 7bc17b2..8c661ca 100644 --- a/src/core/clusters.ts +++ b/src/core/clusters.ts @@ -1,9 +1,8 @@ /** - * CLUSTER LABEL MATCHING (vision §1). DETECTION moved to semantic topic - * clustering over embeddings (core/semantic-clusters.ts, DECIDED - * 2026-09-13) — Louvain over the edge structure is retired: it only ever - * found the reasoning chains (the graph is disconnected by nature), and - * edge-orphans could never join anything. What REMAINS here is the + * CLUSTER LABEL MATCHING (vision §1). DETECTION is birth-session + * clustering since wave 7 (core/session-clusters.ts; the semantic + * embedder and its Louvain predecessor are both dead). What REMAINS here + * is the * cluster contract + core-survival label matching, which is * detection-agnostic: it consumes the same { members } shape semantic * clustering emits. diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 6537a05..971de45 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -58,27 +58,8 @@ export interface ChatSettings { /** pi thinking level — must be in the chosen model's supported list * (getSupportedThinkingLevels; enforced by the settings route). */ thinkingLevel?: ThinkingLevel; - /** Graph context injection (vision-context-injection.md): the session's - * token budget for the prior-knowledge block — the top semantically - * closest propositions from the knowledge graph, injected once, at - * session start, as a steer. Absent = the default (on); 0 = off. The - * settings route enforces the range; the trigger (turn-service → - * subagents/graph-context.ts) is one-shot, so a mid-session change - * affects only sessions that haven't injected yet. */ - graphContextTokens?: number; } -/** The injection budget's decided knobs (2026-09-14; scale revised - * 2026-09-15, owner): default 1024 (a meaningful, modest slice of prior - * knowledge), hard max 16384, 0 = off. The UI is a DISCRETE slider over - * these stops — powers of four from 64 (the fine-grained low end was - * theater: a 128-token budget is ~2 propositions, nobody wants that - * dial); the server still accepts any integer in range (a stale client - * can't brick settings), the stops only govern what the slider sends. */ -export const GRAPH_CONTEXT_TOKENS_DEFAULT = 1024; -export const GRAPH_CONTEXT_TOKENS_MAX = 16384; -/** The slider's positions, left to right: off, then powers of four. */ -export const GRAPH_CONTEXT_TOKENS_STOPS = [0, 64, 256, 1024, 4096, 16384] as const; // ─── Background-extraction usage (the money channel, extraction side) ──────── @@ -202,53 +183,6 @@ export const HANDOFF_TEXT = "The probe is concluded — the learner assessment is recorded. " + "Author the session's plan map now (call plan_init) and present it, then begin teaching."; -// ─── Graph context injection (vision-context-injection.md, 2026-09-14) ──────── - -/** One selected prior-knowledge proposition: similarity-ranked, annotated - * with the learner's CURRENT understanding level — the fold is MOST - * RECENT extraction instance (levels are per-instance, tied to moments in - * time; the latest evidence is the best estimate of current understanding). - * null = the node has no observation event at all. */ -export interface GraphContextProposition { - id: string; - statement: string; - level: UnderstandingLevel | null; -} - -/** The data-graph-context part payload: what the UI renders (the counts) - * AND the audit trail (the full block content — the transcript shows - * exactly what was injected, and when). The model-facing text is derived - * from this by graphContextText below — the adapter flattens the marker - * part to it (the custom-message scheme: no authored human speech). */ -export interface GraphContextData { - chatId: string; - /** The session's budget at injection time (for the card's readout). */ - budget: number; - /** Estimated tokens of the injected block — chars/4 heuristic. */ - tokens: number; - /** The selected propositions, similarity-descending. */ - propositions: GraphContextProposition[]; -} - -/** The model-facing block text — single-sourced (the adapter, the tests, - * and reality can't drift). The level word travels with each line: the - * closing gloss tells the agent what to do with each. */ -export function graphContextText(data: GraphContextData): string { - const n = data.propositions.length; - if (n === 0) return ""; - const lines = data.propositions.map( - (p) => `- ${p.level ? `[${p.level}] ` : ""}${p.statement}`, - ); - return [ - `Prior-knowledge context — what this learner already knows (from their knowledge graph, ` + - `${n} proposition${n === 1 ? "" : "s"}, ~${data.tokens} tokens):`, - "", - ...lines, - "", - "Treat this as reliable prior knowledge: build on what is solid, firm up what is shaky, " + - "and teach around anything resisted. Do not re-teach solid ground.", - ].join("\n"); -} export interface PlanBlockData { /** Only "full" survives (the 2026-09-11 pivot): the plan renders IN diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index 947531f..ea24ae3 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -7,11 +7,11 @@ * of "ai" (safe everywhere: server, client, tests). See docs/vision.md. */ import type { UIMessage } from "ai"; -import type { CallUsage, GraphContextData } from "./chat.ts"; +import type { CallUsage } from "./chat.ts"; // ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── -export type JobKind = "research" | "demo" | "visual" | "pdf" | "graph-context"; +export type JobKind = "research" | "demo" | "visual" | "pdf"; export type JobStatus = "running" | "done" | "error"; export interface JobRecord { @@ -295,8 +295,7 @@ export interface AbortSteerMessage extends UIMessage { export type SteerMessage = | SubagentSteerMessage | AbortSteerMessage - | ProbeHandoffSteerMessage - | GraphContextSteerMessage; + | ProbeHandoffSteerMessage; /** Whether a result is the failure shape (html/svg empty, failure brief). */ export function isSubagentFailure( @@ -426,34 +425,6 @@ export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage }; } -// ─── the graph-context steer (vision-context-injection.md) ─────────────────── -// -// The injection's delivery: same wake pipeline, same gates — it queues -// behind a pending quiz and rides the turn after the learner's answer, or -// fires in the pump's idle window. Marker-only (the custom-message scheme): -// no text part; the adapter flattens data-graph-context to graphContextText -// (core/contracts/chat.ts owns the wording). One per chat, ever — the id is -// deterministic per chat, so re-delivery is idempotent. - -export interface GraphContextSteerMessage extends UIMessage { - id: `steer_ctx_${string}`; - role: "user"; - metadata: { graphContext: { chatId: string; jobId: string } }; - parts: [{ type: "data-graph-context"; data: GraphContextData }]; -} - -export function buildGraphContextSteer( - meta: { chatId: string; jobId: string }, - data: GraphContextData, -): GraphContextSteerMessage { - return { - id: `steer_ctx_${meta.chatId}`, - role: "user", - metadata: { graphContext: meta }, - parts: [{ type: "data-graph-context", data }], - }; -} - /** True when the transcript already carries this steer — idempotent * delivery for ANY steer kind: every steer id is deterministic per its * event (steer_ for jobs, steer_probe_ for the handoff). */ diff --git a/src/core/graph-context.ts b/src/core/graph-context.ts deleted file mode 100644 index cd79e99..0000000 --- a/src/core/graph-context.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Graph context injection — the pure selection core - * (docs/vision-context-injection.md, settled 2026-09-14). - * - * The heavy lifting is deliberately tiny: embedding the topic is the only - * slow step (the embedder singleton), and SEARCH IS BRUTE-FORCE COSINE — - * ~1M multiply-adds at 2k propositions, exact top-K from the in-memory - * ledger, no ANN index until ~10⁶ scale (far away). - * - * Selection doctrine (owner-decided): the token budget is the ONLY control - * on amount — there is NO similarity threshold. Similarity decides ORDER - * (which propositions fill the budget first); irrelevant-but-cheap content - * rides along harmlessly. Levels fold MOST RECENT: UnderstandingLevel lives - * per extraction instance (tied to moments in time), and the latest evidence - * is the best estimate of current understanding — an optimistic fold risks - * stale claims, a pessimistic fold wastes covered ground. - */ - - -import { - GRAPH_CONTEXT_TOKENS_MAX, - type GraphContextData, - type GraphContextProposition, - graphContextText, -} from "./contracts/chat.ts"; -import type { UnderstandingLevel } from "./contracts/graph.ts"; -import type { GraphNode, LedgerEvent } from "./contracts/graph.ts"; - -/** Belt-and-braces count cap — the budget is the primary control; a huge - * budget still can't pour hundreds of lines into the block. */ -export const GRAPH_CONTEXT_MAX_PROPOSITIONS = 64; - -/** The block's framing (header + gloss) in estimated tokens — reserved so - * the composed block, not just the proposition lines, stays under budget. */ -const FRAMING_TOKENS = 55; - -const LEVELS: ReadonlySet = new Set([ - "solid", - "shaky", - "accepted", - "resisted", - "not yet engaged", -]); - -export function isUnderstandingLevel(word: unknown): word is UnderstandingLevel { - return typeof word === "string" && LEVELS.has(word); -} - -/** - * Fold each node's CURRENT understanding level: the level word of its most - * recent observation event (latest `at`; ledger order breaks ties — the - * same rule the snapshot's nodeObservations uses, but across ALL sessions, - * not per chat). Malformed notes (not on the scale) never count. - */ -export function currentUnderstanding(events: LedgerEvent[]): Map { - const latest = new Map(); - events.forEach((event, index) => { - if (event.kind !== "observation" || event.nodeId === null) return; - if (!isUnderstandingLevel(event.note)) return; - const current = latest.get(event.nodeId); - if (!current || event.at > current.at || (event.at === current.at && index > current.index)) { - latest.set(event.nodeId, { at: event.at, level: event.note, index }); - } - }); - const levels = new Map(); - for (const [nodeId, { level }] of latest) levels.set(nodeId, level); - return levels; -} - -/** Estimated tokens for one proposition line: chars/4 (the agreed - * heuristic — no server-side tokenizer) + one for the level tag. */ -function lineTokens(p: GraphContextProposition): number { - return Math.ceil(p.statement.length / 4) + (p.level ? 1 : 0); -} - -export interface SelectGraphContextInput { - chatId: string; - /** The topic's query embedding (same model + dim as the ledger — the - * caller asserts parity; dimension-mismatched vectors are skipped). */ - query: number[]; - /** The embeddings ledger's vectors: nodeId → truncated, L2-normalized - * (cosine = dot product). */ - vectors: Record; - /** Raw node lines (append order; first line per id defines — the - * ledger merge rule, mirrored from core/graph.ts). */ - nodes: GraphNode[]; - /** The event ledger — for the most-recent level fold. */ - events: LedgerEvent[]; - /** The session's token budget (estimated tokens, chars/4). */ - budget: number; -} - -/** - * Select the prior-knowledge block: similarity-descending greedy fill under - * the token budget, capped by count, each line annotated with its folded - * current level. Pure — server composes it with the embedder; tests drive - * it with hand-made vectors. - */ -export function selectGraphContext(input: SelectGraphContextInput): GraphContextData { - const { chatId, query, vectors, nodes, events, budget } = input; - const levels = currentUnderstanding(events); - - // Score every embedded node: dot product (vectors are normalized). A - // dimension mismatch (a stale ledger, a foreign query) never scores. - const scored: Array<{ id: string; statement: string; score: number }> = []; - const seen = new Set(); - for (const node of nodes) { - if (seen.has(node.id)) continue; // first line defines (merge rule) - seen.add(node.id); - const vec = vectors[node.id]; - if (!vec || vec.length !== query.length) continue; - let dot = 0; - for (let i = 0; i < query.length; i++) dot += query[i]! * vec[i]!; - scored.push({ id: node.id, statement: node.statement, score: dot }); - } - scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); - - // Greedy fill: similarity order decides WHICH propositions enter; the - // budget decides WHEN to stop. No similarity threshold, by design. - const propositions: GraphContextProposition[] = []; - let used = FRAMING_TOKENS; - for (const { id, statement } of scored) { - if (propositions.length >= GRAPH_CONTEXT_MAX_PROPOSITIONS) break; - const p: GraphContextProposition = { - id, - statement, - level: levels.get(id) ?? null, - }; - const cost = lineTokens(p); - if (used + cost > budget) continue; // too big for the budget — a later, smaller one may still fit - used += cost; - propositions.push(p); - } - return { chatId, budget, tokens: used, propositions }; -} - -/** True when the selection would inject anything (guards the runner: an - * empty block is never delivered — no steer, the job errors instead). */ -export function hasContext(data: GraphContextData): boolean { - return data.propositions.length > 0 && graphContextText(data).length > 0; -} - -// ─── The marker-part parser (parse-don't-cast — tool-parts doctrine) ────────── - -function parseProposition(raw: unknown): GraphContextProposition | null { - if (typeof raw !== "object" || raw === null) return null; - const { id, statement, level } = raw as Record; - if (typeof id !== "string" || typeof statement !== "string") return null; - if (level !== null && level !== undefined && !isUnderstandingLevel(level)) return null; - return { id, statement, level: (level as UnderstandingLevel | null | undefined) ?? null }; -} - -function parseGraphContextData(raw: unknown): GraphContextData | null { - if (typeof raw !== "object" || raw === null) return null; - const { chatId, budget, tokens, propositions } = raw as Record; - if (typeof chatId !== "string") return null; - if (typeof budget !== "number" || budget < 0 || budget > GRAPH_CONTEXT_TOKENS_MAX) return null; - if (typeof tokens !== "number" || tokens < 0) return null; - if (!Array.isArray(propositions)) return null; - const parsed: GraphContextProposition[] = []; - for (const item of propositions) { - const p = parseProposition(item); - if (!p) return null; // malformed content: the whole part is untrusted - parsed.push(p); - } - return { chatId, budget, tokens, propositions: parsed }; -} - -/** - * The data-graph-context part of a message, validated — or null when the - * part is absent or malformed. The seam the adapter and the chat view read - * the marker through (never a raw cast: the part's data crossed a - * serialization boundary). - */ -export function graphContextOfMessage( - message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }, -): GraphContextData | null { - for (const part of message.parts) { - if (part.type === "data-graph-context") { - return parseGraphContextData((part as { data?: unknown }).data); - } - } - return null; -} \ No newline at end of file diff --git a/src/core/graph.ts b/src/core/graph.ts index c5a780d..a858839 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -399,11 +399,10 @@ const QUIZ_EVENT_KINDS: ReadonlySet = new Set(["correct", "miss", "dont- /** * The latest quiz outcome per node — a raw EVENT FACT, not a node state * (the transitional node-state classes were removed 2026-09-08: nothing - * colors nodes by quiz results anymore). Used by the teacher's - * graph-as-context selection (graph-context.ts) to find diagnostic - * hotspots; the home renderer does not consume it. Nodes with no quiz - * events have no entry. (The graph-as-context consumer is shelved — - * docs/probe-teach-split.md §5 — but the event fact stays.) + * colors nodes by quiz results anymore). Its last consumer, the + * graph-as-context selection, died in wave 7 (the injection is gone — + * the prober reads its map through tools now); the event fact stays as + * raw ledger truth. Nodes with no quiz events have no entry. */ export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map { const latest = new Map(); diff --git a/src/core/index.ts b/src/core/index.ts index 3a01cb1..f5c0d34 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -55,4 +55,3 @@ export * from "./auth-keys.ts"; // keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe). export * from "./keyboard.ts"; export * from "./coverage.ts"; -export * from "./graph-context.ts"; diff --git a/src/core/ports.ts b/src/core/ports.ts index 4d104ad..c43c63e 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -130,11 +130,10 @@ export interface GraphStore { /** The embeddings ledger (config.EMBEDDINGS_FILE) — INERT DATA since * wave 7 (owner ruling 2026-09-23: the provider embedder is dead; - * clustering is birth-session, labels are session names). Kept ONLY - * because the graph-context injection (agent band, Teacher's pass - * removes it) still reads it for vector-space staleness — the seam - * dies with it in the follow-up interlude. graph-deletion keeps - * pruning dead vectors meanwhile (harmless housekeeping). */ + * clustering is birth-session, labels are session names; the + * graph-context injection is gone with it). The whole seam dies in + * the follow-up interlude; graph-deletion's vector pruning is + * harmless housekeeping meanwhile. */ readEmbeddings(): EmbeddingsLedger; /** Replace the whole ledger (graph-deletion's vector pruning only). */ writeEmbeddings(ledger: EmbeddingsLedger): void; diff --git a/src/core/semantic-clusters.ts b/src/core/semantic-clusters.ts deleted file mode 100644 index 7772c60..0000000 --- a/src/core/semantic-clusters.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Semantic topic clustering — the REPLACEMENT for Louvain community -// detection (DECIDED 2026-09-13): structural clusters were just the -// reasoning chains (the graph is disconnected by nature — every connected -// component became its own "cluster"), and edge-orphans could never join -// anything. Clusters are now RELATED TOPICS: nodes whose proposition -// MEANING is close, computed over the embedding ledger -// (config.EMBEDDINGS_FILE). This pulls orphans into topics by meaning -// and groups small chains with the large chains they discuss. -// -// Algorithm: threshold AGGLOMERATIVE (average linkage over cosine -// similarity). No JS HDBSCAN is trustworthy (the npm package is an -// abandoned alpha), and at proposition scale (~100s-1000s of short texts) -// brute-force pairwise cosine is sub-second — so the honest ~60-line -// hand-roll wins. "Stragglers" (nodes that fit nothing) fall out -// naturally: they simply never merge, and stay singletons. - -/** The default similarity threshold, SWEPT ON THE REAL GRAPH - * (2026-09-13): 0.60 → 26 topics of ~20 propositions, 74 of the 90 - * edge-orphans land in real topics, stragglers 10. Higher (0.65-0.68) - * gives tighter micro-topics with far more stragglers; lower (0.55) - * collapses into ~13 mega-topics. */ -export const SEMANTIC_SIM_THRESHOLD = 0.6; - -/** Cosine similarity of two vectors (defensively normalizes inputs; - * ledger vectors are already L2-normalized, so this is a dot product). */ -export function cosineSimilarity(a: readonly number[], b: readonly number[]): number { - let dot = 0; - let ma = 0; - let mb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i]! * b[i]!; - ma += a[i]! * a[i]!; - mb += b[i]! * b[i]!; - } - if (ma === 0 || mb === 0) return 0; - return dot / (Math.sqrt(ma) * Math.sqrt(mb)); -} - -export interface SemanticClusterOptions { - /** Merge while the best cluster pair's average-linkage similarity is - * >= this (default SEMANTIC_SIM_THRESHOLD). */ - similarityThreshold?: number; - /** After the cut, force every singleton into its nearest topic - * ("all-in" mode) instead of leaving stragglers loose. Default - * false: some propositions genuinely fit nothing. */ - attachStragglers?: boolean; -} - -/** Average-linkage agglomerative clustering over embedding vectors. - * Pure and deterministic (stable Map iteration, greedy by best - * similarity, first-found wins ties). Returns clusters size-desc — the - * same { members } shape Louvain produced, so label matching and the - * renderer contract are unchanged. An empty/missing ledger returns [] - * (nothing embedded yet: no clusters, every node loose — the snapshot - * degrades gracefully until the first embed). */ -export function semanticClusters( - vectors: Readonly>, - options: SemanticClusterOptions = {}, -): Array<{ members: string[] }> { - const ids = Object.keys(vectors); - if (ids.length === 0) return []; - const simThreshold = options.similarityThreshold ?? SEMANTIC_SIM_THRESHOLD; - const attach = options.attachStragglers ?? false; - - // Sum vectors per cluster: average-linkage similarity between clusters - // A and B is dot(sumA, sumB) / (|A|·|B|) — the exact mean of pairwise - // dots — so the merge loop never revisits the raw O(n²) matrix. - const sums = new Map(); - const members = new Map(); - ids.forEach((id, i) => { - sums.set(i, [...vectors[id]!]); - members.set(i, [id]); - }); - - // Greedy merge: repeatedly join the pair with the highest - // average-linkage similarity until it drops below the threshold. - // O(k²) pair scan per merge — trivial at proposition scale. - for (;;) { - let bestA = -1; - let bestB = -1; - let bestSim = -Infinity; - for (const [a, sumA] of sums) { - for (const [b, sumB] of sums) { - if (a >= b) continue; - let dot = 0; - for (let i = 0; i < sumA.length; i++) dot += sumA[i]! * sumB[i]!; - const sim = dot / (members.get(a)!.length * members.get(b)!.length); - if (sim > bestSim) { - bestSim = sim; - bestA = a; - bestB = b; - } - } - } - if (bestA < 0 || bestSim < simThreshold) break; - // Merge B into A (deterministic direction: lower cluster index). - const sumA = sums.get(bestA)!; - const sumB = sums.get(bestB)!; - for (let i = 0; i < sumA.length; i++) sumA[i] = sumA[i]! + sumB[i]!; - members.get(bestA)!.push(...members.get(bestB)!); - sums.delete(bestB); - members.delete(bestB); - } - - // Optional all-in pass: singletons join their nearest topic regardless - // of threshold — exactly the "forced membership" mode. The difference - // from stragglers mode is this one pass. Singleton-vs-cluster - // average-linkage similarity is dot(sumS, sumK) / |K|. - if (attach) { - const singletons = [...members.entries()].filter(([, m]) => m.length === 1).map(([k]) => k); - for (const s of singletons) { - if (!members.has(s)) continue; // absorbed by an earlier attach - let bestTarget = -1; - let bestSim = -Infinity; - for (const [k, m] of members) { - if (k === s || m.length < 2) continue; - let dot = 0; - const sumS = sums.get(s)!; - const sumK = sums.get(k)!; - for (let i = 0; i < sumS.length; i++) dot += sumS[i]! * sumK[i]!; - const sim = dot / m.length; - if (sim > bestSim) { - bestSim = sim; - bestTarget = k; - } - } - if (bestTarget >= 0) { - const sumT = sums.get(bestTarget)!; - const sumS = sums.get(s)!; - for (let i = 0; i < sumT.length; i++) sumT[i] = sumT[i]! + sumS[i]!; - members.get(bestTarget)!.push(...members.get(s)!); - sums.delete(s); - members.delete(s); - } - } - } - - return [...members.values()] - .map((m) => ({ members: [...m].sort() })) - .sort((a, b) => b.members.length - a.members.length || (a.members[0] ?? "").localeCompare(b.members[0] ?? "")); -} diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index 9fd2a36..48c6487 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -1,7 +1,6 @@ import type { UIMessage } from "ai"; import type { DemoToolInput, - GraphContextData, PdfToolInput, PlanVerdictData, PlanBlockData, @@ -68,10 +67,6 @@ export type TeacherUIDataParts = { /** The probe→teach handoff wake: the wake pipeline's steer message, * carrying ONLY this part (the model-facing text lives in the adapter). */ "probe-handoff": { chatId: string }; - /** The prior-knowledge injection's delivery (vision-context-injection.md): - * the marker-only steer — the counts for the UI, the full block content - * for the audit trail; the adapter flattens it to graphContextText. */ - "graph-context": GraphContextData; /** The abort steer's message-type marker (the abort badge renders off it). */ "subagent-aborted": SubagentResultMeta & { aborted: true }; }; diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 2dda810..b207b30 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -12,6 +12,7 @@ import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; import { extractionEnabled, + graphToolsEnabled, resolveConversationSpec, resolveRoleSpec, } from "../core/settings-resolution.ts"; @@ -274,6 +275,10 @@ export async function handleChatRequest( jobStore: stores.jobStore, spawn: (job, input) => void jobRunner.runJob(job.id, input), searchAvailable, + // The map reader (wave 7): the prober reads the learner's + // knowledge graph through tools when the gate opens — + // graph master switch AND the agent.graphTools toggle. + ...(graphToolsEnabled(effective) ? { graphStore: stores.graphStore } : {}), }, agentOpts, ); diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 9c4177a..ab44958 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -34,7 +34,6 @@ import type { UIMessage } from "ai"; import { buildAbortSteer, - buildGraphContextSteer, buildSubagentSteer, isSubagentFailure, steerDelivered, @@ -61,9 +60,6 @@ interface QueuedSteer { interface WakeState { turnRunner?: TurnRunner; respawn?: JobRespawn; - /** Crash recovery for context-injection jobs (they are not sub-agent - * loops — their runner takes the graph deps; chat-handler wires it). */ - contextRespawn?: (jobId: string) => void; /** chatId → steers awaiting delivery, in arrival order. */ queues: Map; /** Chats whose pump loop is currently active (serialization per chat). */ @@ -91,13 +87,6 @@ export function setJobRespawn(respawn: JobRespawn): void { state().respawn = respawn; } -/** chat-handler wires this at module init: the graph-context runner - * (subagents/graph-context.ts runGraphContextJob — re-runnable from the - * job record alone, which is all crash recovery has). */ -export function setContextRespawn(respawn: (jobId: string) => void): void { - state().contextRespawn = respawn; -} - const POLL_MS = 400; /** After the gates open, wait a beat before firing — a client that just * answered a question is about to send its continuation; don't 409 it. @@ -124,11 +113,7 @@ function delay(ms: number): Promise { /** The job a steer belongs to, if it is a job steer (result/abort/context); * probe-handoff steers have no job — null. */ function jobIdOf(steer: SteerMessage): string | null { - const meta = steer.metadata as { - subagentResult?: { jobId: string }; - graphContext?: { jobId: string }; - }; - return meta.subagentResult?.jobId ?? meta.graphContext?.jobId ?? null; + return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null; } /** Finalize a delivered steer's job bookkeeping — job steers only. */ @@ -250,12 +235,6 @@ export function abortJob(jobId: string): AbortOutcome { if (!job) return { ok: false, reason: "not_found" }; if (job.status !== "running") return { ok: false, reason: "not_running" }; - // Context-injection jobs abort SILENTLY: nothing was ever promised in the - // lesson (server-initiated scaffolding, unlike a teacher-dispatched - // sub-agent), so no abort steer — flipping the record and killing the - // run is the whole story. Steps 1–3 below are shared; step 4 is skipped. - const isContext = job.kind === "graph-context"; - fireJobAbort(jobId); // no-op when the loop already returned (delivering) // Drop any queued steers for this job (their deliver() resolves unfired). @@ -270,22 +249,17 @@ export function abortJob(jobId: string): AbortOutcome { } stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" }); - if (!isContext) { - void deliverJobResult(buildAbortSteer(job)).catch(() => { - /* deliverJobResult marks the job error on unrecoverable failure */ - }); - } + void deliverJobResult(buildAbortSteer(job)).catch(() => { + /* deliverJobResult marks the job error on unrecoverable failure */ + }); return { ok: true }; } function chatIdOf(steer: SteerMessage): string { - // The probe handoff and the context injection carry their chat directly - // (the context steer's job record would say it too, but the metadata is - // the cheap path — same rule as the handoff). + // The probe handoff carries its chat directly (the job record would + // say it too, but the metadata is the cheap path). const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff; if (handoff) return handoff.chatId; - const injected = (steer.metadata as { graphContext?: { chatId: string } }).graphContext; - if (injected) return injected.chatId; // The chat is resolvable from the job record; the steer's metadata // carries jobId, which indexes the store. const jobId = jobIdOf(steer); @@ -394,12 +368,6 @@ async function pump(chatId: string): Promise { function finalizeJob(jobId: string): void { const job = stores.jobStore.getJob(jobId); if (!job || job.status !== "running") return; - // Context-injection jobs have no sub-agent result shape — a delivered - // block is a success, full stop (isSubagentFailure would misread it). - if (job.kind === "graph-context") { - stores.jobStore.updateJob(jobId, { status: "done", activity: "delivered" }); - return; - } const result = job.result; const failed = result ? isSubagentFailure(job.kind, result as never) : true; stores.jobStore.updateJob(jobId, { @@ -474,23 +442,6 @@ export function recoverStaleJobs(): void { if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it if (!stores.chatStore.getChat(job.chatId)) continue; if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now - if (job.kind === "graph-context") { - // Context-injection orphans: a stored block re-delivers in its own - // steer shape; a job that died before selecting re-runs from the - // record (the graph runner — not the sub-agent respawn seam). - if (job.result) { - stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" }); - void deliverJobResult( - buildGraphContextSteer({ chatId: job.chatId, jobId: job.id }, job.result as never), - ).catch(() => { - /* deliverJobResult marks the job error on failure */ - }); - } else if (s.contextRespawn) { - stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" }); - s.contextRespawn(job.id); - } - continue; - } if (job.result) { // The wake never fired (or crashed mid-flight): re-deliver. const steer = buildSubagentSteer(job, job.result as never); diff --git a/src/server/pi/record-adapter.ts b/src/server/pi/record-adapter.ts index f2b193e..8308012 100644 --- a/src/server/pi/record-adapter.ts +++ b/src/server/pi/record-adapter.ts @@ -1,8 +1,7 @@ import type { UIMessage } from "ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai"; -import { graphContextOfMessage } from "../../core/graph-context.ts"; -import { graphContextText, HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts"; +import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts"; /** * The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The @@ -62,13 +61,6 @@ function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unk return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true); } if (part.type === "data-probe-handoff") return HANDOFF_TEXT; - // Graph context injection: the block text derives from the part's - // validated data (parse-don't-cast — a malformed part has no model - // face, and the message then never reaches the provider). - if (part.type === "data-graph-context") { - const data = graphContextOfMessage({ parts: message.parts }); - return data ? graphContextText(data) : null; - } } return null; } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 2156c5c..f24df8b 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -12,7 +12,7 @@ */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { GRAPH_CONTEXT_TOKENS_MAX, isHexColor, type ChatSettings } from "../core"; +import { isHexColor, type ChatSettings } from "../core"; import type { AgentSettings, AppearanceSettings, @@ -37,7 +37,6 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult GRAPH_CONTEXT_TOKENS_MAX - ) { - return { - ok: false, - error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}`, - }; - } - settings.graphContextTokens = raw.graphContextTokens; - } + // Wave 7 killed the graph-context injection (owner: zero configuration + // beyond chat models — the prober reads its map through tools now); its + // budget dial is gone with it. A stale client sending the old key has + // it silently dropped here (forward-compatible, like any unknown key). return { ok: true, settings }; } diff --git a/tests/default-settings.test.ts b/tests/default-settings.test.ts index a2e8c0e..264a6c4 100644 --- a/tests/default-settings.test.ts +++ b/tests/default-settings.test.ts @@ -10,7 +10,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { GRAPH_CONTEXT_TOKENS_MAX } from "../src/core"; import { parseSettingsBody } from "../src/server/settings-body.ts"; describe("parseSettingsBody (shared by both settings routes)", () => { @@ -24,13 +23,11 @@ describe("parseSettingsBody (shared by both settings routes)", () => { const r = parseSettingsBody({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "high", - graphContextTokens: 256, }); assert.ok(r.ok); assert.deepEqual(r.settings, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "high", - graphContextTokens: 256, }); }); @@ -54,12 +51,4 @@ describe("parseSettingsBody (shared by both settings routes)", () => { assert.equal(parseSettingsBody({ thinkingLevel: 7 }).ok, false); }); - it("rejects out-of-range or non-integer graphContextTokens — never clamps", () => { - for (const bad of [-1, GRAPH_CONTEXT_TOKENS_MAX + 1, 64.5, "256"]) { - const r = parseSettingsBody({ graphContextTokens: bad }); - assert.equal(r.ok, false, String(bad)); - } - const ok = parseSettingsBody({ graphContextTokens: GRAPH_CONTEXT_TOKENS_MAX }); - assert.equal(ok.ok, true); - }); }); diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 05a29c3..ee6942c 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -43,7 +43,6 @@ import type { GraphStore } from "../src/core/ports.ts"; import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts"; import { ModelsNotConfiguredError } from "../src/core/settings.ts"; import { batchTag, coverageOf, coverageSlots } from "../src/core/coverage.ts"; -import { semanticClusters } from "../src/core/semantic-clusters.ts"; import { chatStore } from "../src/store/chats.ts"; import { PATHS } from "../config.ts"; @@ -839,9 +838,11 @@ const NOTES_VERDICT = { dropped: [], } as const; -/** Test seam for the embedder: fakes the post-merge embedding batch — - * one identical unit vector per node → semanticClusters merges them all - * into ONE topic — then persists the partition like the real embedder. */ +/** Test seam for the embedder: semantic clustering died with wave 7 + * (birth-session clusters + session-name labels), so the production + * embedder is a no-op stub pending Platform's ledger-port removal — + * this fake mirrors it: one identical vector per node persisted to the + * (still-live) embeddings ledger port, no clusters computed. */ const fakeEmbedder = async (store: GraphStore) => { const seen = new Set(); const ids: string[] = []; @@ -850,12 +851,11 @@ const fakeEmbedder = async (store: GraphStore) => { seen.add(n.id); ids.push(n.id); } - const vectors = Object.fromEntries(ids.map((id) => [id, [1, 0, 0]])); store.writeEmbeddings({ model: "test/embedder", dim: 3, - vectors, - clusters: semanticClusters(vectors), + vectors: Object.fromEntries(ids.map((id) => [id, [1, 0, 0]])), + clusters: [], updatedAt: "2026-09-13T00:00:00.000Z", }); }; diff --git a/tests/graph-context-core.test.ts b/tests/graph-context-core.test.ts deleted file mode 100644 index 611e0d4..0000000 --- a/tests/graph-context-core.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Graph context injection — the pure core (docs/vision-context-injection.md): - * selection doctrine, level fold, block text, the marker-part parser, and - * the runner's pure gates (topic/budget/one-shot). Deterministic, zero - * network, zero stores — hand-made vectors. - */ - -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { UIMessage } from "ai"; -import { - buildGraphContextSteer, - steerDelivered, - GRAPH_CONTEXT_TOKENS_DEFAULT, - GRAPH_CONTEXT_TOKENS_MAX, - GRAPH_CONTEXT_TOKENS_STOPS, - graphContextText, - type GraphContextData, -} from "../src/core/contracts.ts"; -import { - currentUnderstanding, - GRAPH_CONTEXT_MAX_PROPOSITIONS, - graphContextOfMessage, - hasContext, - isUnderstandingLevel, - selectGraphContext, -} from "../src/core/graph-context.ts"; -import { - contextToolCallId, - effectiveContextBudget, - topicOf, -} from "../src/agent/subagents/graph-context.ts"; -import type { GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts"; - -function unit(i: number): number[] { - const v = new Array(8).fill(0); - v[i] = 1; - return v; -} - -function node(id: string, statement: string): GraphNode { - return { - id, - name: id, - statement, - aliases: [], - provenance: [], - createdAt: "2026-09-14T00:00:00.000Z", - }; -} - -function obs(nodeId: string, level: string, at: string, chatId = "chat_x"): LedgerEvent { - return { nodeId, kind: "observation", chatId, at, note: level }; -} - -/** A text-bearing user message (the learner's typed speech). */ -function userText(text: string): UIMessage { - return { id: `m_${text.length}`, role: "user", parts: [{ type: "text", text }] }; -} - -/** A marker-only user message (steer scaffolding — no text). */ -function marker(type: string, data: unknown): UIMessage { - return { id: `m_${type}`, role: "user", parts: [{ type, data } as never] }; -} - -describe("currentUnderstanding — the most-recent fold", () => { - it("latest observation wins, across chats, ledger order breaks ties", () => { - const events = [ - obs("n1", "shaky", "2026-09-01T00:00:00Z", "chat_a"), - obs("n1", "solid", "2026-09-10T00:00:00Z", "chat_b"), - obs("n2", "resisted", "2026-09-05T00:00:00Z"), - obs("n2", "accepted", "2026-09-05T00:00:00Z"), // same at → later line wins - ]; - const levels = currentUnderstanding(events); - assert.equal(levels.get("n1"), "solid"); - assert.equal(levels.get("n2"), "accepted"); - assert.equal(levels.size, 2); - }); - - it("off-scale notes never count (the closed list is the only vocabulary)", () => { - assert.equal(isUnderstandingLevel("solid"), true); - assert.equal(isUnderstandingLevel("pretty good"), false); - const levels = currentUnderstanding([obs("n1", "confident", "2026-09-01T00:00:00Z")]); - assert.equal(levels.size, 0); - }); - - it("quiz outcome events and null nodes are not observations", () => { - const events: LedgerEvent[] = [ - { nodeId: "n1", kind: "correct", chatId: "c", at: "2026-09-01T00:00:00Z" }, - { nodeId: null, kind: "observation", chatId: "c", at: "2026-09-01T00:00:00Z", note: "solid" }, - ]; - assert.equal(currentUnderstanding(events).size, 0); - }); -}); - -describe("selectGraphContext — similarity orders, the budget is the only amount control", () => { - const base = { - chatId: "chat_1", - query: unit(0), - events: [] as LedgerEvent[], - }; - - it("fills similarity-descending and stops before exceeding the budget", () => { - const vectors = { a: unit(0), b: unit(0), c: unit(7) }; - const nodes = [ - node("a", "Water boils at 100°C at sea level."), - node("b", "Evaporation cools the surrounding air."), - node("c", "The Schrödinger equation is linear."), - ]; - // budget 55 (framing) + ~9 tokens for statement a ≈ fits exactly one line. - const data = selectGraphContext({ ...base, vectors, nodes, budget: 66 }); - assert.deepEqual( - data.propositions.map((p) => p.id), - ["a"], - ); - assert.equal(data.tokens <= 66, true); - assert.equal(data.budget, 66); - }); - - it("an oversized line is skipped, a later smaller one may still fit (no threshold, pure budget)", () => { - const vectors = { big: unit(0), small: unit(0), off: unit(7) }; - const big = "X".repeat(400); // ~100 tokens — bigger than the whole budget - const nodes = [ - node("big", big), - node("small", "Ice melts."), - node("off", "Irrelevant."), - ]; - // budget 60: 'big' (~100 tokens) is skipped outright; 'small' (4 tokens - // incl. the level slot + framing 55 = 59) fits; 'off' (63) doesn't. - const data = selectGraphContext({ ...base, vectors, nodes, budget: 60 }); - assert.deepEqual( - data.propositions.map((p) => p.id), - ["small"], - ); - }); - - it("annotates each line with the folded current level (null when unobserved)", () => { - const vectors = { a: unit(0), b: unit(0) }; - const nodes = [node("a", "A."), node("b", "B.")]; - const events = [obs("b", "shaky", "2026-09-10T00:00:00Z")]; - const data = selectGraphContext({ ...base, vectors, nodes, events, budget: 4096 }); - const byId = Object.fromEntries(data.propositions.map((p) => [p.id, p])); - assert.equal(byId.a!.level, null); - assert.equal(byId.b!.level, "shaky"); - }); - - it("the count cap bounds even a huge budget", () => { - const vectors: Record = {}; - const nodes: GraphNode[] = []; - for (let i = 0; i < GRAPH_CONTEXT_MAX_PROPOSITIONS + 10; i++) { - const id = `n${i}`; - vectors[id] = unit(0); - nodes.push(node(id, `Statement ${i}.`)); - } - const data = selectGraphContext({ ...base, vectors, nodes, budget: GRAPH_CONTEXT_TOKENS_MAX }); - assert.equal(data.propositions.length, GRAPH_CONTEXT_MAX_PROPOSITIONS); - }); - - it("dimension-mismatched vectors and duplicate node lines are handled", () => { - const vectors = { ok: unit(0), stale: [1, 2, 3] }; - const nodes = [ - node("ok", "Fine."), - node("dup", "First line."), - node("dup", "Second line never redefines."), - ]; - // "dup" has no vector anyway; the pin is that the scan neither throws - // nor scores the stale-dim vector. - const data = selectGraphContext({ ...base, vectors, nodes, budget: 4096 }); - assert.deepEqual( - data.propositions.map((p) => p.id), - ["ok"], - ); - }); -}); - -describe("graphContextText + hasContext — the canonical block", () => { - it("carries the counts in the header, the level tag per line, the gloss at the end", () => { - const data: GraphContextData = { - chatId: "c1", - budget: 1024, - tokens: 42, - propositions: [ - { id: "n1", statement: "Water boils at 100°C.", level: "solid" }, - { id: "n2", statement: "Humidity slows evaporation.", level: null }, - ], - }; - const text = graphContextText(data); - assert.match(text, /2 propositions, ~42 tokens/); - assert.match(text, /- \[solid\] Water boils at 100°C\./); - assert.match(text, /- Humidity slows evaporation\./); - assert.match(text, /Do not re-teach solid ground\./); - }); - - it("an empty selection has no text and no context", () => { - const empty: GraphContextData = { chatId: "c1", budget: 1024, tokens: 55, propositions: [] }; - assert.equal(graphContextText(empty), ""); - assert.equal(hasContext(empty), false); - }); -}); - -describe("graphContextOfMessage — parse-don't-cast at the marker seam", () => { - it("round-trips a well-formed part", () => { - const msg = marker("data-graph-context", { - chatId: "c1", - budget: 512, - tokens: 60, - propositions: [{ id: "n1", statement: "S.", level: "shaky" }], - }); - const data = graphContextOfMessage(msg); - assert.equal(data?.chatId, "c1"); - assert.equal(data?.propositions[0]?.level, "shaky"); - }); - - it("rejects malformed shapes wholesale (bad level, bad budget, non-array)", () => { - assert.equal(graphContextOfMessage(marker("data-graph-context", { chatId: "c" })), null); - assert.equal( - graphContextOfMessage( - marker("data-graph-context", { - chatId: "c", - budget: 512, - tokens: 1, - propositions: [{ id: "n", statement: "s", level: "kinda" }], - }), - ), - null, - ); - assert.equal( - graphContextOfMessage( - marker("data-graph-context", { chatId: "c", budget: 999999, tokens: 1, propositions: [] }), - ), - null, - ); - }); - - it("absent part → null", () => { - assert.equal(graphContextOfMessage(userText("hello")), null); - }); -}); - -describe("buildGraphContextSteer — the marker-only delivery", () => { - it("id is deterministic per chat; metadata carries chat + job; one part", () => { - const data: GraphContextData = { - chatId: "chat_9", - budget: 1024, - tokens: 10, - propositions: [{ id: "n1", statement: "S.", level: null }], - }; - const steer = buildGraphContextSteer({ chatId: "chat_9", jobId: "job_1" }, data); - assert.equal(steer.id, "steer_ctx_chat_9"); - assert.equal(steer.role, "user"); - assert.deepEqual(steer.metadata, { graphContext: { chatId: "chat_9", jobId: "job_1" } }); - assert.equal(steer.parts.length, 1); - assert.equal(steer.parts[0]!.type, "data-graph-context"); - // steerDelivered keys on the id — the one-shot guard. - assert.equal(steerDelivered([steer], "steer_ctx_chat_9"), true); - }); -}); - -describe("the runner's pure gates", () => { - it("topicOf: exactly one typed message is the topic; steers and answers never count", () => { - assert.equal(topicOf([userText("the water cycle")]), "the water cycle"); - assert.equal(topicOf([]), null); - assert.equal(topicOf([userText("a"), userText("b")]), null); - // A quiz answer: tool-output part, no text — probing never breaks the gate. - const answer: UIMessage = { - id: "m_ans", - role: "user", - parts: [{ type: "tool-quiz", toolCallId: "t1", state: "output-available", input: {}, output: {} } as never], - }; - assert.equal(topicOf([userText("topic"), answer, marker("data-probe-handoff", { chatId: "c" })]), "topic"); - // Multi-part text joins into the topic. - assert.equal(topicOf([userText("a"), userText("")]), "a"); - }); - - it("effectiveContextBudget: absent = the default (on), 0 = off, set = set", () => { - assert.equal(effectiveContextBudget(undefined), GRAPH_CONTEXT_TOKENS_DEFAULT); - assert.equal(effectiveContextBudget({}), GRAPH_CONTEXT_TOKENS_DEFAULT); - assert.equal(effectiveContextBudget({ graphContextTokens: 0 }), 0); - assert.equal(effectiveContextBudget({ graphContextTokens: 2048 }), 2048); - }); - - it("the decided knobs: default 1024, max 16384, discrete powers-of-four stops from 64", () => { - assert.equal(GRAPH_CONTEXT_TOKENS_DEFAULT, 1024); - assert.equal(GRAPH_CONTEXT_TOKENS_MAX, 16384); - // The scale revision (owner, 2026-09-15): off then powers of four — - // every stop a real decision, no fine-grained dial theater. - assert.deepEqual([...GRAPH_CONTEXT_TOKENS_STOPS], [0, 64, 256, 1024, 4096, 16384]); - }); - - it("contextToolCallId is deterministic per chat (the one-shot dedup key)", () => { - assert.equal(contextToolCallId("chat_abc"), "ctx_chat_abc"); - }); -}); \ No newline at end of file diff --git a/tests/graph-context-job.test.ts b/tests/graph-context-job.test.ts deleted file mode 100644 index a11857e..0000000 --- a/tests/graph-context-job.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Graph context injection — the runner + the wake integration - * (docs/vision-context-injection.md). The trigger gates, the failure - * modes, the one happy delivery (real wake pipeline, fake turn runner, - * scripted embedder), and the SILENT abort (the decided contrast with - * teacher-dispatched sub-agents, which abort with a steer). - * - * Cleanup follows tests/jobs.test.ts: files removed in finally/after. - */ - -import assert from "node:assert/strict"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { after, describe, it } from "node:test"; -import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts"; -import { abortJob } from "../src/server/jobs.ts"; -import { persistTurn } from "../src/server/turn-service.ts"; -import { startGraphContextInjection, runGraphContextJob, type GraphContextDeps, type GraphContextEmbedder } from "../src/agent/subagents/graph-context.ts"; -import { chatStore } from "../src/store/chats.ts"; -import { jobStore } from "../src/store/jobs.ts"; -import { PATHS } from "../config.ts"; -import type { EmbeddingsLedger, GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts"; -import type { JobRecord, SteerMessage } from "../src/core/contracts.ts"; - -const createdChats: string[] = []; -const createdJobs: string[] = []; - -function trackChat(id: string) { - createdChats.push(id); -} -function trackJob(id: string) { - createdJobs.push(id); -} - -after(() => { - for (const id of createdChats) { - try { - fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); - } catch { - /* already gone */ - } - } - for (const id of createdJobs) { - try { - fs.unlinkSync(path.join(PATHS.data, "jobs", `${id}.json`)); - } catch { - /* already gone */ - } - } -}); - -function waitFor(cond: () => boolean, timeoutMs = 8000, what = "condition"): Promise { - return new Promise((resolve, reject) => { - const started = Date.now(); - const tick = () => { - if (cond()) return resolve(); - if (Date.now() - started > timeoutMs) return reject(new Error(`timeout waiting for ${what}`)); - setTimeout(tick, 25); - }; - tick(); - }); -} - -function unit(i: number): number[] { - const v = new Array(8).fill(0); - v[i] = 1; - return v; -} - -function node(id: string, statement: string): GraphNode { - return { id, name: id, statement, aliases: [], provenance: [], createdAt: "2026-09-14T00:00:00.000Z" }; -} - -/** A minimal in-memory graph store — the runner only reads. */ -function fakeGraphStore(opts: { - vectors?: Record; - nodes?: GraphNode[]; - events?: LedgerEvent[]; - model?: string; - dim?: number; -}): Pick { - const ledger: EmbeddingsLedger = { - model: opts.model ?? "test-embed", - dim: opts.dim ?? 8, - vectors: opts.vectors ?? {}, - clusters: [], - updatedAt: "2026-09-14T00:00:00.000Z", - }; - return { - readNodes: () => opts.nodes ?? [], - readEvents: () => opts.events ?? [], - readEmbeddings: () => ledger, - }; -} - -/** A chat at session start: exactly one typed message (the topic). */ -function newTopicChat(label: string, settings?: Record): { id: string } { - const chat = chatStore.createChat(`Test: ${label}`); - trackChat(chat.id); - chatStore.saveMessages(chat.id, [ - { id: "u1", role: "user", parts: [{ type: "text", text: "the water cycle" }] }, - ] as never); - if (settings) chatStore.saveSettings(chat.id, settings as never); - return { id: chat.id }; -} - -/** The scripted embedder: deterministic query vectors, recorded topics. - * Wave 6 shape: the query carries its (absent) prompt-token count; the - * resolver seam wraps the static fake (fresh resolution per run). */ -function fakeEmbedder(vec: number[] = unit(0)) { - const topics: string[] = []; - return { - topics, - embedder: { - model: "test-embed", - embedQuery: async (text: string) => { - topics.push(text); - return { vector: vec, promptTokens: undefined }; - }, - }, - }; -} - -function deps( - graph: Pick, - embedder: GraphContextEmbedder, - deliver: (steer: SteerMessage) => Promise, -): GraphContextDeps { - return { chatStore, jobStore, graphStore: graph, embedder: () => embedder, deliver }; -} - -describe("startGraphContextInjection — the one-shot gates", () => { - it("starts at session start: job kind graph-context, deterministic toolCallId, runner runs to delivery", async () => { - const { id: chatId } = newTopicChat("injection happy gate"); - const graph = fakeGraphStore({ - vectors: { n1: unit(0) }, - nodes: [node("n1", "Water evaporates from oceans.")], - }); - const { embedder, topics } = fakeEmbedder(); - const delivered: SteerMessage[] = []; - const d = deps(graph, embedder, async (steer) => { - delivered.push(steer); - }); - - const job = startGraphContextInjection(d, chatId); - assert.ok(job, "a chat at session start with budget on starts the injection"); - trackJob(job.id); - assert.equal(job.kind, "graph-context"); - assert.equal(job.toolCallId, `ctx_${chatId}`); - - await waitFor(() => delivered.length > 0, 8000, "delivery"); - assert.deepEqual(topics, ["the water cycle"], "the topic is the session's only typed message"); - const steer = delivered[0]!; - assert.equal(steer.id, `steer_ctx_${chatId}`); - assert.equal(steer.parts.length, 1); - assert.equal(steer.parts[0]!.type, "data-graph-context"); - - // One shot: a second trigger does nothing (job exists). - assert.equal(startGraphContextInjection(d, chatId), null); - }); - - it("budget 0 (off) never starts; a session past its start never injects", async () => { - const { id: off } = newTopicChat("injection off", { graphContextTokens: 0 }); - const { id: rolling } = (() => { - const chat = chatStore.createChat("Test: two typed messages"); - trackChat(chat.id); - chatStore.saveMessages(chat.id, [ - { id: "u1", role: "user", parts: [{ type: "text", text: "topic" }] }, - { id: "u2", role: "user", parts: [{ type: "text", text: "a second message" }] }, - ] as never); - return { id: chat.id }; - })(); - const graph = fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }); - const d = deps(graph, fakeEmbedder().embedder, async () => {}); - - assert.equal(startGraphContextInjection(d, off), null, "0 budget = off"); - assert.equal(startGraphContextInjection(d, rolling), null, "no retroactive injection"); - assert.equal(jobStore.listJobs(off).length, 0); - assert.equal(jobStore.listJobs(rolling).length, 0); - assert.equal(startGraphContextInjection(d, "chat_missing"), null, "unknown chat"); - }); - - it("an ERRORED context job still blocks re-injection (one shot means one shot)", async () => { - const { id: chatId } = newTopicChat("injection error one-shot"); - const graph = fakeGraphStore({ vectors: {} }); // empty ledger → the run errors - const d = deps(graph, fakeEmbedder().embedder, async () => {}); - const job = startGraphContextInjection(d, chatId); - assert.ok(job); - trackJob(job.id); - await waitFor(() => jobStore.getJob(job.id)!.status === "error", 8000, "job errors"); - assert.match(jobStore.getJob(job.id)!.error ?? "", /no embeddings yet/); - // Re-trigger with a now-populated ledger: still nothing. - const populated = deps( - fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }), - fakeEmbedder().embedder, - async () => {}, - ); - assert.equal(startGraphContextInjection(populated, chatId), null); - }); -}); - -describe("runGraphContextJob — failure modes are honest errors, never phantom deliveries", () => { - it("a stale ledger (model/dim mismatch) errors with the parity reason", async () => { - const { id: chatId } = newTopicChat("injection stale ledger"); - const chat = chatStore.createChat(`Test: ${chatId}`); // placeholder to satisfy lint-free compile - trackChat(chat.id); - const job = jobStore.createJob({ kind: "graph-context", chatId, toolCallId: `ctx_${chatId}` }); - trackJob(job.id); - // Ledger stamped with a DIFFERENT model than the embedder (wave 6: - // staleness is the model identity — dim is checked against the - // served vector below). - const d = deps(fakeGraphStore({ vectors: { n1: unit(0) }, model: "other-model" }), fakeEmbedder().embedder, async () => {}); - await runGraphContextJob(d, job.id); - const got = jobStore.getJob(job.id)!; - assert.equal(got.status, "error"); - assert.match(got.error ?? "", /stale/); - }); - - it("BYOK: a null embedder (unset model or vanished key) fails honestly, never a phantom query", async () => { - const { id: chatId } = newTopicChat("injection unconfigured"); - const job = jobStore.createJob({ kind: "graph-context", chatId, toolCallId: `ctx_${chatId}` }); - trackJob(job.id); - // Wave 6: the resolver collapses BOTH unset-model and missing-key - // into null — the error must name the two honestly, never a false - // "graph.embeddingModel is null" for a key race. - const d = { ...deps(fakeGraphStore({ vectors: { n1: unit(0) } }), fakeEmbedder().embedder, async () => {}), embedder: () => null }; - await runGraphContextJob(d, job.id); - const got = jobStore.getJob(job.id)!; - assert.equal(got.status, "error"); - assert.match(got.error ?? "", /no embedding model available/); - assert.match(got.error ?? "", /unset, or its provider key is missing/); - }); - - it("nothing fitting the budget errors rather than delivering an empty block", async () => { - // Budget below the framing overhead: nothing can fit, ever. - const { id: chatId } = newTopicChat("injection empty selection", { graphContextTokens: 10 }); - const job = jobStore.createJob({ kind: "graph-context", chatId, toolCallId: `ctx_${chatId}` }); - trackJob(job.id); - // Query orthogonal to every vector; budget can hold nothing. - const graph = fakeGraphStore({ vectors: { n1: unit(7) }, nodes: [node("n1", "A very long statement indeed.")] }); - const d = deps(graph, fakeEmbedder(unit(0)).embedder, async () => {}); - await runGraphContextJob(d, job.id); - const got = jobStore.getJob(job.id)!; - assert.equal(got.status, "error"); - assert.match(got.error ?? "", /nothing relevant fit the budget/); - }); -}); - -describe("the injection + the wake pipeline (end to end, fake turn runner)", () => { - it("delivers through the wake: the record gains the marker steer, the job flips done, the dock kind rides along", async () => { - const { id: chatId } = newTopicChat("injection wake delivery"); - const graph = fakeGraphStore({ - vectors: { n1: unit(0), n2: unit(0) }, - nodes: [node("n1", "Water evaporates from oceans."), node("n2", "Condensation forms clouds.")], - events: [ - { nodeId: "n1", kind: "observation", chatId: "chat_old", at: "2026-09-10T00:00:00Z", note: "solid" }, - ], - }); - const fired: { chatId: string; last: SteerMessage }[] = []; - setTurnRunner(async (cid, messages) => { - // Mimic the real wake turn: write-ahead persistence of the incoming - // messages (the handler does this), then 200. - await persistTurn(chatStore, cid, messages as never, false); - fired.push({ chatId: cid, last: messages[messages.length - 1] as SteerMessage }); - return 200; - }); - - const d = deps(graph, fakeEmbedder().embedder, deliverJobResult); - const job = startGraphContextInjection(d, chatId); - assert.ok(job); - trackJob(job.id); - - await waitFor(() => (jobStore.getJob(job.id)?.status ?? "running") === "done", 8000, "wake flipped the job"); - const got = jobStore.getJob(job.id)!; - assert.equal(got.status, "done"); - assert.equal(got.activity, "delivered"); - - // The transcript carries the marker steer (audit trail + dedup key). - const record = chatStore.getChat(chatId)!; - const steerMsg = record.messages.find((m) => m.id === `steer_ctx_${chatId}`); - assert.ok(steerMsg, "the steer persisted to the record"); - const part = steerMsg.parts[0] as { type: string; data: { propositions?: unknown[] } }; - assert.equal(part.type, "data-graph-context"); - assert.ok(part.data.propositions!.length >= 1); - - // The server-side turn fired once, with the steer as its last input. - assert.equal(fired.length, 1); - assert.equal(fired[0]!.chatId, chatId); - assert.equal(fired[0]!.last.id, `steer_ctx_${chatId}`); - }); - - it("aborts SILENTLY: the record flips, the teacher is never told (nothing was promised)", async () => { - const { id: chatId } = newTopicChat("injection abort"); - // A hanging embedder — killed mid-call, like the research abort test. - let release: (() => void) | null = null; - const hanging = new Promise((resolve) => { - release = resolve; - }); - const embedder = { - model: "test-embed", - embedQuery: () => hanging.then(() => ({ vector: unit(0), promptTokens: undefined })), - }; - const fired: number[] = []; - setTurnRunner(async () => { - fired.push(1); - return 200; - }); - const graph = fakeGraphStore({ vectors: { n1: unit(0) }, nodes: [node("n1", "S.")] }); - const d = deps(graph, embedder, deliverJobResult); - const job = startGraphContextInjection(d, chatId); - assert.ok(job); - trackJob(job.id); - - await waitFor(() => jobStore.getJob(job.id)!.activity === "embedding topic", 8000, "run started"); - const outcome = abortJob(job.id); - assert.ok(outcome.ok); - const got = jobStore.getJob(job.id)!; - assert.equal(got.status, "error"); - assert.equal(got.error, "Aborted by learner"); - - release!(); // unwind the hanging call; the abort check exits silently - await waitFor(() => fired.length >= 0, 50, "settle"); - await new Promise((r) => setTimeout(r, 150)); - assert.equal(fired.length, 0, "NO abort steer — the decided contrast with sub-agent jobs"); - assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal"); - // And the chat record never gained a context steer. - const record = chatStore.getChat(chatId)!; - assert.equal( - record.messages.some((m) => m.id === `steer_ctx_${chatId}`), - false, - ); - }); -}); \ No newline at end of file diff --git a/tests/graph-tools.test.ts b/tests/graph-tools.test.ts new file mode 100644 index 0000000..15adca2 --- /dev/null +++ b/tests/graph-tools.test.ts @@ -0,0 +1,203 @@ +// 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>; + } = {}, +): { chatStore: ChatStore; graphStore: GraphStore } { + const edges: GraphEdge[] = []; + const graphStore: GraphStore = { + readNodes: () => nodes, + readEdges: () => edges, + readEvents: () => over.events ?? [], + readMappings: () => [], + readCheckpoints: () => [], + readCoverage: () => [], + readSources: () => [], + readPositions: () => ({}), + writePositions: () => {}, + readEmbeddings: () => ({ model: "dead", dim: 0, vectors: {}, clusters: [], updatedAt: "" }), + writeEmbeddings: () => {}, + 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.'); + }); +}); diff --git a/tests/merge-adopted.test.ts b/tests/merge-adopted.test.ts index 2488a56..9702210 100644 --- a/tests/merge-adopted.test.ts +++ b/tests/merge-adopted.test.ts @@ -13,7 +13,10 @@ function userText(id: string, text: string): TeacherUIMessage { return { id, role: "user", parts: [{ type: "text", text }] }; } function steer(id: string): TeacherUIMessage { - return { id, role: "user", parts: [{ type: "data-graph-context", data: { chatId: "c", budget: 1024, tokens: 1, propositions: [] } }] }; + // Wave 7 killed the graph-context injection; its marker part is gone. + // The steer-shaped user message here exercises mergeAdopted's + // non-text user parts path with the handoff marker instead. + return { id, role: "user", parts: [{ type: "data-probe-handoff", data: { chatId: "c" } }] }; } function assistant(id: string): TeacherUIMessage { return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] }; diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index f83d0f6..e00f906 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -117,10 +117,10 @@ describe("the model catalog projection", () => { const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong); assert.ok(partial); assert.equal(partial!.thinkingLevel, "low"); - // graphContextTokens-only record: model untouched - assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }, strong), { + // thinkingLevel-only record: model untouched + assert.deepEqual(catalogDefaultsFor({ thinkingLevel: "minimal" }, strong), { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, - thinkingLevel: "low", + thinkingLevel: "minimal", }); // chat.model with NO strong slot: the conversation runs on it alone assert.deepEqual( diff --git a/tests/record-adapter.test.ts b/tests/record-adapter.test.ts index e892071..dd94522 100644 --- a/tests/record-adapter.test.ts +++ b/tests/record-adapter.test.ts @@ -86,35 +86,4 @@ describe("record-adapter: system message flattening", () => { assert.equal(out.length, 0, "no model face at all — skipped"); }); - it("the graph-context steer flattens to the canonical prior-knowledge block", () => { - const out = recordToPiMessages([ - userParts([ - { - type: "data-graph-context", - id: "ctx1", - data: { - chatId: "chat_1", - budget: 1024, - tokens: 64, - propositions: [ - { id: "n1", statement: "Water boils at 100°C.", level: "solid" }, - { id: "n2", statement: "Evaporation cools things.", level: null }, - ], - }, - }, - ]), - ]); - assert.equal(out.length, 1, "one user message — the marker part is the model face"); - const got = texts(out); - assert.equal(got.length, 1); - assert.match(got[0]!, /Prior-knowledge context/); - assert.match(got[0]!, /\[solid\] Water boils at 100°C\./); - }); - - it("a malformed graph-context part has no model face — the message is skipped wholesale", () => { - const out = recordToPiMessages([ - userParts([{ type: "data-graph-context", id: "ctx2", data: { chatId: "chat_1" } }]), - ]); - assert.equal(out.length, 0, "parse-don't-cast: no validated data, no face"); - }); }); \ No newline at end of file diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 03ad560..b87826f 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -166,17 +166,6 @@ describe("activity section render", () => { assert.match(html, /activity-total-cost[^>]*>— { - const html = renderToStaticMarkup( - , - ); - assert.match(html, /data-testid="activity-blob-job"/); - assert.match(html, /Graph context/); - assert.match(html, /selecting propositions/); - assert.match(html, /data-testid="activity-abort"/, "END kills the injection like any job"); - }); it("shows total running cost when jobs are priced", () => { const usage = { diff --git a/tests/render-graph-context-marker.test.tsx b/tests/render-graph-context-marker.test.tsx deleted file mode 100644 index 8bc75d1..0000000 --- a/tests/render-graph-context-marker.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { GraphContextList, GraphContextMarker } from "../components/graph-context-marker.tsx"; -import type { GraphContextData, TeacherUIMessage } from "../src/core/index.ts"; - -// The knowledge-context delivery marker (owner observability ask, 2026-09-15): -// a quiet line that opens the delivered propositions, in delivery order, -// each with its folded understanding level. Static markup can't click — the -// closed line and the popover body are pinned separately. - -const DATA: GraphContextData = { - chatId: "c1", - budget: 1024, - tokens: 537, - propositions: [ - { id: "n1", statement: "Water boils at 100°C at standard pressure.", level: "solid" }, - { id: "n2", statement: "Modal realism takes possible worlds as concrete.", level: "shaky" }, - { id: "n3", statement: "An untagged proposition has no folded level.", level: null }, - ], -}; - -function markerMessage(): TeacherUIMessage { - return { id: "steer_ctx_c1", role: "user", parts: [{ type: "data-graph-context", data: DATA }] }; -} - -describe("GraphContextMarker", () => { - it("renders the quiet summary line with token + proposition counts", () => { - const html = renderToStaticMarkup(); - assert.ok(html.includes("Graph context — ~537")); - assert.ok(html.includes("3 propositions")); - assert.ok(html.includes('data-testid="graph-context"')); - assert.ok(html.includes(" { - const html = renderToStaticMarkup( - , - ); - assert.ok(html.includes("Graph context { - it("lists propositions 1..n with their level tags; null level gets no tag", () => { - const html = renderToStaticMarkup(); - const first = html.indexOf("Water boils"); - const second = html.indexOf("Modal realism"); - const third = html.indexOf("untagged"); - assert.ok(first !== -1 && second !== -1 && third !== -1, "all three statements render"); - assert.ok(first < second && second < third, "delivery order preserved"); - assert.ok(html.includes("solid") && html.includes("shaky"), "level tags render"); - // The null-level row: a bare of text, no level chip before it. - const nullRow = html.slice(html.indexOf("untagged") - 120, html.indexOf("untagged")); - assert.ok(!nullRow.includes(">resisted<") && !nullRow.includes(">accepted<")); - assert.ok(html.includes("537 tok of 1024 budget"), "budget + delivered tokens visible"); - }); -}); \ No newline at end of file diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index 09887ef..e47050e 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -111,51 +111,6 @@ describe("DraftShell — the sessionless surface", () => { assert.ok(cta !== -1 && blob !== -1 && cta < blob, "CTA above the blob"); }); - it("renders the knowledge-context slider in the stats bar's left slot, at the decided default (absent settings = on, 1024)", () => { - const html = renderToStaticMarkup( - {}} - onSend={() => {}} - settings={undefined} - onSettingsSaved={() => {}} - />, - ); - assert.ok(html.includes('data-testid="graph-context-control"')); - // INSIDE the stats row (the session-data slot), not a strip above the - // composer: the control renders after the stats bar opens. - assert.ok( - html.indexOf('data-testid="stats-bar"') < html.indexOf('data-testid="graph-context-control"'), - "the slider is the stats bar's left slot", - ); - // The DISCRETE slider (owner, 2026-09-15): 6 positions (off + powers of - // four from 64), not a continuous 0-4096 dial. - assert.ok(html.includes('min="0"') && html.includes('max="5"') && html.includes('step="1"')); - assert.ok(html.includes('value="3"'), "default 1024 = stop index 3"); - // The readout: "tok" abbreviated, but the FULL number (owner, 2026-09-15: - // no k-rounding — 1024 reads as 1024). - assert.ok(html.includes("1024 tok"), "the readout shows the default budget, not 0 — default is ON"); - assert.ok(html.includes("Graph context"), "the control is labeled"); - // Absent settings render the default — never 'off'. - assert.ok(!html.includes(">off<")); - }); - - it("an explicit 0 renders as off — the learner turned the graph off for this session", () => { - const html = renderToStaticMarkup( - {}} - onSend={() => {}} - settings={{ graphContextTokens: 0 }} - onSettingsSaved={() => {}} - />, - ); - assert.ok(html.includes('data-testid="graph-context-value"')); - assert.ok(html.includes(">off<")); - }); - it("no chat id anywhere — nothing in the markup references a session", () => { const html = renderToStaticMarkup( x + amount * target[i]!); - let mag = 0; - for (const x of mixed) mag += x * x; - mag = Math.sqrt(mag); - return mixed.map((x) => x / mag); -} - -function axis(dim: number, k: number): number[] { - return Array.from({ length: dim }, (_, i) => (i === k ? 1 : 0)); -} - -describe("cosineSimilarity", () => { - test("identical unit vectors → 1; orthogonal → 0; defensive normalization", () => { - const a = axis(4, 0); - const b = axis(4, 1); - assert.ok(cosineSimilarity(a, a) > 0.999); - assert.ok(Math.abs(cosineSimilarity(a, b)) < 1e-9); - // Unnormalized twins still match. - assert.ok(cosineSimilarity([3, 0], [5, 0]) > 0.999); - }); -}); - -describe("semanticClusters", () => { - test("empty ledger: zero clusters (nothing embedded yet)", () => { - assert.deepEqual(semanticClusters({}), []); - }); - - test("related vectors merge; distant ones stay separate (threshold)", () => { - const dim = 8; - const topicA = axis(dim, 0); - const topicB = axis(dim, 1); - const vectors = { - a1: near(topicA, topicB, 0.1), - a2: near(topicA, topicB, 0.15), - a3: near(topicA, topicB, 0.05), - b1: near(topicB, topicA, 0.1), - b2: near(topicB, topicA, 0.12), - }; - const clusters = semanticClusters(vectors); - // Both topics form; average-linkage similarity within each is well - // above the default threshold, across is well below. - const big = clusters.filter((c) => c.members.length >= 2); - assert.equal(big.length, 2); - assert.deepEqual( - big.map((c) => c.members.sort()).sort((x, y) => x[0]!.localeCompare(y[0]!)), - [["a1", "a2", "a3"], ["b1", "b2"]], - ); - }); - - test("threshold is the granularity knob: higher splits, lower merges", () => { - const dim = 6; - const vectors = { - x: near(axis(dim, 0), axis(dim, 1), 0.3), - y: near(axis(dim, 1), axis(dim, 0), 0.3), - }; - // Similarity ~0.72: merges below that, splits above. - const sim = cosineSimilarity(vectors.x!, vectors.y!); - assert.equal(semanticClusters(vectors, { similarityThreshold: sim - 0.01 }).length, 1); - assert.equal(semanticClusters(vectors, { similarityThreshold: sim + 0.01 }).length, 2); - }); - - test("stragglers stay singletons by default; attachStragglers forces membership", () => { - const dim = 6; - const topic = axis(dim, 0); - const vectors = { - t1: near(topic, axis(dim, 1), 0.1), - t2: near(topic, axis(dim, 2), 0.1), - t3: near(topic, axis(dim, 3), 0.1), - loner: axis(dim, 5), // orthogonal: fits nothing - }; - const loose = semanticClusters(vectors); - const looseLoner = loose.find((c) => c.members.includes("loner")); - assert.equal(looseLoner?.members.length, 1, "default: loner is a straggler"); - assert.equal(loose.find((c) => c.members.length >= 3)?.members.length, 3); - - const allIn = semanticClusters(vectors, { attachStragglers: true }); - assert.equal(allIn.length, 1, "attach: the loner joins the nearest topic"); - assert.ok(allIn[0]!.members.includes("loner")); - }); - - test("members sorted; clusters ordered by size then id (deterministic)", () => { - const dim = 4; - const vectors = { - solo: axis(dim, 3), - p1: near(axis(dim, 0), axis(dim, 1), 0.05), - p2: near(axis(dim, 0), axis(dim, 2), 0.05), - q1: near(axis(dim, 1), axis(dim, 0), 0.05), - q2: near(axis(dim, 1), axis(dim, 2), 0.05), - }; - const a = semanticClusters(vectors); - const b = semanticClusters(vectors); - assert.deepEqual(a, b, "same input → identical partition"); - for (let i = 1; i < a.length; i++) { - assert.ok(a[i - 1]!.members.length >= a[i]!.members.length, "size-descending"); - } - for (const c of a) { - assert.deepEqual(c.members, [...c.members].sort(), "members sorted"); - } - }); - - test("default threshold is the swept real-graph value", () => { - assert.equal(SEMANTIC_SIM_THRESHOLD, 0.6); - }); -}); diff --git a/tests/settings-store.test.ts b/tests/settings-store.test.ts index acf5271..83b4f23 100644 --- a/tests/settings-store.test.ts +++ b/tests/settings-store.test.ts @@ -20,7 +20,6 @@ import type { ChatSettings } from "../src/core"; const CHAT_FIXTURE: ChatSettings = { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low", - graphContextTokens: 1024, }; // Snapshot any pre-existing files; restore in after() so the worktree's diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 18bc09f..f95516d 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -53,7 +53,7 @@ describe("sanitizeUserSettings", () => { const record = { version: 1, general: { autoUpdate: false }, - chat: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low", graphContextTokens: 256 }, + chat: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low" }, agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" },