Owner round (2026-10-06): markdown export of sessions, actioned from
the sidebar context menu — "Export as Markdown…" sits with the
management actions, above Delete (the destructive action stays last).
- GET /api/chats/:id/markdown (404 unknown): the whole session as one
attachment — text/markdown; charset=utf-8, filename from the slugified
title ("relational-databases-joins-normal-forms.md", non-ascii titles
fall back to "session.md"). Both session kinds export.
- The client is a same-origin anchor download; the route's
Content-Disposition names the file.
- sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id
anchors are extraction provenance, not learner-facing). The engine
joins the core barrel (pure, browser-safe) for its first consumer.
The practice join (owner-approved mid-round): practice tool results are
bare acks — statement, outcome, verdict, and the model solution all
live in the assistant's call arguments. The engine now folds them the
same way the view seam folds cards: Problem blocks render the pose's
statement (solution never leaks at pose time); Concluded blocks render
the outcome as prose ("Solved with hints.") + verdict, then the
pose-committed model solution as a blockquoted reveal via
problemInputForConclude (clean-poses-only, its refusal law). A
dismissed problem concludes as "The learner skipped this problem." —
no reveal. Errored poses render nothing (the failed-tool law).
1427 tests (route pins: headers, slug, anchorless body, 404; engine
pins: pose-time statement, the reveal, the dismissal refusal, the
errored-pose silence); tsc, lint, build green.
- core/compaction.ts: compactionCutIndex (backwards ~20k-token walk, NEVER
separates a tool result from its call — the cut extends to the call's
assistant message; a pending pose always survives), estimateTokens
(chars/4, pi's method), newCompactionMarker (header + summary; details
carry firstKeptMessageId/tokensBefore/model/at), serializeForSummary
(labeled text, tool results truncated ~2k), planTreeToText (verbatim
tree with transcript-derived statuses), buildCompactionPrompt (the four
ruled sections: learner understanding + evolution, teaching arc, plan
tree verbatim, current moment)
- toLlmMessages honors the cut: the ACTIVE (newest) marker renders ONCE
as the head; everything before firstKeptMessageId drops; corrupt cut
degrades honestly (everything renders, never throw)
- constants: KEEP_RECENT 20k, RESERVE 16k, DEFAULT_THRESHOLD 150k
- 16 test pins
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from
history if ever revisited. Removed: core practice contracts + pipeline
helpers, the PracticeStore port + adapter + data dir path, server
service + API routes (sets, clusters picker, practice aborts), the
practice pages + dialog/exam/list UI, chain-table's Generate handoff,
sidebar nav, activity-dock practice rows, and the partitioner/
problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5).
Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain,
chainsForSelection, formatChainLine — the chain table and graph
emission still consume them.
Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).
Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).
Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.
Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).
UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').
Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.
Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).
Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).
Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.
Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).
Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
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.
- Settings ▸ Graph grows the embedding-model picker (graph-row-embedding-model):
GET /api/models/embeddings' live-federated BYOK catalog (re-fetched on mount
and every settings save), rows priced from EMBEDDING_PRICES ("$x.xx/1M in",
honestly "—" when unlisted), ids shown AND stored as full colon-safe slugs
(openrouter:liquid/…:free is never split), plus an Unset row writing null.
Empty catalog = nothing keyed: the trigger BECOMES the deep link to Keys
("Add a provider key first…") — and deep-links fired while the dialog is
open now actually switch sections (initialSection was read once at mount;
the body follows it now — pre-existing gap, first exercised by this link).
- Re-embed gate (owner design): picking a model that differs from the stored
one holds the write behind a confirm strip carrying the honest node count
from GET /api/graph; zero propositions skip the strip (nothing to re-embed)
and land directly. Unset never confirms — the port just stops resolving.
The copy says the re-embed runs with the next background pass (the staleness
machinery — a settings PUT alone never triggers it; no server endpoints
were added).
- Gray-out, the searchStatus precedent: with no model configured, the ladder
rungs Embedding/Clustering/Labeling dim + disable with a "Needs an
embedding model" hint (master switch stays lit), and the stats-bar
GraphContextControl swaps its slider for a dimmed deep-link button to the
Graph section. Loading never grays; every settings save re-reads (signal).
- Cost chrome: the pipeline popover grows the Embeddings stage row
(costs.stages.embedding, wave 6's folded stage — "— · 0 in / 0 out" until
runs exist, same as its siblings).
- Master's ride-along: the core-barrel ChatDeleteMode comment now says two
type-only things (it always exported both).
Gates: tsc clean, 912/912 (8 new: price lines, model-dependency predicate,
unset/set SSR dimming; 2 ladder fixtures updated to the new no-model
semantics), lint 0, build ok. Live-verified on :3001: unset deep-link →
graph section, menu pricing + colon-safety (37 options), confirm strip with
"20 propositions", Cancel keeps the record, Unset writes immediately,
in-dialog empty-catalog deep-link lands Keys, Embeddings row in the popover.
- Right-click any session row: MenuShell context menu, exactly two actions
(owner ruling): Rename and Delete… — MenuShell discipline (placement,
focus, arrow cycling, lh:menu-open announcements), agent-section's
outside-click/Esc contract with [data-lh-menu] carve-outs.
- Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur
commits through the existing plumbing — PUT /api/chats/:id then
lh:chat-renamed. The open chat header now LISTENS for the event too, so
renaming the active session from the sidebar updates the header in place
(it only dispatched before).
- Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure
seam for render pins): "Delete the session only" (preserve-graph — the
graph keeps every contribution) vs "Also remove its graph traces"
(delete-nodes — shared topics survive, only-this-session topics die).
409 turn_in_flight/jobs_running and 503 extraction_winding_down render
the server's line verbatim with a same-mode Try-again.
- Success: the sidebar patches its snapshot instantly, the active session
navigates home (chat unmounts — drafts die with it), and lh:graph-revision
announces the returned revision — the graph view refetches the snapshot
AND costs (deletion is the one writer the status poll can't see).
- ChatDeleteMode type re-exported through the core barrel (the shell's one
needed seam from core/graph-deletion.ts).
Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001:
context menu open/Esc, inline rename (row + API + active header), both
delete modes on scratch sessions, simulated 409 + retry-then-succeed,
active-session navigation home, graph+costs refetch deltas on home.
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
auth is the Bearer header, not the legacy body api_key; Brave is
X-Subscription-Token) + engine resolution per owner decisions: a key
ALONE activates its engine (no engine choice needed), explicit
agent.search.engine pins the preference (both keys: Tavily first),
a pinned engine with a missing key is honestly unavailable — no
silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
(CSE closed to new customers, dies Jan 2027 — stored values become
preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
(length guard) join. UI keys-section TOOL_IDS now derives from the
catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
the settings gray-out; researcher-disable semantics ride the same
signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
web.ts's WebSearchResult is now the core SearchHit (re-exported).
- Cluster namer bills the money channel now: per-step usage + model
captured via onStep, persisted as NamingRunUsage records to
data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
runs persist too — money spent either way). callUsage promoted to
extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
notes-source runs, and naming runs into stage totals (extraction /
audit / naming) + newest-first recent runs. Dollars INFERRED read-time
via prices.ts, never stored; unpriced models excluded-but-counted
(render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
expandable breakdown panel (stage rows with tokens secondary, recent
runs with time/title/merged/cost); fetched on mount, refetched when a
pipeline run finishes; merged into one bottom-left stack with the
running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
naming path), store idempotency + strict reads.
The auth/keys wave (2026-09-19): the server now owns the flat auth.json
map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev,
gitignored). The store reflects itself into the child's env at boot with
fill-gap precedence — real env (incl. .env.local in dev) always wins —
so every consumer keeps its plain process.env reads and saved keys take
effect immediately, no restart; main.cjs's spawn-time injection retired
(an env copy would shadow every settings-page change).
Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with
shape-only validation (no live pings — owner decision), 400s on bad
pastes, last-4+length masking (plaintext never leaves the store),
unknown file vars preserved + surfaced. Routes: GET /api/keys,
PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the
env-sanitizing prologue (a dev shell may export real keys; tests use
throwaway values only).
The other half-wired seam: Platform's settings 'agent.prompts' switches
had no consumer in the agent layer, and their effectivePromptSegments
mirrored the PRE-sources-v1 loader — the shared/ fragments dir (the
learner-files doctrine) was invisible to it, so a naive delegation would
have silently dropped the segment from both agents' prompts.
- src/core/prompt-segments.ts (NEW, pure): the mirror-layout path
grammar + segment types, defined exactly once; core stays zero-IO.
- src/agent/prompt-segments.ts (NEW, the CANONICAL discovery): base +
shared/ + per-agent fragments, override resolution, allSegmentPaths.
Composition is an agent concern; the agent layer reads its own tree
(the architecture law pins agent and store apart — the first direct
refactor attempt tripped the enforced import/no-restricted-paths
zone, hence the three-layer split).
- src/store/prompt-overrides.ts: slimmed to the override-file CRUD
behind the core port; shares the core grammar. Its doc no longer
claims to be the canonical mirror.
- teacher-agent: composeInstructions(kind, switches?) composes through
effectivePromptSegments — a segment whose switch is ON and whose
override file exists composes the override text. Switches-less calls
use the cached pure-source composition (byte-identical to the old
loader, pinned by test); AgentBuildOptions.promptSwitches threads
effective.agent.prompts per turn, composing fresh so override-file
writes flip the very next turn.
- server barrel: re-exports the agent module's resolution for the
settings routes (server→agent, the lawful direction).
Tests: byte-identical source pin, override swap-in (on/off/file-absent),
shared/ overridability in both agents, agent-construction threading,
updated segment-list pins with shared/ in composition order. This file
keeps owning PROMPTS_OVERRIDE_DIR (parallel-test discipline).
Gates: 671/671, typecheck, lint, build.
The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
binding) + lh:keymap localStorage overrides + event normalization.
The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
listener (window level, bubble) owning the pieces no surface owns:
- Tab is DEAD (owner decision): browser tab-focus navigation is
abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
thinking level.
- Esc is a layered dismiss: topmost popover first (popovers
stopPropagation their own Esc — usePopoverDismiss + ModelControl),
then dictation stop (lh:kbd-stop-dictation), then abort of an
in-flight turn (lh:kbd-abort-turn; server-side wake turns are
honestly non-stoppable — the seam only stops local streams).
- Global actions dispatch lh:kbd-<action> window events (the
lh:chat-renamed pattern); surfaces that aren't mounted simply
don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.
Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).
Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.
Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
THE MESSAGE IS THE UNIT OF COVERAGE (vision-extraction-coverage.md): a
per-chat append-only ledger (coverage.jsonl) records which messages the
pipeline has extracted, by content fingerprint over exactly what the
extraction view sees (one shared line builder — core/coverage.ts); the
uncovered remainder is DERIVED at read time, never dual-tracked. A quiz
answer merging into the asking message re-opens only that message;
plan-block data-part attach never re-opens anything.
- The trigger (turn-service) replaces every-3-quiz-answers with
uncovered-tokens >= EXTRACTION.chunkTokens (4096) — quiz-less sessions
extract too, content size is the honest signal.
- runExtraction is now chunked: token-budgeted batches of the oldest
uncovered consecutive messages (a tool call + result never split), each
merged batch checkpointing its messages' coverage atomically — an abort
or error loses at most the in-flight batch; merged batches stay covered.
- Observation refs are batch-tagged (obs:<batchTag>:<sourceId>): re-runs
of the same batch dedupe; a re-opened message's fresh understanding
lands as a new event. Fixes the old obs:p1 position-collision.
- New API: GET /api/graph/coverage (exact per-session derived coverage +
running flags, most-uncovered first) and POST /api/graph/coverage/extract
(manual run for one session, 409 while in flight).
- Graph view chrome: the coverage button opens a panel with every
session's covered/uncovered tokens and a per-session Extract button —
the manual answer to aborted runs, errors, and dormant tails. Renders
even when the graph is empty: an empty graph with uncovered sessions is
exactly when it matters. Polls only while open.
- Notes imports keep the whole-file checkpoint path (coverage undefined).
- Tests: pure-core coverage suite (11), chunked-pipeline suite (3:
multi-batch + no-op, abort-keeps-earlier-batches + remainder re-run,
re-open alone + data-part invisibility), panel render suite (5);
pendingQuizAnswers and its tests deleted as dead code. 486/486.
Live-smoked on real GLM: manual extract of "teach me modal realism" ran
one batch, wrote 4 coverage entries, panel flipped to "1.3k covered ·
complete" with the button disabled, graph refetched (nodes 11), usage
persisted with real stats.
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.
Louvain only ever found 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:
propositions grouped by meaning.
- Embedder (src/server/embeddings.ts): in-process transformers.js v4
running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download),
MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs,
L2-normalized so cosine = dot. Model/dim mismatch voids the ledger
(full re-embed). Injected at the composition root — the agent layer
never imports the server layer (architecture law).
- Ledger (data/graph/embeddings.json): model + dim + vectors + the
persisted cluster partition. Reclustering runs at the END of every
embedding batch (post-merge hook, both front doors) — never per read
(the agglomerative pass is ~1.5s at 338 nodes).
- Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage
agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers
stay singletons by default; attachStragglers is the all-in toggle.
Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans
land in real topics, 10 stragglers.
- Consumers (snapshot, settle, namer, replay) read the persisted
partition — one cluster concept everywhere; core-survival label
matching unchanged (20/26 topics kept labels).
- Louvain retired: detectCommunities + graphology* deps deleted
(graphology-louvain had leaked into the client bundle since the
clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed.
- scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s
including the one-time model download).
The 'virtual node' realized as a centroid force: two allocation-free
O(n) passes per tick (membership resolved once at sim creation;
Float64Array sums). Measured indistinguishable from force-off at 338
nodes (271ms vs 272ms per 400-tick settle).
- createGraphSimulation: SimOptions.clusterPull + linkStrength +
velocityDecay (physics controls plumbing); degree-scaled link
stiffness so linkStrength=1 is exactly d3's default
- settleGraphLayout split into layout-settle.ts (it computes the
Louvain partition for the pull); layout.ts stays browser-barrel-pure
- barrel no longer re-exports clusters.ts: graphology + louvain are
fully out of the client bundle (verified against built chunks)
- replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15
tuned by sweep: cluster spread 63->36 world units, centroid
separation and total span unchanged (no blob merge, no collapse)
One engine owns the equilibrium: the merge settle and (next commit)
the live drag both run createGraphSimulation — d3-force link springs,
1/d charge, weak centroid gravity — so a drag's rest state IS the
server's rest state. The FA2-era anchor mechanism survives as a
post-tick POSITION-SPACE re-blend (retention 0.85): d3's forceLink is
a Hooke spring (strength*(d-L)*alpha), which overpowers any force-space
anchor at long range — the re-blend is alpha-independent and always
wins as the sim anneals (the 675-unit drift bug). Gravity retuned for
d3's 1/d many-body falloff (bootstrap 0.2 / incremental 0.12: cluster
spacing lands ~400 world units, the FA2-era density). Rim orphans seed
(and anchor) at 1.7x the CONNECTED core's mean radius — a max-span-based
rim fed back on itself and ran the layout away to 6000 units. Replay:
338 positions in 1.6s, old-node shift within a batch 4.8 (FA2: 23).
Fixes the zoom frame-rate collapse and rebuilds draw() around a single
camera transform, per the user's Obsidian-style sizing proposal.
Bug fix first: the eased zoom leaked a queued rAF callback per frame --
advanceZoom applies each step through d3-zoom, whose zoom event called
markDirty() from inside the tick loop, scheduling an extra draw while the
loop scheduled its own. Draws multiplied per displayed frame; sustained
wheel ran at ~20fps against a 120Hz display. markDirty now skips
scheduling while inTick (verified: 62 frames/500ms during a 28x zoom
burst, matching pan).
World-space model: every draw call now runs in graph coordinates under
one setTransform(k). Sizes are defined as screen-px-at-fit and converted
once per geometry build (/ kFit), so nodes, node labels, arrowheads and
the hover halo scale with the graph as you zoom (0.25x painted area at
half zoom, exactly k^2) while edge widths and hull borders stay
viewer-relative hairlines (/ k). Cluster hull geometry (convex hull +
three offset fills + solid boundary -- dashes dropped for now) is built
as cached Path2Ds per data/visibility/settings change; camera moves do
zero geometry math, making zoom per-frame cost identical to pan. Label
wrapping runs once per build, not per frame. Region labels stay
viewer-relative (18px at any zoom) as UI chrome. Picking radius scales
with the world-sized discs.
Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now
settles in ~260ms instead of ~750ms. Resize still paints synchronously.
Also fixes barrel-law lint violations from the settings wave (import
from @/src/core and @/src/server, not deep module paths).
detectCommunities(nodes, edges) in src/core/clusters.ts (new dep
graphology-communities-louvain): Louvain modularity over the undirected,
unweighted follows+supports structure (user-decided 2026-09-13). The
random-walk traversal runs on an inlined seeded mulberry32 so the same
graph always yields the same partition; community indices are still
unstable across edits, which is exactly why labelings (phase B) key to
sorted MEMBER SETS. follows+supports between the same pair collapse to
one edge; self-loops and dangling edges are skipped defensively; no
min-size filter here (the >=3 render threshold is a presentation rule).
Tests: two-cliques-plus-bridge split, determinism, singleton/empty
graphs, pair collapse, defensive skips, member sorting. 363/363.
Real-data smoke over the current notes graph: 338 nodes / 211 edges ->
138 communities, modularity 0.95, and the big ones are exactly the
learn-session topics (databases, differential forms, sales, servers,
vim, copulas...). 32 communities clear the >=3 render threshold.
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.
Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
(off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
"minimal" was silently clamped to low by streamSimple since the swap;
nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
from the catalog, thinking level from settings ?? config, CLAMPED to the
model's supported list (a settings change between models can never send
an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
read by chat-handler at turn composition: agents are stateless per turn,
so the change is next-turn-effective by construction. The turns log
still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
(core/contracts/models.ts) + config defaults; a second provider joins
the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
provider/model or unsupported level → 400 carrying the supported list,
never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
shipped openrouter.json (offline; same source as the engine + picker).
All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
thinking buttons; menus open ABOVE; fuzzy search over "provider id
name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
price/context; the thinking menu lists exactly the chosen model's
supported levels (GLM: low|high|max — no dead options, no "off").
Model switch drops an incompatible pinned level (falls back to config,
which clamps honestly) rather than carrying a 400.
Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).
Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.
The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).
- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
shared quizInputBase — strips the teacher-only nodeId, accepts both
prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
probe_done tool declares it (single source of truth), the seam runs
the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
old state reasoning and shape guards collapse into it.
Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.
Gates: tsc, lint, build, 328/328.
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.
The redesign (docs/probe-teach-split.md) abolishes derived occupancy in
favor of explicit events:
- Two agents over one transcript: createProberAgent (quiz without a
nodeId field, ask, probe_done) and createTeacherAgent (full toolset,
plan_init onward). The chat-handler construction branch keys on a
probe_done part in the transcript. MODELS.prober added.
- probe_done: server-side seam tool — carries the learner assessment
(strands + free-form edge read), refuses while a question is open or
the probe is already concluded, and queues a handoff steer through the
generalized wake pipeline (steers now dedupe by id, not jobId; job
finalization only for job steers). The teacher auto-wakes and authors
the plan; the learner sees the assessment via the probe-done card.
- phases: probe ends at a concluded probe_done, plan ends at a
successful plan_init — strictly sequential, no skipping, no legacy
compatibility (data wiped at the pivot per doctrine).
- Plan map = position, not completion: derivePlanStatuses reads only
tagged quiz CALLS (state/outcome feed nothing); current = latest
tagged node with consecutive duplicates collapsed, visited = earlier
distinct stays (a revisit stays in both — current wins at render).
PlanTree is structure-only; currentNodeId/doneNodeIds and the dead
mutation helpers are gone. No fallback current.
- Prompts split per agent (prompts/prober/, prompts/teacher/ over the
trimmed shared base); graph-context injection removed entirely
(shelved, docs/probe-teach-split.md §5); knowledge-graph context no
longer reaches either agent.
- Plan blocks are full-only snapshots at plan_init (neighborhood
machinery retired; the persistent DAG window is the future UI wave);
usage capture unchanged.
Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
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.
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.)