Three owner fixes for /chat/new (refinement round 4, 2026-09-24) —
the page must stop looking like a session that's already running:
1. NO PHASE CHIP: the header's <PhaseChip phase="probe" /> dies —
no session has started, so no phase exists yet. Active sessions
keep their chip (chat.tsx's derives from the live timeline).
2. TOP BAR STRUCTURE MATCHS SESSIONS: the header gains the same
border-b border-border as the chat header (h-15, lh-drag,
60px). The floating-bar look is gone; /chat/new now reads as
the same shell with empty content.
3. COMPOSER PLACEHOLDER = THE OPENING QUESTION: active sessions
keep "Ask, answer, or steer the lesson…" (now the DEFAULT of a
new Composer `placeholder` prop), but the fresh page asks what
the graph view's new-session input asks — "What do you want to
learn?" No lesson is in flight, so the composer must not read
like one is.
The ConversationEmptyState intro card ("The teacher will open by
probing…") stays, per owner confirmation.
Live-verified on :3001 /chat/new: no phase-chip node, computed
1px header border rgb(67,67,67) at h-15/60px, placeholder reads
"What do you want to learn?"; the empty-state card intact. Unit
test pins the default session placeholder AND the override.
903/903.
learning-harness
A standalone learning app (web + Electron desktop): a teacher agent (Next.js; the Vercel AI SDK's UIMessage protocol end-to-end, LLM engine pi-ai via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal session plan tree the teacher authors once (plan_init) and whose progress is derived from tagged quiz calls, and a persistent cross-session knowledge graph of what the learner knows — the graph is the app's home page.
Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, quiz outcomes reach the graph through the extraction pipeline as ledger events, and the graph feeds back as the teacher's session context (semantic top-K under a token budget) so probing compounds across sessions.
docs/architecture.mdis the canonical architecture spec — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived underdocs/archive/— history only.)- Living design doc (all decisions):
docs/vision.md - Build plan + wave/gate results:
docs/plan.md
Commands
npm install # once
npm run dev # http://localhost:3000
npm run build # production build (standalone output)
npm start # serve the production build
npm test # all 44 suites (node:test via tsx; deterministic, no LLM)
npm run lint # eslint — includes the import law (architecture §2)
npm run typecheck # tsc --noEmit
npm run desktop:dev # next dev + Electron shell pointed at it
npm run desktop:dist # build + package the desktop app (electron-builder)
# one-shot data scripts (run from the master tree only — data never crosses trees)
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
npx tsx --env-file=.env.local scripts/embed-graph.ts # embed/backfill the whole graph
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
Environment (.env.local)
Secrets and machine-specific paths only. Model choice and thinking budgets are product tuning, not environment — they live in config.ts → MODELS (prober, teacher, researcher, demoBuilder, visualMaker, extractor, auditor, labeler; each an OpenRouter model id + reasoning effort). The extraction batch threshold is there too (EXTRACTION.chunkTokens).
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY |
required — all LLM calls go through OpenRouter |
GOOGLE_SEARCH_API_KEY + GOOGLE_CSE_ID |
researcher's web_search (Google Custom Search) |
DEEPGRAM_API_KEY |
optional — dictation (STT token minting; audio streams browser→Deepgram, never through the server) |
CHROME_PATH |
optional explicit Chrome path for headless verification (puppeteer-core uses system Chrome; nothing is downloaded) |
The packaged desktop app takes keys from userData/auth.json (BYOK, mode 0600, injected into the server child as env only).
Architecture
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: docs/architecture.md.
flowchart TB
LEARNER["the learner"]
subgraph STAGE["STAGE — presentation shell (browser)"]
UI["session UI · question dock · plan panel · graph canvas · activity dock · sidebar"]
end
subgraph CLASSROOM["CLASSROOM — session engine (owns data/chats)"]
TURN["turn pipeline — handleChatRequest:<br/>liveness · steer claiming · write-ahead persist · stream"]
AGENTS["prober → teacher agents<br/>(stateless per-turn, built from the record)"]
CHATDATA[("chat records<br/>data/chats/*.json")]
end
subgraph WORKBENCH["WORKBENCH — background labor (owns data/jobs)"]
SUBS["researcher · demo-builder · visual-maker<br/>(pi agents driven to completion)"]
WAKE["wake pipeline — per-chat serialized steer delivery,<br/>server-side wake turns, crash recovery"]
end
subgraph LIBRARY["LIBRARY — world model (owns data/graph ledgers)"]
EXTRACT["extraction pipeline:<br/>extractor → auditor → merger<br/>(the SOLE graph writer)"]
DERIVE["derivation — graphSnapshot · embeddings · clusters<br/>(pure reads)"]
LEDG[("append-only ledgers<br/>data/graph/*.jsonl")]
end
subgraph ROOM["MACHINE ROOM — host"]
HOST["Electron main → standalone Next server ·<br/>config.ts · LH_DATA_DIR · BYOK keys"]
end
LEARNER -->|"text · graded answers"| UI
UI -->|"POST /api/chat + addToolOutput"| TURN
TURN -->|"quiz / ask stream to browser<br/>(client tools, no server execute)"| UI
TURN --> CHATDATA
TURN -->|"spawn(job) — fire-and-forget ack"| SUBS
SUBS -->|"result → steer message"| WAKE
WAKE -->|"exactly-once wake turn"| TURN
TURN -->|"trigger when uncovered tokens ≥ chunkTokens"| EXTRACT
EXTRACT -->|"reads transcript as evidence"| CHATDATA
EXTRACT --> LEDG
EXTRACT -->|"semantic top-K context injection (one-shot steer)"| TURN
LEDG --> DERIVE
UI -->|"GET /api/graph · PUT positions · POST import"| DERIVE
HOST -.->|"hosts the server side"| TURN
Core invariant: the extraction pipeline (extractor → auditor → merger) is the sole graph writer — tree-blind and post-hoc, triggered by token coverage, not quiz counts. The LLM stages propose, verify, and judge identity (link-or-create against the current graph; each finding carries verbatim evidence and message-granular provenance); the deterministic merger owns every ledger append and rebuilds anything the model could silently mis-copy (event kinds, timestamps, idempotency refs, provenance) from the transcript. Every node is an objective, world-facing fact; the learner's relationship to it lives entirely in the event ledger. The teacher stays a teacher; everything below the ledgers is derived.
Layout
app/ Next.js App Router — pages + 16 thin API route handlers
components/ session + home UI, question dock, plan panel, graph
canvas + view, artifact renderers (registry: artifacts/),
dictation, activity dock, keyboard machine
src/
agent/
system-prompt.md base teaching behavior (the methodology)
prompts/{prober,teacher}/ composed numbered fragments per role
teacher-agent.ts prober/teacher factories (engine injectable for tests)
tools.ts tool composition; quiz / ask_user_question (client tools)
tools/plan.ts plan_init (author-once mermaid plan, deterministic parser)
tools/probe.ts probe_done (probe→teach handoff seam)
tools/jobs.ts research / demo / visual tool surfaces
tools/web.ts web_search (Google CSE) + web_fetch (researcher only)
subagents/ researcher · demo-builder · visual-maker,
extraction pipeline (extractor/auditor + merger handoff),
runner (async protocol), cluster-namer, graph-context job,
headless Chrome helper, abort registry
server/chat-handler.ts THE turn composition root (liveness · steers · persist · stream)
server/turn-service.ts turn seams: persist · graph-context · extraction trigger
server/jobs.ts the wake pipeline: serialized per-chat steer delivery,
server-side wake turns, crash recovery
server/stores.ts THE storage composition root — the one place adapters wire in
server/turn-liveness.ts per-chat turn registry (one turn per chat, ever)
server/embeddings.ts local EmbeddingGemma-300M embedder (transformers.js)
server/observability.ts the activity feed (running jobs + extraction, abort)
server/pi/ the engine bridge: record-adapter (record → pi messages)
· ui-stream (pi events → UIMessage SSE)
server/index.ts the server barrel (the only server import app/ may use)
core/contracts/{plan,graph,chat,jobs,probe,models}.ts
the contracts — one file per domain
core/ports.ts storage ports — ChatStore · GraphStore · JobStore
(core code depends on these types only)
core/ question-core, plan-tree, phases, pending, graph
(merger/derivation), coverage, graph-context, layout,
clusters, prices, tool-parts — all pure: storage
arrives as parameters
store/ fs adapters implementing the ports: chats · graph
ledgers · jobs (+ render-settings, default-settings)
electron/ desktop launcher + security guard (spawns the standalone
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
scripts/ build-server.mjs (packaging guards), import-notes,
backfill-graph, embed-graph, replay-layout, sync-pricing
data/ chats/ · graph/ (12 ledger + map files) · jobs/ ·
default-settings.json — runtime state, git-ignored
Key mechanisms
Client-side question tools. quiz and ask_user_question have no server execute: the tool call streams to the browser, the learner answers in the dock, the client grades via question-core and returns the verdict with addToolOutput — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes resume mid-question free.
The formal plan tree (author-once). The teacher authors the session's dependency tree with one schema-validated tool — plan_init, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — current/visited/upcoming are derived from tagged quiz calls in the transcript, and the panel renders that derivation.
The async sub-agent protocol (ack-and-steer). research / demo / visual are server-executed tools: the call resolves immediately mid-turn with a fire-and-forget ack and the teacher keeps teaching; a background runner works in parallel (the client polls /api/activity and /api/jobs). On completion the runner builds a steer message — a user-role UIMessage whose text part is the model-facing result and whose data-subagent-artifact part carries the full result for the client — and the wake pipeline delivers it: a per-chat serialized queue waits for the turn to end and no question to be pending, then fires a server-side teacher turn that weaves the result into the lesson. Delivery is exactly-once (deterministic steer ids, delivered-flag checks at every claim point); stalled jobs are recovered on read; aborted jobs deliver an abort steer. The client adopts the wake turn through the same turnInFlight machinery as returning mid-stream (busy shimmer, composer gate) and renders steers as artifacts — never a bubble. Sub-agents verify, never one-shot: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (vision-capable model — MODELS.visualMaker in config.ts).
The knowledge graph (two channels, one writer). The write channel: the extraction pipeline — extractor (LLM, tree-blind: sees only the transcript view — propositions as true statements, follows/supports edges, retroactive quiz attribution, one-word understanding observations) → auditor (LLM: sees findings + the current graph; drops anything without verbatim evidence, stamps each survivor with an identity verdict — link to an existing node or create) → merger (mergeFindings, deterministic: applies the verdicts, link-appends phrasings/provenance, rebuilds quiz events from the transcript's tool parts, dedupes, checkpoints coverage) → embeddings + layout settle + cluster naming. Triggered when a chat's uncovered tokens (message-view fingerprints, coverage.jsonl) cross EXTRACTION.chunkTokens — one swappable seam (turn-service.ts); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the notes front door (POST /api/graph/import: any *.md/*.txt becomes a source; re-imports are idempotent by path hash). The read channel: GET /api/graph derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. At session start the learner's topic is embedded and the top-K propositions under a token budget are delivered to the teacher as a one-shot context steer (marked in the UI) — same embeddings power search and the cluster partition.
Dictation. Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
Testing
All deterministic, no LLM, no network (engines are injected — pi-ai's faux provider scripted per test; globalThis.fetch stubbed where transport matters):
- Pure core units — merger (via the real fs adapters, backup-and-restored), derivation, coverage, question grading, plan tree, phases, clusters, layout, tool-parts, prices.
- Server flows —
handleChatRequestdriven end-to-end with scripted engines: persistence, liveness, steer claiming, the wake pipeline (409 retry, quiz deferral, idempotency), crash recovery, the data-dir seam (including a fresh child process). - Sub-agent loops — researcher/demo-builder/visual-maker with fake headless APIs; the extraction pipeline end-to-end (audit drops, failure-checkpoints-nothing, single-flight, abort).
- Render tests — transcript, cards, docks, composer, sidebar, coverage via React server-side rendering.
Lineage
The teaching methodology comes from the Obsidian-based POC (~/Desktop/Obsidian/vault/π/learn; its knowledge graph design.md was the original epistemic-model source, since amended — see the amendments table in docs/vision.md). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's UIMessage as the client protocol (docs/pi-engine-swap.md).
Data
Nothing under data/ is ever tracked by git. Chats, graph ledgers, jobs, embeddings, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via LH_DATA_DIR.