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";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BookOpenIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { CONTEXT_LIMITS, isModelsRefusal, jobCost } from "@/src/core";
@@ -236,7 +236,6 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
demo: PlayIcon,
visual: PenToolIcon,
pdf: FileTextIcon, // sources v0 — type-forced entry (JobKind grew "pdf")
"graph-context": BookOpenIcon,
};
/** Abort states per blob id: pending (fired) / failed (backend absent). */
@@ -353,9 +352,7 @@ function ActivityBlobRow({
? "Figure maker"
: blob.jobKind === "pdf"
? "PDF reader"
: blob.jobKind === "graph-context"
? "Graph context"
: "Sub-agent";
: "Sub-agent";
return (
<div
data-testid={`activity-blob-${blob.process}`}
-3
View File
@@ -23,9 +23,6 @@ const KIND_LABEL: Record<JobKind, string> = {
demo: "Demo agent",
visual: "Figure agent",
pdf: "PDF reader",
// The injection is server-initiated — it has no tool part, so never a
// dispatch card. The entry exists only for the exhaustive record.
"graph-context": "Graph context",
};
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
+5 -13
View File
@@ -24,7 +24,6 @@ import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import { GraphContextMarker } from "@/components/graph-context-marker";
import { useActivity } from "@/components/activity-dock";
import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
@@ -453,10 +452,10 @@ export function Chat({
setTimeout(() => {
if (adoptionWindowOpenRef.current) {
void adoptAppendOnly();
// Turn-start nudge re-fire (accuracy wave): the graph-context
// job starts at send — the initial refresh above can race the
// server's job-record write; the window's ticks retry it so the
// dock always catches the job within the window (≤2s).
// Turn-start nudge re-fire (accuracy wave): extraction and
// sub-agent jobs start at send — the initial refresh above can
// race the server's job-record write; the window's ticks retry
// it so the dock always catches the job within the window (≤2s).
refreshActivity();
}
}, ms),
@@ -945,14 +944,7 @@ export function Chat({
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
// can still hold empty ids — the index keeps keys unique for them.
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? (
// The prior-knowledge injection's delivery (vision-context-injection.md):
// a quiet, CLICKABLE line (owner observability ask, 2026-09-15) —
// the popover shows which propositions were delivered, in delivery
// order; the block itself was for the model (adapter flattening),
// the part data is the audit trail this reads from.
<GraphContextMarker key={m.id || `m-${mi}`} message={m} />
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
// The probe→teach handoff wake (custom-message scheme): quiet
// system line, never a user bubble — the wake is scaffolding,
// the "Planning" divider marks the seam, and the model-facing
-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 { SetupDialog } from "@/components/setup-dialog";
import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar";
@@ -172,12 +171,6 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
<StatsBar
dictationSlot={<DictationBar dictation={dictation} />}
turns={[] as TurnUsage[]}
leftSlot={
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) => saveDraft({ ...settings, graphContextTokens: tokens })}
/>
}
/>
</div>
</div>
-12
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDefaultSettings } from "@/components/default-settings";
import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar";
@@ -131,17 +130,6 @@ export function DraftShell({
<StatsBar
dictationSlot={<DictationBar dictation={dictation} />}
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>