wave 7: the prober reads its map — graph-context injection dies whole

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.
This commit is contained in:
amosblomqvist
2026-09-23 15:24:48 +03:00
parent a72753b059
commit 6b13583e96
40 changed files with 373 additions and 1990 deletions
+2 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; 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 { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { CONTEXT_LIMITS, isModelsRefusal, jobCost } from "@/src/core"; import { CONTEXT_LIMITS, isModelsRefusal, jobCost } from "@/src/core";
@@ -236,7 +236,6 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
demo: PlayIcon, demo: PlayIcon,
visual: PenToolIcon, visual: PenToolIcon,
pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf") pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf")
"graph-context": BookOpenIcon,
}; };
/** Abort states per blob id: pending (fired) / failed (backend absent). */ /** Abort states per blob id: pending (fired) / failed (backend absent). */
@@ -353,9 +352,7 @@ function ActivityBlobRow({
? "Figure maker" ? "Figure maker"
: blob.jobKind === "pdf" : blob.jobKind === "pdf"
? "PDF reader" ? "PDF reader"
: blob.jobKind === "graph-context" : "Sub-agent";
? "Graph context"
: "Sub-agent";
return ( return (
<div <div
data-testid={`activity-blob-${blob.process}`} data-testid={`activity-blob-${blob.process}`}
-3
View File
@@ -23,9 +23,6 @@ const KIND_LABEL: Record<JobKind, string> = {
demo: "Demo agent", demo: "Demo agent",
visual: "Figure agent", visual: "Figure agent",
pdf: "PDF reader", 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 }) { export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
+5 -13
View File
@@ -24,7 +24,6 @@ import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context"; import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { GraphContextMarker } from "@/components/graph-context-marker";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core"; import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
@@ -453,10 +452,10 @@ export function Chat({
setTimeout(() => { setTimeout(() => {
if (adoptionWindowOpenRef.current) { if (adoptionWindowOpenRef.current) {
void adoptAppendOnly(); void adoptAppendOnly();
// Turn-start nudge re-fire (accuracy wave): the graph-context // Turn-start nudge re-fire (accuracy wave): extraction and
// job starts at send — the initial refresh above can race the // sub-agent jobs start at send — the initial refresh above can
// server's job-record write; the window's ticks retry it so the // race the server's job-record write; the window's ticks retry
// dock always catches the job within the window (≤2s). // it so the dock always catches the job within the window (≤2s).
refreshActivity(); refreshActivity();
} }
}, ms), }, ms),
@@ -945,14 +944,7 @@ export function Chat({
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene) // PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
// can still hold empty ids — the index keeps keys unique for them. // can still hold empty ids — the index keeps keys unique for them.
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} /> <SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? ( ) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
// 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.
<GraphContextMarker key={m.id || `m-${mi}`} message={m} />
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
// The probe→teach handoff wake (custom-message scheme): quiet // The probe→teach handoff wake (custom-message scheme): quiet
// system line, never a user bubble — the wake is scaffolding, // system line, never a user bubble — the wake is scaffolding,
// the "Planning" divider marks the seam, and the model-facing // the "Planning" divider marks the seam, and the model-facing
-140
View File
@@ -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 (
<div
data-testid={`${testId}-needs-model`}
className="flex items-center gap-2 text-xs text-muted-foreground opacity-40"
>
<BookOpenIcon className="size-3 shrink-0" />
<button
type="button"
onClick={() => openSettingsSection("graph")}
className="underline underline-offset-2 transition-colors hover:text-foreground"
>
Graph context needs an embedding model
</button>
</div>
);
}
// 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 (
<div data-testid={testId} className="flex items-center gap-2 text-xs text-muted-foreground">
<BookOpenIcon className="size-3 shrink-0" />
<span className="shrink-0">Graph context</span>
<input
type="range"
min={0}
max={GRAPH_CONTEXT_TOKENS_STOPS.length - 1}
step={1}
value={index}
onChange={(e) => {
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. */}
<span
className="shrink-0 tabular-nums"
data-testid="graph-context-value"
>
{value === 0 ? "off" : `${value} tok`}
</span>
</div>
);
}
/** 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<ChatSettings, "graphContextTokens"> | undefined,
): number {
return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT;
}
-104
View File
@@ -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<HTMLDivElement>(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 (
<div ref={ref} data-testid={testId} className="relative flex items-center justify-center py-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
title="What was delivered — the propositions, in delivery order"
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<BookOpenIcon className="size-3" />
{summary}
</button>
{open && data && <GraphContextList data={data} />}
</div>
);
}
/** The popover body — exported for render tests. Delivery order preserved. */
export function GraphContextList({ data }: { data: GraphContextData }): ReactNode {
return (
<div
data-testid="graph-context-list"
className="absolute top-full left-1/2 z-40 mt-1.5 w-[36rem] max-w-[80vw] -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="font-medium uppercase tracking-wide text-muted-foreground">
Prior knowledge delivered
</span>
<span className="tabular-nums text-muted-foreground">
~{formatTokens(data.tokens)} tok of {data.budget} budget
</span>
</div>
<p className="mb-2 text-muted-foreground">
Most similar first — the order the budget filled. The tags are your current
understanding level for each proposition.
</p>
<ol className="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{data.propositions.map((p, i) => (
<li key={p.id} className="flex items-baseline gap-2">
<span className="shrink-0 tabular-nums text-muted-foreground">{i + 1}.</span>
{p.level && (
<span className="shrink-0 rounded bg-foreground/10 px-1 py-0.5 text-xs text-muted-foreground">
{p.level}
</span>
)}
<span className="min-w-0 flex-1">{p.statement}</span>
</li>
))}
</ol>
</div>
);
}
-7
View File
@@ -6,7 +6,6 @@ import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view"; import { GraphView } from "@/components/graph-view";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDefaultSettings } from "@/components/default-settings"; import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
@@ -172,12 +171,6 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
<StatsBar <StatsBar
dictationSlot={<DictationBar dictation={dictation} />} dictationSlot={<DictationBar dictation={dictation} />}
turns={[] as TurnUsage[]} turns={[] as TurnUsage[]}
leftSlot={
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) => saveDraft({ ...settings, graphContextTokens: tokens })}
/>
}
/> />
</div> </div>
</div> </div>
-12
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, useDictation } from "@/components/dictation";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDefaultSettings } from "@/components/default-settings"; import { useDefaultSettings } from "@/components/default-settings";
import { PhaseChip } from "@/components/phase-divider"; import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
@@ -131,17 +130,6 @@ export function DraftShell({
<StatsBar <StatsBar
dictationSlot={<DictationBar dictation={dictation} />} dictationSlot={<DictationBar dictation={dictation} />}
turns={[]} 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={
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
onSettingsSaved({ ...settings, graphContextTokens: tokens })
}
/>
}
/> />
</div> </div>
</div> </div>
@@ -24,3 +24,4 @@ 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. 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. 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.
+15 -21
View File
@@ -482,12 +482,12 @@ function runNotesAuditor(
// ─── the pipeline status registry ───────────────────────────────────────────── // ─── the pipeline status registry ─────────────────────────────────────────────
/** The pipeline phases, in order — what the home status endpoint shows. /** The pipeline phases, in order — what the home status endpoint shows.
* `embedding` is its own phase (accuracy wave, 2026-09-19): the post-batch * `embedding` was its own phase (accuracy wave, 2026-09-19): the post-batch
* tail — embed every uncovered proposition, re-cluster, settle the layout * embed-re-cluster tail used to hide inside `merging`. Wave 7 stubbed the
* — used to hide inside `merging`, so a run that had already merged every * tail to a no-op — the phase survives only until Platform's
* batch sat on "merging" for the whole embedding tail (model load + * ledger-port interlude deletes the seam wholesale; it transitions
* cold-start backfill on first runs = minutes). The phases must tell the * instantly today. The phases must tell the truth about what the loop is
* truth about what the loop is actually waiting on. */ * actually waiting on. */
export type ExtractionPhase = "extracting" | "auditing" | "merging" | "embedding"; export type ExtractionPhase = "extracting" | "auditing" | "merging" | "embedding";
/** One in-flight pipeline run. Module-level: one process, one registry. /** One in-flight pipeline run. Module-level: one process, one registry.
@@ -575,16 +575,12 @@ export interface ExtractionModels {
} }
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels { export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
/** The post-merge embedding batch (semantic clusters run on it). /** The post-merge embedding batch — WAVE 7 STUB: the provider
* INJECTED by the composition root (the agent layer never imports * embedder and semantic clustering are dead (birth-session clusters
* the server layer — architecture law): production passes the * + session-name labels), so production injects a no-op that
* provider-embedder batch (server/embeddings.ts embedNewNodes — BYOK * reports skipped; tests fake writes to the (still-live) ledger
* wave 6; null model = it skips itself with an info log), tests fake * port. The whole seam dies with Platform's ledger-port interlude
* it. Failure inside it is non-fatal by contract, and since 02bd1b0 * — kept one round so that removal is a pure deletion. */
* 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. */
embedder: (graphStore: GraphStore) => Promise<unknown>; embedder: (graphStore: GraphStore) => Promise<unknown>;
} }
@@ -863,11 +859,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// The embedding tail gets its own phase — the honest label for what // The embedding tail gets its own phase — the honest label for what
// the run is waiting on here (see ExtractionPhase above). // the run is waiting on here (see ExtractionPhase above).
setPhase(chatId, "embedding", "chat"); setPhase(chatId, "embedding", "chat");
// Semantic embeddings (DECIDED 2026-09-13): topics come from // The embedding tail (wave 7 stub — see ExtractionPipelineDeps):
// MEANING, so embed the new propositions BEFORE the settle and // the seam stays until Platform's ledger-port interlude; the
// the namer look at clusters. Failure is non-fatal — the landed // failure contract is unchanged (non-fatal, by-result).
// vectors persist (pay-once, 02bd1b0) and clustering runs with
// them; the next merge's retry embeds only what remains.
await captureEmbedUsage(chatId, await embed(graphStore).catch(recordEmbedFailure(chatId))); await captureEmbedUsage(chatId, await embed(graphStore).catch(recordEmbedFailure(chatId)));
// Option A: the layout ledger evolves with the merge (sync — the // Option A: the layout ledger evolves with the merge (sync — the
// next snapshot fetch must already reflect the new nodes). // next snapshot fetch must already reflect the new nodes).
-222
View File
@@ -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<ChatStore, "getChat">;
jobStore: Pick<JobStore, "createJob" | "getJob" | "updateJob" | "findJobByToolCallId">;
graphStore: Pick<GraphStore, "readNodes" | "readEvents" | "readEmbeddings">;
/** 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<void>;
}
/** 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<void> {
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);
}
}
+6 -16
View File
@@ -107,7 +107,7 @@ interface LoopContext {
/** The kind→role table (model slots wave, 2026-09-20): which ModelRole's /** The kind→role table (model slots wave, 2026-09-20): which ModelRole's
* engine each sub-agent kind runs — the engineFor injection key. */ * engine each sub-agent kind runs — the engineFor injection key. */
const KIND_ROLE: Record<Exclude<JobKind, "graph-context">, ModelRole> = { const KIND_ROLE: Record<JobKind, ModelRole> = {
research: "researcher", research: "researcher",
demo: "demoBuilder", demo: "demoBuilder",
visual: "visualMaker", visual: "visualMaker",
@@ -148,18 +148,14 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return jobStore.getJob(jobId)?.status === "running"; return jobStore.getJob(jobId)?.status === "running";
} }
/** The kind→runner map: one sub-agent loop per SUB-AGENT kind. /** The kind→runner map: one sub-agent loop per kind. Each kind's
* graph-context is deliberately absent: injection jobs are not * runner narrows the union input itself (dispatch-time inputs are
* sub-agent loops (no model, graph deps instead) — their spawn and * kind-correct by construction; transcript fallbacks are cast after). */
* 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 engine one job runs on: test override first (the old seam — /** The engine one job runs on: test override first (the old seam —
* tests swap scripted engines without touching deps), then the injected * tests swap scripted engines without touching deps), then the injected
* settings-resolved engineFor. undefined = the BYOK chain came up * settings-resolved engineFor. undefined = the BYOK chain came up
* empty (owner, 2026-09-22) — runJob refuses below, honestly. */ * empty (owner, 2026-09-22) — runJob refuses below, honestly. */
function pickEngine(kind: Exclude<JobKind, "graph-context">): PiEngine | undefined { function pickEngine(kind: JobKind): PiEngine | undefined {
const testOverride: Record<string, { current?: PiEngine } | undefined> = { const testOverride: Record<string, { current?: PiEngine } | undefined> = {
researcher: researchModelOverride, researcher: researchModelOverride,
demoBuilder: demoModelOverride, demoBuilder: demoModelOverride,
@@ -170,7 +166,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
} }
const KIND_RUNNERS: Record< const KIND_RUNNERS: Record<
Exclude<JobKind, "graph-context">, JobKind,
(jobId: string, engine: PiEngine, input: JobToolInput | undefined, loop: LoopContext) => Promise<void> (jobId: string, engine: PiEngine, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
> = { > = {
research: (jobId, engine, input, loop) => runResearchJob(jobId, engine, input, loop), research: (jobId, engine, input, loop) => runResearchJob(jobId, engine, input, loop),
@@ -186,12 +182,6 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return; return;
} }
if (job.status !== "running") return; // already finished / re-run after crash recovery 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]; const run = KIND_RUNNERS[job.kind];
if (!run) { if (!run) {
console.error(`[job ${jobId}] unknown kind: ${job.kind}`); console.error(`[job ${jobId}] unknown kind: ${job.kind}`);
+18 -5
View File
@@ -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 { jobTools, type DemoInput, type PdfInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
import { planTools, type PlanToolContext } from "./tools/plan.ts"; import { planTools, type PlanToolContext } from "./tools/plan.ts";
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts"; import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
import { proberGraphTools } from "./tools/graph.ts";
import { readTool } from "./tools/read.ts"; import { readTool } from "./tools/read.ts";
import { lsTool } from "./tools/ls.ts"; import { lsTool } from "./tools/ls.ts";
import { findTool } from "./tools/find.ts"; import { findTool } from "./tools/find.ts";
import { grepTool } from "./tools/grep.ts"; import { grepTool } from "./tools/grep.ts";
import type { GraphStore } from "../core/ports.ts";
export { jobTools }; export { jobTools };
export type { ProbeDoneContext } from "./tools/probe.ts"; 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 /** Search availability — the research dispatch tool's honest-disable
* signal (same gate as the teacher's). Absent = available. */ * signal (same gate as the teacher's). Absent = available. */
searchAvailable?: () => boolean; 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 /** The prober's tool set: probe quiz (no nodeId) + ask + the learner-fs
* set + probe_done + the two phase-omnipresent job dispatch tools. * set + probe_done + the two phase-omnipresent job dispatch tools +
* `enabled` is the resolved settings seam (core/pending.ts): any tool * (wave 7, gated) the graph-map reader. `enabled` is the resolved
* explicitly false is dropped from the registry. probe_done stays * settings seam (core/pending.ts): any tool explicitly false is dropped
* always-on regardless — it is how the phase ENDS; a prober that can * from the registry. probe_done stays always-on regardless — it is how
* never conclude is a lockout. */ * the phase ENDS; a prober that can never conclude is a lockout. */
export function createProberTools( export function createProberTools(
ctx: ProberToolContext, ctx: ProberToolContext,
enabled?: EnabledTools, enabled?: EnabledTools,
@@ -122,6 +130,11 @@ export function createProberTools(
// when no engine is live). // when no engine is live).
...(jobs.research ? { research: jobs.research } : {}), ...(jobs.research ? { research: jobs.research } : {}),
pdf: jobs.pdf!, 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, ...done,
}, },
enabled, enabled,
+75
View File
@@ -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<string, LhTool<any, any>> {
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<string> => {
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<string> => {
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")}`;
},
}),
};
}
+4 -5
View File
@@ -1,9 +1,8 @@
/** /**
* CLUSTER LABEL MATCHING (vision §1). DETECTION moved to semantic topic * CLUSTER LABEL MATCHING (vision §1). DETECTION is birth-session
* clustering over embeddings (core/semantic-clusters.ts, DECIDED * clustering since wave 7 (core/session-clusters.ts; the semantic
* 2026-09-13) — Louvain over the edge structure is retired: it only ever * embedder and its Louvain predecessor are both dead). What REMAINS here
* found the reasoning chains (the graph is disconnected by nature), and * is the
* edge-orphans could never join anything. What REMAINS here is the
* cluster contract + core-survival label matching, which is * cluster contract + core-survival label matching, which is
* detection-agnostic: it consumes the same { members } shape semantic * detection-agnostic: it consumes the same { members } shape semantic
* clustering emits. * clustering emits.
-66
View File
@@ -58,27 +58,8 @@ export interface ChatSettings {
/** pi thinking level — must be in the chosen model's supported list /** pi thinking level — must be in the chosen model's supported list
* (getSupportedThinkingLevels; enforced by the settings route). */ * (getSupportedThinkingLevels; enforced by the settings route). */
thinkingLevel?: ThinkingLevel; 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) ──────── // ─── 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. " + "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."; "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 { export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN /** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
+3 -32
View File
@@ -7,11 +7,11 @@
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md. * of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
*/ */
import type { UIMessage } from "ai"; 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) ──────────── // ─── 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 type JobStatus = "running" | "done" | "error";
export interface JobRecord { export interface JobRecord {
@@ -295,8 +295,7 @@ export interface AbortSteerMessage extends UIMessage {
export type SteerMessage = export type SteerMessage =
| SubagentSteerMessage | SubagentSteerMessage
| AbortSteerMessage | AbortSteerMessage
| ProbeHandoffSteerMessage | ProbeHandoffSteerMessage;
| GraphContextSteerMessage;
/** Whether a result is the failure shape (html/svg empty, failure brief). */ /** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure( 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 /** True when the transcript already carries this steer — idempotent
* delivery for ANY steer kind: every steer id is deterministic per its * delivery for ANY steer kind: every steer id is deterministic per its
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */ * event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
-184
View File
@@ -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<string> = 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<string, UnderstandingLevel> {
const latest = new Map<string, { at: string; level: UnderstandingLevel; index: number }>();
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<string, UnderstandingLevel>();
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<string, number[]>;
/** 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<string>();
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<string, unknown>;
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<string, unknown>;
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;
}
+4 -5
View File
@@ -399,11 +399,10 @@ const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-
/** /**
* The latest quiz outcome per node — a raw EVENT FACT, not a node state * 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 * (the transitional node-state classes were removed 2026-09-08: nothing
* colors nodes by quiz results anymore). Used by the teacher's * colors nodes by quiz results anymore). Its last consumer, the
* graph-as-context selection (graph-context.ts) to find diagnostic * graph-as-context selection, died in wave 7 (the injection is gone —
* hotspots; the home renderer does not consume it. Nodes with no quiz * the prober reads its map through tools now); the event fact stays as
* events have no entry. (The graph-as-context consumer is shelved — * raw ledger truth. Nodes with no quiz events have no entry.
* docs/probe-teach-split.md §5 — but the event fact stays.)
*/ */
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> { export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>(); const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
-1
View File
@@ -55,4 +55,3 @@ export * from "./auth-keys.ts";
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe). // keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
export * from "./keyboard.ts"; export * from "./keyboard.ts";
export * from "./coverage.ts"; export * from "./coverage.ts";
export * from "./graph-context.ts";
+4 -5
View File
@@ -130,11 +130,10 @@ export interface GraphStore {
/** The embeddings ledger (config.EMBEDDINGS_FILE) — INERT DATA since /** The embeddings ledger (config.EMBEDDINGS_FILE) — INERT DATA since
* wave 7 (owner ruling 2026-09-23: the provider embedder is dead; * wave 7 (owner ruling 2026-09-23: the provider embedder is dead;
* clustering is birth-session, labels are session names). Kept ONLY * clustering is birth-session, labels are session names; the
* because the graph-context injection (agent band, Teacher's pass * graph-context injection is gone with it). The whole seam dies in
* removes it) still reads it for vector-space staleness — the seam * the follow-up interlude; graph-deletion's vector pruning is
* dies with it in the follow-up interlude. graph-deletion keeps * harmless housekeeping meanwhile. */
* pruning dead vectors meanwhile (harmless housekeeping). */
readEmbeddings(): EmbeddingsLedger; readEmbeddings(): EmbeddingsLedger;
/** Replace the whole ledger (graph-deletion's vector pruning only). */ /** Replace the whole ledger (graph-deletion's vector pruning only). */
writeEmbeddings(ledger: EmbeddingsLedger): void; writeEmbeddings(ledger: EmbeddingsLedger): void;
-141
View File
@@ -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<Record<string, readonly number[]>>,
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<number, number[]>();
const members = new Map<number, string[]>();
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] ?? ""));
}
-5
View File
@@ -1,7 +1,6 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { import type {
DemoToolInput, DemoToolInput,
GraphContextData,
PdfToolInput, PdfToolInput,
PlanVerdictData, PlanVerdictData,
PlanBlockData, PlanBlockData,
@@ -68,10 +67,6 @@ export type TeacherUIDataParts = {
/** The probe→teach handoff wake: the wake pipeline's steer message, /** The probe→teach handoff wake: the wake pipeline's steer message,
* carrying ONLY this part (the model-facing text lives in the adapter). */ * carrying ONLY this part (the model-facing text lives in the adapter). */
"probe-handoff": { chatId: string }; "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). */ /** The abort steer's message-type marker (the abort badge renders off it). */
"subagent-aborted": SubagentResultMeta & { aborted: true }; "subagent-aborted": SubagentResultMeta & { aborted: true };
}; };
+5
View File
@@ -12,6 +12,7 @@ import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts"; import { searchAvailable, searchProvider } from "./search.ts";
import { import {
extractionEnabled, extractionEnabled,
graphToolsEnabled,
resolveConversationSpec, resolveConversationSpec,
resolveRoleSpec, resolveRoleSpec,
} from "../core/settings-resolution.ts"; } from "../core/settings-resolution.ts";
@@ -274,6 +275,10 @@ export async function handleChatRequest(
jobStore: stores.jobStore, jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input), spawn: (job, input) => void jobRunner.runJob(job.id, input),
searchAvailable, 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, agentOpts,
); );
+6 -55
View File
@@ -34,7 +34,6 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { import {
buildAbortSteer, buildAbortSteer,
buildGraphContextSteer,
buildSubagentSteer, buildSubagentSteer,
isSubagentFailure, isSubagentFailure,
steerDelivered, steerDelivered,
@@ -61,9 +60,6 @@ interface QueuedSteer {
interface WakeState { interface WakeState {
turnRunner?: TurnRunner; turnRunner?: TurnRunner;
respawn?: JobRespawn; 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. */ /** chatId → steers awaiting delivery, in arrival order. */
queues: Map<string, QueuedSteer[]>; queues: Map<string, QueuedSteer[]>;
/** Chats whose pump loop is currently active (serialization per chat). */ /** Chats whose pump loop is currently active (serialization per chat). */
@@ -91,13 +87,6 @@ export function setJobRespawn(respawn: JobRespawn): void {
state().respawn = respawn; 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; const POLL_MS = 400;
/** After the gates open, wait a beat before firing — a client that just /** 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. * answered a question is about to send its continuation; don't 409 it.
@@ -124,11 +113,7 @@ function delay(ms: number): Promise<void> {
/** The job a steer belongs to, if it is a job steer (result/abort/context); /** The job a steer belongs to, if it is a job steer (result/abort/context);
* probe-handoff steers have no job — null. */ * probe-handoff steers have no job — null. */
function jobIdOf(steer: SteerMessage): string | null { function jobIdOf(steer: SteerMessage): string | null {
const meta = steer.metadata as { return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null;
subagentResult?: { jobId: string };
graphContext?: { jobId: string };
};
return meta.subagentResult?.jobId ?? meta.graphContext?.jobId ?? null;
} }
/** Finalize a delivered steer's job bookkeeping — job steers only. */ /** 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) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" }; 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) fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired). // 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" }); stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
if (!isContext) { void deliverJobResult(buildAbortSteer(job)).catch(() => {
void deliverJobResult(buildAbortSteer(job)).catch(() => { /* deliverJobResult marks the job error on unrecoverable failure */
/* deliverJobResult marks the job error on unrecoverable failure */ });
});
}
return { ok: true }; return { ok: true };
} }
function chatIdOf(steer: SteerMessage): string { function chatIdOf(steer: SteerMessage): string {
// The probe handoff and the context injection carry their chat directly // The probe handoff carries its chat directly (the job record would
// (the context steer's job record would say it too, but the metadata is // say it too, but the metadata is the cheap path).
// the cheap path — same rule as the handoff).
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff; const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
if (handoff) return handoff.chatId; 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 // The chat is resolvable from the job record; the steer's metadata
// carries jobId, which indexes the store. // carries jobId, which indexes the store.
const jobId = jobIdOf(steer); const jobId = jobIdOf(steer);
@@ -394,12 +368,6 @@ async function pump(chatId: string): Promise<void> {
function finalizeJob(jobId: string): void { function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId); const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return; 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 result = job.result;
const failed = result ? isSubagentFailure(job.kind, result as never) : true; const failed = result ? isSubagentFailure(job.kind, result as never) : true;
stores.jobStore.updateJob(jobId, { 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 (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
if (!stores.chatStore.getChat(job.chatId)) continue; if (!stores.chatStore.getChat(job.chatId)) continue;
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now 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) { if (job.result) {
// The wake never fired (or crashed mid-flight): re-deliver. // The wake never fired (or crashed mid-flight): re-deliver.
const steer = buildSubagentSteer(job, job.result as never); const steer = buildSubagentSteer(job, job.result as never);
+1 -9
View File
@@ -1,8 +1,7 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { graphContextOfMessage } from "../../core/graph-context.ts"; import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
import { graphContextText, HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
/** /**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The * 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); return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
} }
if (part.type === "data-probe-handoff") return HANDOFF_TEXT; 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; return null;
} }
+5 -19
View File
@@ -12,7 +12,7 @@
*/ */
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; 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 { import type {
AgentSettings, AgentSettings,
AppearanceSettings, AppearanceSettings,
@@ -37,7 +37,6 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSetting
const raw = body as { const raw = body as {
model?: { provider?: unknown; id?: unknown }; model?: { provider?: unknown; id?: unknown };
thinkingLevel?: unknown; thinkingLevel?: unknown;
graphContextTokens?: unknown;
}; };
const settings: ChatSettings = {}; const settings: ChatSettings = {};
if (raw.model !== undefined) { if (raw.model !== undefined) {
@@ -57,23 +56,10 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSetting
} }
settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel; settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
} }
// Graph context injection budget (vision-context-injection.md): integer // Wave 7 killed the graph-context injection (owner: zero configuration
// tokens, 0 (off) to GRAPH_CONTEXT_TOKENS_MAX, no silent clamping — the // beyond chat models — the prober reads its map through tools now); its
// slider is the only writer, and it only ever sends multiples of the step. // budget dial is gone with it. A stale client sending the old key has
if (raw.graphContextTokens !== undefined) { // it silently dropped here (forward-compatible, like any unknown key).
if (
typeof raw.graphContextTokens !== "number" ||
!Number.isInteger(raw.graphContextTokens) ||
raw.graphContextTokens < 0 ||
raw.graphContextTokens > GRAPH_CONTEXT_TOKENS_MAX
) {
return {
ok: false,
error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}`,
};
}
settings.graphContextTokens = raw.graphContextTokens;
}
return { ok: true, settings }; return { ok: true, settings };
} }
-11
View File
@@ -10,7 +10,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { GRAPH_CONTEXT_TOKENS_MAX } from "../src/core";
import { parseSettingsBody } from "../src/server/settings-body.ts"; import { parseSettingsBody } from "../src/server/settings-body.ts";
describe("parseSettingsBody (shared by both settings routes)", () => { describe("parseSettingsBody (shared by both settings routes)", () => {
@@ -24,13 +23,11 @@ describe("parseSettingsBody (shared by both settings routes)", () => {
const r = parseSettingsBody({ const r = parseSettingsBody({
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
thinkingLevel: "high", thinkingLevel: "high",
graphContextTokens: 256,
}); });
assert.ok(r.ok); assert.ok(r.ok);
assert.deepEqual(r.settings, { assert.deepEqual(r.settings, {
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
thinkingLevel: "high", thinkingLevel: "high",
graphContextTokens: 256,
}); });
}); });
@@ -54,12 +51,4 @@ describe("parseSettingsBody (shared by both settings routes)", () => {
assert.equal(parseSettingsBody({ thinkingLevel: 7 }).ok, false); 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);
});
}); });
+7 -7
View File
@@ -43,7 +43,6 @@ import type { GraphStore } from "../src/core/ports.ts";
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts"; import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
import { ModelsNotConfiguredError } from "../src/core/settings.ts"; import { ModelsNotConfiguredError } from "../src/core/settings.ts";
import { batchTag, coverageOf, coverageSlots } from "../src/core/coverage.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 { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts"; import { PATHS } from "../config.ts";
@@ -839,9 +838,11 @@ const NOTES_VERDICT = {
dropped: [], dropped: [],
} as const; } as const;
/** Test seam for the embedder: fakes the post-merge embedding batch — /** Test seam for the embedder: semantic clustering died with wave 7
* one identical unit vector per node → semanticClusters merges them all * (birth-session clusters + session-name labels), so the production
* into ONE topic — then persists the partition like the real embedder. */ * 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 fakeEmbedder = async (store: GraphStore) => {
const seen = new Set<string>(); const seen = new Set<string>();
const ids: string[] = []; const ids: string[] = [];
@@ -850,12 +851,11 @@ const fakeEmbedder = async (store: GraphStore) => {
seen.add(n.id); seen.add(n.id);
ids.push(n.id); ids.push(n.id);
} }
const vectors = Object.fromEntries(ids.map((id) => [id, [1, 0, 0]]));
store.writeEmbeddings({ store.writeEmbeddings({
model: "test/embedder", model: "test/embedder",
dim: 3, dim: 3,
vectors, vectors: Object.fromEntries(ids.map((id) => [id, [1, 0, 0]])),
clusters: semanticClusters(vectors), clusters: [],
updatedAt: "2026-09-13T00:00:00.000Z", updatedAt: "2026-09-13T00:00:00.000Z",
}); });
}; };
-293
View File
@@ -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<string, number[]> = {};
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");
});
});
-333
View File
@@ -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<void> {
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<string, number[]>;
nodes?: GraphNode[];
events?: LedgerEvent[];
model?: string;
dim?: number;
}): Pick<GraphContextDeps["graphStore"], "readNodes" | "readEvents" | "readEmbeddings"> {
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<string, unknown>): { 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<GraphContextDeps["graphStore"], "readNodes" | "readEvents" | "readEmbeddings">,
embedder: GraphContextEmbedder,
deliver: (steer: SteerMessage) => Promise<void>,
): 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<void>((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,
);
});
});
+203
View File
@@ -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<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
} = {},
): { chatStore: ChatStore; graphStore: GraphStore } {
const edges: GraphEdge[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readEvents: () => over.events ?? [],
readMappings: () => [],
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () => [],
readPositions: () => ({}),
writePositions: () => {},
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.');
});
});
+4 -1
View File
@@ -13,7 +13,10 @@ function userText(id: string, text: string): TeacherUIMessage {
return { id, role: "user", parts: [{ type: "text", text }] }; return { id, role: "user", parts: [{ type: "text", text }] };
} }
function steer(id: string): TeacherUIMessage { 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 { function assistant(id: string): TeacherUIMessage {
return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] }; return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] };
+3 -3
View File
@@ -117,10 +117,10 @@ describe("the model catalog projection", () => {
const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong); const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong);
assert.ok(partial); assert.ok(partial);
assert.equal(partial!.thinkingLevel, "low"); assert.equal(partial!.thinkingLevel, "low");
// graphContextTokens-only record: model untouched // thinkingLevel-only record: model untouched
assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }, strong), { assert.deepEqual(catalogDefaultsFor({ thinkingLevel: "minimal" }, strong), {
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, 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 // chat.model with NO strong slot: the conversation runs on it alone
assert.deepEqual( assert.deepEqual(
-31
View File
@@ -86,35 +86,4 @@ describe("record-adapter: system message flattening", () => {
assert.equal(out.length, 0, "no model face at all — skipped"); 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");
});
}); });
-11
View File
@@ -166,17 +166,6 @@ describe("activity section render", () => {
assert.match(html, /activity-total-cost[^>]*>—</); assert.match(html, /activity-total-cost[^>]*>—</);
}); });
it("renders a graph-context job as 'Graph context' — the dock's fourth kind", () => {
const html = renderToStaticMarkup(
<ActivitySection
feed={{ jobs: [job({ id: "jctx", kind: "graph-context", activity: "selecting propositions", title: "Water cycle" })], extractions: [] }}
/>,
);
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", () => { it("shows total running cost when jobs are priced", () => {
const usage = { const usage = {
@@ -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(<GraphContextMarker message={markerMessage()} />);
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("<button"), "the line is clickable");
});
it("a malformed part still renders the generic line (parse-don't-cast)", () => {
const html = renderToStaticMarkup(
<GraphContextMarker message={{ parts: [{ type: "data-graph-context", data: { nope: true } }] }} />,
);
assert.ok(html.includes("Graph context</"));
assert.ok(!html.includes("Graph context —"), "no counts on a malformed part");
});
});
describe("GraphContextList — delivery order is the render order", () => {
it("lists propositions 1..n with their level tags; null level gets no tag", () => {
const html = renderToStaticMarkup(<GraphContextList data={DATA} />);
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 <span> 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");
});
});
-45
View File
@@ -111,51 +111,6 @@ describe("DraftShell — the sessionless surface", () => {
assert.ok(cta !== -1 && blob !== -1 && cta < blob, "CTA above the blob"); 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(
<DraftShell
busy={false}
text=""
onChange={() => {}}
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(
<DraftShell
busy={false}
text=""
onChange={() => {}}
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", () => { it("no chat id anywhere — nothing in the markup references a session", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<DraftShell <DraftShell
-118
View File
@@ -1,118 +0,0 @@
/**
* Semantic topic clustering (the Louvain REPLACEMENT, DECIDED
* 2026-09-13): average-linkage agglomerative clustering over the
* embedding ledger — related TOPICS, not reasoning chains. Pure and
* deterministic, so these tests are exact.
*/
import { strict as assert } from "node:assert";
import { describe, test } from "node:test";
import { semanticClusters, SEMANTIC_SIM_THRESHOLD, cosineSimilarity } from "../src/core/semantic-clusters.ts";
/** Two clearly-related one-hot-ish vectors (high cosine similarity). */
function near(vec: number[], target: number[], amount = 0.2): number[] {
let dot = 0;
for (let i = 0; i < vec.length; i++) dot += vec[i]! * target[i]!;
const mixed = vec.map((x, i) => 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);
});
});
-1
View File
@@ -20,7 +20,6 @@ import type { ChatSettings } from "../src/core";
const CHAT_FIXTURE: ChatSettings = { const CHAT_FIXTURE: ChatSettings = {
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
thinkingLevel: "low", thinkingLevel: "low",
graphContextTokens: 1024,
}; };
// Snapshot any pre-existing files; restore in after() so the worktree's // Snapshot any pre-existing files; restore in after() so the worktree's
+1 -1
View File
@@ -53,7 +53,7 @@ describe("sanitizeUserSettings", () => {
const record = { const record = {
version: 1, version: 1,
general: { autoUpdate: false }, 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: { agent: {
models: { models: {
researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }, researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" },