feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)

One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.

- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
  step 128, 0 = off); GraphContextData + graphContextText; the
  graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
  under budget, most-recent UnderstandingLevel fold, count cap 64,
  parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
  — vector-space parity by construction); the runner (startGraphContext-
  Injection one-shot gates; runGraphContextJob validates ledger parity);
  wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
  re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
  (nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
  parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
  strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
  "Knowledge context"; model-picker PUT now spreads settings (a model
  pick can no longer clear the sibling override)

Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
This commit is contained in:
amosblomqvist
2026-09-15 07:54:50 +03:00
parent ecb293a77d
commit 1d45d77303
25 changed files with 1443 additions and 28 deletions
+16 -3
View File
@@ -89,11 +89,14 @@ interface LoopContext {
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder,
* visual→visualMaker); the id is taken from config so a price-table sync
* re-prices jobs for free. Model overrides (tests) still bill under the
* configured model — usage is what is being captured, not which instance. */
* configured model — usage is what is being captured, not which instance.
* Context-injection jobs make NO model call ("" — onUsage never fires for
* them; the kind is deliberately absent from the runners below). */
const KIND_MODEL: Record<JobKind, string> = {
research: MODELS.researcher.model,
demo: MODELS.demoBuilder.model,
visual: MODELS.visualMaker.model,
"graph-context": "",
};
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
@@ -128,11 +131,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return jobStore.getJob(jobId)?.status === "running";
}
/** The kind→runner map: one sub-agent loop per job kind. */
/** The kind→runner map: one sub-agent loop per SUB-AGENT kind.
* graph-context is deliberately absent: injection jobs are not
* sub-agent loops (no model, graph deps instead) — their spawn and
* crash-respawn seam is subagents/graph-context.ts, and runJob refuses
* them below rather than guessing wrong. */
// Each kind's runner narrows the union input itself (dispatch-time inputs
// are kind-correct by construction; transcript fallbacks are cast after).
const KIND_RUNNERS: Record<
JobKind,
Exclude<JobKind, "graph-context">,
(jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
> = {
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
@@ -147,6 +154,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return;
}
if (job.status !== "running") return; // already finished / re-run after crash recovery
if (job.kind === "graph-context") {
// Not a sub-agent: the injection's own respawn seam
// (setContextRespawn → runGraphContextJob) owns these records.
console.error(`[job ${jobId}] graph-context job reached the sub-agent runner — wrong seam`);
return;
}
const run = KIND_RUNNERS[job.kind];
if (!run) {
console.error(`[job ${jobId}] unknown kind: ${job.kind}`);