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
+20 -3
View File
@@ -4,14 +4,15 @@ import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../ag
import { piEngineFor } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { embedNewNodes } from "./embeddings.ts";
import { runGraphContextJob, type GraphContextDeps } from "../agent/subagents/graph-context.ts";
import { EMBEDDING_DIM, EMBEDDING_MODEL, embedNewNodes, embedQuery } from "./embeddings.ts";
import type { ModelRole } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.ts";
import { stores } from "./stores.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.ts";
/**
@@ -48,6 +49,18 @@ export const extraction = createExtractionPipeline({
embedder: embedNewNodes,
});
/** Graph context injection (vision-context-injection.md): the runner's
* deps — same storage ports, the REAL embedder as the query seam (same
* warm singleton as extraction, so vector-space parity holds by
* construction), and the wake pipeline as the delivery seam. */
export const graphContext: GraphContextDeps = {
chatStore: stores.chatStore,
jobStore: stores.jobStore,
graphStore: stores.graphStore,
embedder: { model: EMBEDDING_MODEL, dim: EMBEDDING_DIM, embedQuery },
deliver: deliverJobResult,
};
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// fires its server-side teacher turns through THIS composition root, and
// crash recovery re-spawns orphaned sub-agent loops through THIS runner.
@@ -65,6 +78,7 @@ setTurnRunner(async (chatId, messages) => {
return res.status;
});
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
setContextRespawn((jobId) => void runGraphContextJob(graphContext, jobId));
/** The message-shape gate the SDK's UIMessageValidator owned: a malformed
* body must fail BEFORE the stream exists — the path where no onFinish will
@@ -223,6 +237,9 @@ export async function handleChatRequest(
});
}
triggerExtraction(stores, extraction, chatId);
// The graph READ channel: one-shot prior-knowledge injection at
// session start — a no-op for every session past its start.
triggerGraphContext(graphContext, chatId);
// LAST: a poller that sees not-in-flight must read an already-persisted
// record — endTurn only after persistTurn has done its job.
endTurn(chatId);