One quiet row beneath the composer/question shell (not flush to the
screen bottom; static height, outside the morphing shell):
- LEFT: context window + session cost, clickable → per-turn detail
popover (context, total, unpriced count, one row per turn with
model/steps/cost). All folds from core/prices.ts — sessionCost,
contextWindow, turnCost — no cost math in components.
- RIGHT: model + "Thinking —" — a placeholder slot for the coming
model/thinking-level CONTROL (later work with its contract changes);
display-only today, model read from the last turn's usage,
pretty-printed (Glm 5.3 Flash).
- Data: the record's turns log, seeded on mount, refreshed when the
local stream settles, kept fresh by the existing record re-syncs.
No turns yet → "—" everywhere; unpriceable turns never fold into a
fake $0 (the doctrine).
- formatUsd/formatTokens moved to components/usage-format.ts (shared
with the activity dock); prettyModel joins them, pure + tested.
One reserved corner of the shell, one blob per running BACKGROUND
process — session sub-agents (research/demo/visual) and global
extraction runs. Teaching turns are deliberately NOT here: the dock
exists to surface what the learner can't otherwise see; a running
teaching turn is already fully visible where it happens (user decision).
- Data: ONE poll of GET /api/activity (jobs across ALL chats,
title-joined, oldest-first + extraction runs), folded client-side
with core/prices.ts (jobCost). Adaptive interval — 2.5s while
anything runs, 10s idle, 4× slower on hidden tabs; a failed fetch
keeps the last feed (no flicker to empty on a blip).
- Per blob: kind icon, elapsed (ticking), cost, activity line, and an
explicit END on the blob's face. Click → detail popover (context
line, model, steps, open-session link). The context line is the LAST
model call's tokens vs the model's limit (JobUsage.lastStep — pending
agents-stream contract; "—" until it populates), NEVER a cumulative
sum. Absent data renders "—", never zero (the doctrine).
- Header: total running cost (sum of priced blobs, "—" when nothing
is priced). Narrow viewports collapse to a count badge → drawer.
- END posts to /api/activity/abort {kind, id} — the backend is being
composed by master over the streams' mechanisms; until it exists a
click locks the button and the popover says "Abort not available
yet" (never a dead control).
- Idle renders NOTHING. Mounted once in the root layout; every view
gets it for free.
toBlobs / formatElapsed / formatUsd / formatTokens are pure + tested;
SSR render tests pin blobs, the "—" doctrine, the face END, and the
priced total.
The dock is visible in every view, so it needs every running process —
sub-agent jobs across ALL chats (not just the open one) plus the global
extraction runs — in one poll. /api/jobs stays chat-scoped and untouched:
it is the transcript machinery's poll and carries turnInFlight for the
re-attach seam (chat-transport), the seam we spent three live rounds
stabilizing; making it polymorphic would fork its response shape by mode
and put regression risk on exactly the wrong path. The dock is a different
consumer with a different shape (cross-chat, running-only, cost-bearing),
served by a thin route over server/observability.ts:
- activityFeed(): running jobs from every chat (title-joined, oldest-first
on last activity) + extraction runs + busyChats (chats with a teacher
turn in flight — the biggest spender; data made available, rendering is
a dock decision).
- GET /api/activity — the dock's single poll everywhere.
- Cost math stays client-side (core/prices.ts folds); raw usage rides the
records and the transport stays thin.
Decided with the user over the polymorphic-/api/jobs and
separate-all-jobs-endpoint alternatives.
The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:
- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
a DELIBERATELY parallel append-only usage log, not per-message metadata:
the client re-POSTs its message list every turn and its copies never
carry server-stamped fields, so anything inside messages gets clobbered
by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
(full DAG on plan_init, one-level neighborhood on plan updates, snapshot
semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
per messageId (abort replays / wake retries append once); listChats no
longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
(fixes the import-law violation in the graph status route that slipped
through the wave-3 merge verification).
Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
Wave 3: the plan protocol collapses to one re-callable mermaid plan_init
(seven mutation tools deleted by design; statuses derive from quiz checks;
live quiz-nodeId validation with mid-turn retry). The steer-starvation arc:
steers deliver coalesced with the learner's answer (claimSteers), persist
dedupes same-id continuation copies, jobs settle-sync defers while a turn
is busy. Research steers gain the collapsed full-brief affordance. Model
reasoning effort set to minimal across roles.
With continuation coalescing, a job flips done at CLAIM time — while the
learner's own answer turn is still streaming. The poller's settle re-sync
fired on the flip, swapping in a mid-turn record snapshot mid-stream; the
steer after the assistant message broke the SDK's in-place continuation
merge, and the streaming response landed as a SECOND message with the same
id — duplicate React keys in the live transcript (the user's live test hit
it; the record itself stayed clean).
A settle during a busy turn (local stream OR server turnInFlight) now marks
a pending sync; the first idle poll tick adopts the finished record. This
also fixes the latent multi-job race: steer B settling while steer A's wake
turn is in flight would have grabbed the same mid-turn snapshot.
Out-of-lane surgical touch (flagged for master review): the wave-1 poller
block in components/chat.tsx.
With a steer coalesced into a quiz-answer continuation, the steer sits AFTER
the assistant message being continued, so the SDK's onFinish list carries
the input copy AND the extended response copy as two entries with the SAME
id — persistTurn saved both, the jobs poller's syncFromServer imported the
dup, and React keyed a single message twice (the user's live test hit it).
withMessageIds now dedupes by id keeping the LAST occurrence — the SDK's
final merge state (a superset of the earlier copy) — which also yields the
right semantic shape: [user, steer, assistant(extended)].
Out-of-lane surgical touch (flagged for master review): turn-service.ts.
Squashed the live session's already-corrupted record by hand (same dedupe).
The bug (seen live): a teacher that ends every turn with a new pending quiz
keeps the wake pipeline's idle gates permanently closed — the steer sits in
'delivering result' forever while the learner answers check after check.
- claimSteers (server/jobs.ts): the chat-handler claims every queued,
undelivered steer the instant a client turn wins the turn registry, and
appends it after the learner's answer — conversion-safe, the answered
quiz carries its output. Every answer flushes pending steers immediately.
- The pump keeps its post-idle duplicate-guard (re-check steerDelivered
before firing), so a claim racing the pump converges to exactly one
delivery either way.
- The stale-job sweep now skips chats with a turn in flight (a healthy but
slow wake no longer gets falsely re-queued as 'recovering').
- Out-of-lane surgical touch (flagged for master review): the 3-line
handleChatRequest seam (claim after beginTurn, persist+stream 'outgoing').
- Verified live end-to-end on the stuck session: answering the pending quiz
fired the steer + wake turn within a second; job flipped done/delivered.
- plan_init is now the ONLY plan tool: a mermaid 'graph TD' block parsed by a
constrained-subset parser (mermaid-plan.ts) with line-precise errors; the
seven mutation/tracking tools are gone by design (2026-09-03 design pass).
- Re-call plan_init = sanctioned wholesale rewrite; slugs are permanent.
- derivePlanStatuses (contracts/plan.ts): done = passed check tagged with a
quiz nodeId; current = open check's node else first topo-ready node — the
tracking the teacher no longer declares by hand.
- questionToolsFor(ctx) (tools.ts): the quiz schema chains a dynamic plan-
nodeId validation against the LIVE record — an invalid nodeId is rejected
at the call boundary and the teacher retries mid-turn, before any UI.
Static questionTools stay validator-less for the default path.
- Prompts rewritten for mermaid authoring + derived position.
- Out-of-lane surgical touches (flagged for master review):
src/core/question-core.ts (quizInputObject export + nodeId description),
src/core/contracts/plan.ts (derivePlanStatuses + doc).
- The learner's own typed Other answer now renders INSIDE the options
container in the answered card, styled exactly like its selected
siblings (border-primary bg-accent) — it is an option, not a loose
quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
(metaKey || ctrlKey — the Gmail convention, same handler on every
platform). Plain Enter stays a newline: the field is for dumping.
A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
discoverability (text mode keeps Enter-to-submit, no hint).
The extractor's per-session understanding notes were free-form strings;
the model freely appended em-dash justifications ('shaky — the learner
could state the headline distinction but calls the machinery behind it
foggy'), which overflowed the detail card's session rows and squeezed
the session names.
The note becomes a CLOSED named-word scale — solid / shaky / accepted /
resisted / not yet engaged (DECIDED 2026-09-08, no numbers) — enforced
by the zod enum in propositionSchema: a wrong-shaped note is rejected
at parse time and the agent retries. Justifications are dropped, not
stored (user decision: not necessary). Extractor and auditor
instructions updated to demand exactly one word.
Renderer: the session row keeps the session NAME as the main thing
(flex-1, truncates) and the level chip is bounded (max-w-28,
whitespace-nowrap, truncate) — overflow impossible even for legacy
prose notes in old ledgers.
Vision doc: §1 extractor bullet + event-ledger line updated; state
model line notes the five-class derivation was removed; amendments
table row added.
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:
- A labeled composer-TWIN field ("Your thinking") between the options
and Submit, docked with the submit button inside it exactly like the
chat composer's send. The placeholder invites dumping ("Why this
answer? What are you unsure about? Say as much as you like — the
teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
that fills on selection + inline input, styled like its siblings — so
picking Other never displaces the thinking field; typed text survives
deselection.
- The answered card promotes the thinking to a quoted first-class
block above the verdict, labeled "Your thinking" — the transcript
reads as a dialogue of thinking, not a buried "Note:" footnote.
The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
The background extraction pipeline is invisible on the home surface —
essential observability per the user's directive: the graph view must
show what is currently running (extracting / auditing / merging) and
for which chat.
- extraction.ts: module-level registry of in-flight runs (chatId,
phase, startedAt), updated at each phase transition in runExtraction
and cleared in finally (done or failed). extractionStatuses() is the
read; one process, one registry.
- GET /api/graph/status: thin transport over the registry, chat titles
joined from the chat store.
- graph-view: polls every 2s and shows one chip per running pipeline
(spinner + phase + title + elapsed seconds, bottom-left); when a run
finishes (some -> none) the graph refetches, so merges land live.
Status is cosmetic — poll errors never surface.
- Unit test drives a mocked pipeline run and observes the registry
mid-flight: 'extracting' during the extractor's generate, 'auditing'
during the auditor's, empty after.
The extractor's per-proposition understanding observations (named-word
scale: solid/shaky/accepted/...) were ledger data since the redesign,
with no surface. This exposes them at read time and shows each
session's note as a small chip on its session row in the node detail
card — the per-session confidence history the user asked for, one
observation per (node, chat), latest wins within a chat.
- GraphSnapshot.nodeObservations: nodeId -> chatId -> note, built in
graphSnapshot() from observation events (pure read-time join).
- Sessions list rows: link + observation chip.
- Session-relative judgments stay session-scoped: they render next to
their session, never as node state (the state model stays pending).
The five-class NodeStateClass (taught/known/missed/dont-know/candidate)
was transitional scaffelage from the 2026-09-02 redesign, deriving state
by latest-quiz-wins — exactly what the philosophy rejects: understanding
is inferred at read time from the real state model (still pending
ideation), never encoded as quiz-derived classes.
- contracts: NodeStateClass deleted; GraphSnapshot no longer carries
nodeStates (objective graph + sessions only).
- core: deriveNodeStates replaced by latestQuizOutcomes — the raw
event fact (nodeId -> latest quiz kind), consumed ONLY by the
teacher's graph-as-context selection, never by the home renderer.
- graph-context: hotspot selection re-keyed to outcomes — groups are
now 'Hotspots (last answer wrong or don-t-know) / Answered correctly /
Never probed'. Same diagnostic selection, no state taxonomy.
- graph-view: nodes render uniformly (accent color, size 8); the panel
is search-only; the detail card's state badge is gone. Quiz outcomes
remain ledger data for the future state model.
Verified live (headless): uniform discs (single color/size, zero state
pixels), search-only panel, card = name + statement + sessions, no
console errors.
With real extracted data, full-sentence statements as canvas labels proved
unworkable: 10-line label blocks, illegible overprint clusters, bottom-edge
clipping. Per the user's 2026-09-08 decision:
- GraphNode/ExtractedProposition gain `name` — a very short, glanceable
label (glossary-headword style: "Will to power"), presentational only:
not unique, not identity, never precise. Set at creation; link-appends
never change it (first line defines; labels stay stable across sessions).
- `statement` keeps its full precision and becomes the identity anchor;
the detail card shows it under the name. `description` is dropped.
- Extractor coins the name (freshest session context); auditor carries it
through on create, rewording only clearly unglanceable names; linked
propositions keep the existing node's name.
- Renderer: canvas label = name; search matches name + statement;
detail card = name (bold) + statement (muted).
- Store guard requires name (pre-pivot node lines are skipped as legacy);
vision.md node-schema bullet + amendments table updated (2026-09-08).
The minimal implementation of the 2026-09-02 graph vision (docs/vision.md §1):
- contracts/graph.ts: every node is a TRUE proposition (statement is both
rendering label and identity key; required description; no kind, no name).
Edges are follows (derivable) / supports (grounds without entailing) with
message-granular provenance points {chatId, messageId}. Events reduce to
correct/miss/dont-know plus observation (understanding notes); no taught
events, no misconception kind.
- extraction.ts: extractor is TREE-BLIND (transcript only, no plan tree) and
free-form; auditor sees findings + the current graph, owns faithfulness and
identity (link-or-create verdict per proposition, recorded as linkTo);
transcriptView tags every line with its message id (provenance anchors).
- core/graph.ts merger: applies verdicts deterministically — linkTo appends
a link line (phrasings join aliases, provenance unions; reads merge by id),
absent creates; NO string matching anywhere. Quiz events rebuilt from tool
parts with attribution-only nodeId resolution; understanding notes become
observation events (obs:<sourceId>); mapping lines are the verdict audit
trail and make re-merges idempotent.
- deriveNodeStates: transitional — latest quiz event wins (known/missed/
dont-know, else candidate); taught unreachable until the state-model
ideation lands.
- graph-context.ts + 50-graph-context.md: follows chains, hotspot grouping,
state vocabulary updated for the new event kinds.
- graph-view.tsx: node label is the statement; detail shows statement +
description.
- tests: merger verdicts (link, no-string-match, cross-chat assertions),
tree-blind pipeline end-to-end, follows vocabulary in context tests.
276/276 green.
The Research-complete one-liner is now a native <details> disclosure:
collapsed it reads exactly as before (Research · complete · N sources);
one click expands the full persisted brief + source links via
ResearchBriefCard. No fetch, no state, no protocol change — the
data-subagent-artifact part in the record is the single source of truth,
which is what made this affordance 'feasible later' back in wave 1.
The brief still never clutters the transcript by default; the teacher's
wake turn remains the primary channel for weaving it into the lesson.
Test updated: the brief + sources now assert PRESENT inside the details
(and collapsed — no open attribute) instead of absent from the DOM.
Gates: typecheck, 279/279 tests, lint, build — all green.
The top-right overlay doubles as the state legend and the filter
surface: a search box (matches name or statement, case-insensitive)
plus one row per node state in reading order (taught, known, missed,
don't know, candidate) with its color dot, label, and total count.
Clicking a row toggles that state's visibility.
Filtering flows through sigma's official nodeReducer/edgeReducer
settings: hidden nodes drop out of rendering, hover, picking, AND
labels; edges drop when either endpoint is hidden; an open detail card
closes if its node is filtered out. Pure predicate (isNodeVisible) is
exported and covered by 7 tests; the reducers just consume its
verdict. Verified live: search 'Nietzsche' 5457->1687->5457 label px
(exact restore), known toggle drops 13 discs+labels and restores,
nonsense query shows the 'No matching nodes.' hint with zero labels,
counts 0/13/0/0/10 match the ledger.
- components/chat-transport.ts (NEW): createChatTransport(chatId) — the
chat.tsx seam is now a one-line swap. Its fetch turns a 409 on POST
/api/chat (a learner send racing the wake pipeline's ~1s adoption window)
into: wait out the in-flight turn (poll turnInFlight, 30s cap), refetch
the record, MERGE the outgoing messages onto the fresh server list, re-POST.
A bare retry would clobber the steer + wake messages via the write-ahead
persist (steerDelivered makes that loss permanent) — merging is the whole
point. Unit-tested: pass-through, wait+merge+reorder, dedupe by id,
repeated-409 loop, bounded give-up.
- src/core/pending.ts: findPendingJobCall deleted (dead since wave 1 —
job tools resolve server-side with an ack, nothing pends client-side).
JOB_TOOLS kept (runner.ts still uses it). Header doc rewritten.
- src/server/jobs.ts: GRACE_MS 1500 → 750, with the evidence in the
comment: the race window is only the learner's answer→POST flight
(sendAutomaticallyWhen dispatches synchronously; ~10ms localhost,
<300ms realistic RTT — 750 is 2.5× worst case) and misses self-heal
via the pump's gate recheck, the 409 backoff ramp, and the new
transport merge-retry. POLL_MS=400 kept (sampling granularity only).
- tests/jobs.test.ts: prompt-size guard — every fragment ≤8k chars,
composed prompt ≤60k — the README-leak class (815 bytes ≈ 200 tokens
per teacher turn before wave 1's fix) can't silently recur.
Gates: typecheck, 279/279 tests, lint, build — all green.
The fit zoom was capped at 1×, so any graph smaller than the panel
rendered at natural size, centered, with big empty margins above and
below — the common case for early-session chains in the 384px panel.
The fit now scales up to fill (FIT_ZOOM_MAX 1.5 — a lone node doesn't
become a billboard), floor-clamped as before. Math extracted into
fitZoomFor() and unit-tested (scale-up, cap, width/height-bound,
floor, unmeasured viewport).
chat.tsx woven by hand: UI's session-surface layout (transcript column +
right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped
in sub-agents' JobsContext.Provider; the message map carries both the
steer ternary (steers bypass the Message wrapper — the model-facing glue
text never renders) and UI's PhaseDivider emission (steers are verified
phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column
(SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton.
contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem,
additive above the async-protocol section).
Smoke-verified on the merged tree: steer renders bare as its artifact
badge with no learner bubble, no rendered glue text, and no spurious
phase divider at the steer boundary.
composeInstructions loaded EVERY .md in src/agent/prompts — including
README.md, the directory's human documentation, which rode into the
system prompt as its own section (visible in OpenRouter logs, old POC
paths and all).
The loader now enforces the documented convention: only numeric-prefix
fragments (\d+-*.md) compose. README.md stays in the directory as docs
and can never reach the model again. README's rule text updated to
match; composeInstructions exported for tests; regression test asserts
the README never leaks and every numeric fragment composes verbatim.
Figures now always fill the card's width (h-auto keeps the aspect
ratio): narrower svgs scale up instead of leaving a white strip on the
right when they carry their own background color; oversized ones still
shrink to fit. bg-white stays as a neutral backing for svgs without
their own background.
Three refinements from live testing:
- Steer messages no longer sit inside the user Message container (the
bubble background + right alignment it gave artifact cards). chat.tsx
renders SubagentSteerCard directly in the transcript column — full
width, centered, same register as the teacher's artifacts.
- Research steers render a quiet "Research complete · N sources" badge
(the dispatch-settled idiom), not the full brief: briefs are long, the
wake turn weaves them into the lesson anyway, and the full text stays
persisted in the artifact part — a "view full research" affordance
can be added later without any protocol change. Failures keep the
failure card (the brief explains what went wrong).
- The sub-agents' verification notes (visual/demo builder notes) are
model-facing glue and no longer render on success; failure notes
remain the error explanation.
SSR-verified against a synthetic record: badge + SVG bare in the
transcript (no is-user wrapper), notes/brief confined to the RSC
payload. 229/229, typecheck, lint, build green.
The pi-style ack protocol (previous commit) left the client describing the
old world: artifact cards read the ack string as a result ("could not be
displayed"), the only job poller rendered on input-available parts that no
longer exist (so wake turns landed invisibly until re-navigation), steers
rendered as learner bubbles with no SVG anywhere, and server-persisted
assistant messages carried empty SDK ids (duplicate React keys).
- persistTurn (turn-service): fill empty message ids at the canonical write
— server-only messages (last turn's, wake turns') now key uniquely;
client-authored ids untouched.
- chat.tsx: ONE central /api/jobs poll (fast while jobs run, slow discovery
cadence, off for job-less sessions) drives every job surface, re-syncs
once when a job settles (the record already holds the steer + wake
response at flip time), and adopts server-side wake turns via the same
turnInFlight machinery as returning mid-turn. Steers branch to the
artifact card — never a bubble. Composer no longer gates on jobs.
- /api/jobs now reports turnInFlight alongside the job list.
- dispatch-card: the tool-call box — live activity by toolCallId while
running, quiet "delivered"/"failed" one-liner after; the ack is visible
observability, the artifact lives in the steer.
- steer-card: renders the delivered artifact by kind (research brief /
sanitized SVG / sandboxed demo iframe) from the data-subagent-artifact
part; result renderers extracted and exported from the artifact cards.
- sub-agents-panel: right-sidebar section above the plan tree listing
running sub-agents (kind, elapsed, live activity) — vanishes when idle.
- tests: dispatch/steer/panel render tests re-target the new protocol;
persistTurn id-hygiene suite. 229/229 green, build green.
Out-of-lane touches for master review: chat.tsx + components/** (cleared
this wave), app/api/jobs/route.ts, src/server/turn-service.ts, contracts
protocol doc §2. Live-verified on :3003 (full loop, SSR DOM checked).
Redesign per review feedback: the colored phase strips were too busy —
removed. One small dot now carries two channels:
- COLOR = phase (sky/violet/emerald, same system as chip + dividers)
- FILL + MOTION = what's owed:
- pulsing = teacher is working (the only state that deserves motion)
- solid = a question awaits the learner's answer — the dominant
resting state of real sessions, so deliberately calm
- hollow = idle, nothing owed
The amber attention channel is gone entirely (it would read as a
phantom fourth phase). The meta line now leads with the useful fact:
"Waiting for your answer · 5m ago" / "Teacher is working…" — the
message count only shows when idle. Ordering stays pure recency.
The job tools (research/demo/visual) switch from client-side turn-breaking
calls to pi's model: execute resolves IMMEDIATELY with a fire-and-forget
ack, the teacher's turn continues, and the result arrives later as an
injected user-role steer message that wakes the teacher in a new
server-side turn.
- contracts.ts: THE ASYNC PROTOCOL rewritten (ack now, steer later); steer
types (SubagentSteerMessage, metadata.subagentResult + data-subagent-
artifact part), pure buildSubagentSteer/steerDelivered/isSubagentFailure
- tools/jobs.ts: jobTools(ctx) factory — server-side execute creates the
job (deduped by toolCallId), fires spawn with the dispatch-time input
(the part isn't persisted yet at execute — a transcript scan would race)
- runner.ts: runJob(jobId, input?) builds the steer and hands it to the
injected deliver seam; pipeline owns the done/error flip (a polling
client re-syncs once, at the end); failures still steer
- server/jobs.ts (new pipeline): per-chat serialized queues on globalThis,
idle gates (turn liveness + pending question, 1.5s grace), 409 retry
with backoff, idempotent per jobId, stale-job recovery sweep (re-deliver
or re-spawn); wake turns run through the same handleChatRequest
composition via setTurnRunner injection
- chat-handler.ts: wires runner+deliver+tools ctx; drops spawnTurnJobs
from onFinish; teacher default registry = question tools only
- plan.ts: PlanToolContext split out (plan tools need no job wiring)
- visual-maker: edit_svg added (pi parity — exact-match single
replacement, fuzzy context on a miss); researcher: pi's kept/dropped
source accounting restored
- prompts 20-jobs/40-demos: async discipline rewritten (announce and keep
teaching, no waiting/polling/fabricating, parallel dispatch, teach with
the artifact when it arrives)
- tests: jobs.test.ts rewritten (dispatch ack mid-turn, runner steers,
wake pipeline: deferral/409/idempotency/failure), demos.test.tsx runner
tests ported to runJob + fake deliver; 222 green
FLAGGED surgical touches outside the stream's owned files, for master
review at merge: src/core/contracts.ts (protocol doc + steer types),
src/core/ui-message.ts (job tool + data-part types), src/server/
chat-handler.ts (wiring), src/server/turn-service.ts (spawnTurnJobs
retirement), src/agent/teacher-agent.ts (default tools), src/agent/tools/
plan.ts (context type split).
Verified live on PORT=3003 with real models: teacher dispatched research
mid-turn, researcher ran real searches/fetches, steer injected on quiz
answer, wake turn wove the brief in citing its sources, job flipped done;
a client/wake race correctly 409'd.
- Chat page: the tree panel is now a full-height sibling of the whole
transcript column (header + scroller), exactly like the left sidebar —
the session header (name + phase chip) spans from the left sidebar's
edge to the panel's edge instead of riding over the panel.
- Plan graph: the zoom controls sit inside the pan surface, so their
pointerdown started a capture drag and the click never reached the
button (felt dead). startDrag now ignores presses on buttons.
- Fully zoomed out IS the fit view: zooming to the floor snaps to the
centered fit (pure zoomedGraphView), pan is locked there (no drag
begins, cursor-default instead of grab), and sitting at the floor
re-arms auto-fit so later tree edits keep the graph fitting.
- Fit padding 48px → 16px: the default view fits more graph.
The nested list stays as the default/collapsed view; the new expand
toggle (list header) opens the graph — a hand-built SVG diagram
(mermaid STYLE, no mermaid renderer: full control of the styling so it
speaks this app's design language):
- Top-down layered layout (topoLayout depth = rows, authoring order
within a row, rows centered on the widest) — vertical space is the
cheap axis; boxes + vertical bezier arrows with arrowheads.
- Status coloring matches the list marks: done nodes are colored in
(teach-phase green tint — the graph fills in as the session
progresses), current = the highlighted position, next = unlocked
ring, upcoming = quiet.
- Drag pan + wheel zoom (to the cursor, non-passive), +/- and
fit-to-panel buttons. The zoom FLOOR is fit: fully zoomed out, the
whole diagram always fits the panel — it can never disappear into
nothing. Auto-fits on measure/layout change until the learner takes
manual control; fit re-enables it.
- Panel widens to w-96 in graph mode; mode persists
(lh:tree-panel-mode: rail|list|graph, legacy key migrated).
- wrapTitle + graphLayout pure, unit-tested; TreeGraph SSR-render
tested (statuses, arrows, wrapping, data-next).
- components/phase-divider.tsx: PhaseChip (header, next to the session
title: phase-colored dot + Probing/Planning/Teaching) and PhaseDivider
(the transcript boundary: color-coded dotted rules + a small chip —
'Plan · N nodes' at the plan seam, 'Teaching' at the teach seam; probe
never renders, phases only advance). planNodeCount reads the seam
message's first plan tree.
- chat.tsx: dividers render at MESSAGE boundaries (phaseTimeline, one
linear fold) — the seam is the transition to the message's phase, so
the seam message's prose sits below its divider. requiresReveal now
uses phaseAt3 teach (an occupied node), so probe- AND plan-phase
questions auto-open.
- Retired the part-level plan-divider (artifact registry): the boundary
is message-level, not part-level. Its tests replaced by
render-phase-divider tests; core/phases gains tested phaseTimeline.
- SessionRow (extracted, exported for tests): leftmost phase strip
(bookmark tick, per-phase color: probe/plan/teach) + status dot left of
the title — pulsing amber attention dot when an answer is owed, quiet
breathing dot while the teacher works, nothing when it's the learner's
turn. A pending answer outranks 'working'.
- globals.css: phase + attention color tokens (one system shared with the
upcoming header chip and dividers) and the two dot animations, both
disabled under prefers-reduced-motion.
- SessionsSidebar polls /api/chats every 2.5s while open (payload is tiny
now that busy/phase/pendingQuestion come from the list) and pauses while
the tab is hidden; fetch-on-navigation preserved.
- core/phases.ts: Phase3 (probe/plan/teach) + pure derivations —
phaseOfTree (point-in-time, from the stored plan tree: no nodes → probe,
no occupied node → plan, occupied → teach) and phaseAt3 (monotonic
transcript fold for the dividers: failed plan_init never marks a seam;
phase only advances).
- contracts.ts: ChatSummary gains record-derived phase + pendingQuestion;
ChatListItem adds liveness-merged busy.
- store/chats.ts listChats computes the record-derived fields (tree +
pending question scan).
- api/chats route merges busy = turn running || running background job.
Lane crossing granted by master: contracts/store/api (schema arbitration).
Data-only inference: pre-plan planning reads as probe until the plan lands.
Schema: GraphNode + ExtractedProposition carry 'name' — a short 2-6 word
header, the rendering handle. Node theory made explicit: a node is an
objective world-facing fact; the learner's relationship to it lives
entirely in the event ledger (state labels are derived, never stored).
Extractor + auditor instructions enforce it — misconceptions state the
wrong idea itself as a claim, never 'the learner believes Y'. Legacy
ledger lines without a name derive one from the statement on read.
graph-view: labels use the name; the detail panel shows name as header
and statement as body.
The bug: answering a quiz and navigating away mid-turn, then returning,
re-prompted the same question. Root causes: (1) the answer was durable only
at onFinish — every mid-turn view read the stale pre-answer record;
(2) the server had no notion of a turn in flight; (3) remount treated the
stale record as current, with no re-attach path. Worse, the stale dock let
the quiz be answered again, racing two turns into a last-writer-wins
saveMessages clobber.
Fix (four pieces):
- Write-ahead persist in chat-handler: the request body IS the client's
canonical state — saved to disk BEFORE the stream starts. The answer is
durable the instant the request arrives; crashes recover via the existing
mount continuation.
- Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId →
in-flight map with a TTL leak guard; beginTurn rejects concurrent turns
(409) so one turn ever owns a chat. endTurn runs in onFinish AFTER
persistTurn, so pollers that see not-in-flight always read a finished
record; a turn-start failure clears the registry (no lockout).
- GET /api/chats/[id] reports turnInFlight; the chat page passes
initiallyInFlight to the client.
- Client re-attach (components/chat.tsx): while in flight, busy shimmer +
composer gated + mount continuation suppressed; poll /api/chats/[id] until
the turn finishes, then syncFromServer adopts the completed transcript.
Stop stays local-stream only (a server turn has no client request to
abort).
Verified by scripted-model tests: concurrent turn 409 + registry lifecycle,
write-ahead durability mid-turn, failed-turn lockout guard.
quizInputSchema now enforces (via pure quizInputProblems + superRefine) that
correctAnswer resolves against option values, options are non-empty and
unique, and multi-answer requires multiSelect — schema-valid now implies
gradeable. The AI SDK already returns invalid tool input to the model as a
tool-error part (verified in ai@7 run loop), so the teacher retries in the
same turn. chat-handler onError passes tool-input validation messages
through to the client (masked 'An error occurred.' for everything else).
Covers the Gate 3 GLM failure: correctAnswer slug matching no option.
The knowledge graph's write channel is now the pivot's target
architecture (vision §1 construction):
- src/agent/subagents/extraction.ts — the pipeline factory
(createExtractionPipeline({ chatStore, graphStore, extractor?,
auditor? }); models env LH_EXTRACT_MODEL ?? LH_MODEL):
* transcriptView(): the compact view both LLM stages see — final plan
tree with node ids, conversation verbatim, QUIZ lines with outcomes,
mark_done lines; job tool parts are one-liners, plan ops fold into
the tree line.
* Extractor: transcript → SessionFindings (propositions incl.
misconceptions and prose-only supports edges, attribution for
UNTAGGED probes, all with verbatim evidence) via one zod-validated
submit_findings call.
* Auditor: transcript + findings → the verified subset + drops (the
faithfulness net) via submit_review.
- src/core/graph.ts — emitChat/foldStaleChats/replayFinalTree retire;
mergeFindings() replaces them as the pipeline's deterministic apply
stage: exact/alias identity resolution (one mapping line per (chat,
graph node)), edge dedupe, and event rebuild FROM THE TRANSCRIPT's tool
parts — kinds/timestamps/refs/notes deterministic, the LLM's only event
say being WHICH proposition an untagged probe tested (retroactive —
the vision's open question #3, resolved structurally: tagged events
resolve deterministically; attribution rescues untagged and
tagged-but-unmapped ones; unattributable land null + question text).
pendingQuizAnswers() is the trigger helper.
- Trigger (turn-service.ts triggerExtraction, replacing the old
foldTurnIntoGraph body): once a chat accumulates
EXTRACTION.everyNQuizAnswers (LH_EXTRACT_N, default 3) new answered
quizzes, fire the pipeline async — single-flight per chat, failure
checkpoints nothing and re-runs on the next trigger. Deliberately a v1
placeholder isolated in ONE seam, trigger-agnostic pipeline.
- GET /api/graph is now a pure read (foldStaleChats gone).
- NodeMapping.treeNodeId → sourceId (the mapping key is now the writer's
local proposition id — tree node when tree-derived); store strict-read
schema updated. Local ledger data regenerates through the pipeline.
- Tests: graph.test.ts rewritten around mergeFindings (identity,
attribution paths incl. ghost-rescue, idempotency, cross-session
provenance, trigger counting), graph-context.test.ts merge section
converted to findings-driven, NEW extraction.test.ts — transcriptView
units + the full pipeline with mock models (audit drops, failure
checkpoints nothing, single-flight). 195/195, tsc/eslint/build clean.
Probe attribution (vision open question): probes attach retroactively by
the extractor against the whole transcript; a pre-tree probe maps to a
proposition the tree only articulates later. Fuzzy/embedding merge
adjudication stays deferred (conservative exact/alias only).
The chat turn is now composed, not implemented, by chat-handler.ts:
- src/server/turn-service.ts: one named, error-contained seam per turn
responsibility — buildTeacherContext (W8 read channel), persistTurn
(canonical state write), spawnTurnJobs (async protocol kick-off),
foldTurnIntoGraph (session→graph consolidation; the extraction-pipeline
stage swaps this body behind the same seam). Each takes its deps
explicitly; failures log one [chat <id>] line and never touch the
response.
- chat-handler.ts wires: storage ports (stores), the job runner, the
teacher tools (ctx = chatId + injected ChatStore), and the services.
It no longer contains any logic beyond request validation.
Agent layer now receives dependencies instead of importing them — a new
lint zone enforces it (src/agent never imports src/server or src/store;
verified to fire both ways):
- runner.ts: createJobRunner({ chatStore, jobStore }) factory returning
the spawn hook + the three job runners; the model-override test seam
stays module-level. Bodies otherwise verbatim.
- plan tools: persist through the ctx-injected ChatStore
(TeacherToolContext = { chatId, chatStore }).
- teacher-agent's dependency-free default registry is now the true
client-side set: questionTools + jobTools (jobTools was ctx-parameter-
shaped but never used its ctx — now honestly context-free). Production
always passes createTeacherTools({ chatId, chatStore }); tests that
drive a bare mock model get the client-side set unchanged.
Zero behavior change: tsc clean, 190/190 tests, eslint clean, production
build clean. Tests inject the same fs ports the chat-handler does.
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.
The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
pure — it takes the storage ports as explicit parameters (GraphDeps).
No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
(chatStore/graphStore/jobStore); bodies otherwise verbatim.
NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
(they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
and chat-handler wire from it; agent (plan tools, sub-agent runner)
call the port objects. The shell surface (@/src/server exports) is
unchanged.
Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)