Commit Graph
39 Commits
Author SHA1 Message Date
amosblomqvist 8a9497bf59 Split contracts.ts by domain: plan / graph / chat / jobs
Purely mechanical: src/core/contracts/{plan,graph,chat,jobs}.ts carry the
sections verbatim (graph = nodes + ledgers + extraction findings; jobs =
the async ack-and-steer protocol + tool results + demo bridge), and
contracts.ts becomes a re-exporting barrel so every existing import path
(@/src/core and ./contracts.ts) is unchanged.

Wave-2 lane ownership follows the split: the graph stream owns
contracts/graph.ts, the teacher stream owns contracts/jobs.ts — same-file
contract collisions between streams are structurally impossible now;
cross-domain schema changes go through master.
2026-09-05 21:20:38 +03:00
amosblomqvist 25df0edd5b Merge feat/ui-polish: three-phase session model, plan graph, layout restructure
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.
2026-09-02 16:40:47 +03:00
amosblomqvist 28ccc76594 fix: README.md leaked into the teacher's system prompt
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.
2026-09-02 03:19:03 +03:00
amosblomqvist 2d7b004fda fix: live sub-agent observability — dispatch cards, steer artifacts, sidebar panel
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).
2026-09-02 02:34:02 +03:00
Amos 99fa3f0847 Pi-style ack-and-steer sub-agent protocol: dispatch mid-turn, steer via server wake turn
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.
2026-09-01 19:15:43 +03:00
Amos fbe4f16305 In-session phase presentation: header chip + dotted boundary dividers
- 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.
2026-09-01 18:31:01 +03:00
Amos 1c3d288d08 Three-phase session status: derived summaries for the picker
- 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.
2026-09-01 18:24:13 +03:00
Amos 2e4e43b8c4 Graph nodes gain a name; nodes are objective facts (no learner/session references)
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.
2026-09-01 03:34:47 +03:00
Amos 49a6a6d9a0 Match the pi learning system's tool definitions (quiz/ask)
Compared the port against the original pi setup
(vault/π/learn/.pi: quiz.ts, ask-user-question.ts, teach SKILL.md) —
the pi version never produced invalid correctAnswer values. Root cause
of the port's validation failures: the pi options schema's 'Give each
option a stable value; you reference the correct one by that value in
correctAnswer' sentence was dropped in the port. Without it the model
omits values and must copy a long natural-language label verbatim into
correctAnswer — and instead slugifies it ('cultural-diagnosis' for
'A cultural diagnosis') every turn.

Now matched to pi:
- quiz schema: pi's field order (question, details, options,
  multiSelect, correctAnswer, explanation) and descriptions — options
  carry the stable-value instruction, correctAnswer is phrased as a
  value reference ('the value field of the option you intend'), not a
  copy-exact-or-fail threat. nodeId (lh-only, graph) stays.
- ask schema: dropped the redundant 'mode' param (pi has none; the dock
  already infers from options presence), pi's descriptions.
- quiz tool description: pi's full text (ask contrast, note field,
  dontKnow semantics) minus terminal-only details, plus lh's turn
  protocol sentence.
- system-prompt: fixed the stale 'exactly two tools' line (plan/research/
  demo/visual tools have their own sections).

Not matched, deliberately: tool results stay JSON (pi sends curated
text; formatAnswerMessage exists if we want it later), shuffle param
(lh always shuffles seeded), pi's ask '(Recommended)' label hint.
2026-09-01 03:00:23 +03:00
Amos 7ab444ead3 Fix turn-liveness registry across Next dev module instances (globalThis)
In Next dev (Turbopack), each route compiles as its own entry — a
module-level Map in turn-liveness.ts was a DIFFERENT instance in the page
render, GET /api/chats/[id], and POST /api/chat. The chat route held the
live turn (hence the 409), but the page render read an empty registry:
initiallyInFlight=false → mount continuation fired a send → 409 → the
'Something went wrong' banner on returning mid-turn. The registry now
lives on globalThis (the standard Next singleton channel) — one Map per
process regardless of module instances. Semantics unchanged (TTL leak
guard, begin/end round trip).

Browser-verified end-to-end (headless tester): answer quiz → navigate
away → return mid-turn → graded card (no re-prompt), 'Thinking…' shimmer,
composer gated, no error banner; teacher response auto-delivered when the
background turn finished. Polling GET /api/chats/[id] observed working.
2026-09-01 02:25:54 +03:00
Amos a7ca33548b Sessions keep running across navigation: turn liveness + write-ahead persist
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.
2026-09-01 01:57:13 +03:00
Amos 59fc2ef863 Centralized model config (config.ts MODELS) + extraction seam logging
MODELS in config.ts is now THE single tuning table: model id + OpenRouter
reasoning effort for teacher/researcher/demoBuilder/visualMaker/extractor/
auditor. .env.local is secrets-only (LH_* model vars removed; all sub-agents
previously had NO reasoning setting -> provider-default unbounded thinking,
the extraction token burn). One factory (src/agent/models.ts modelFor) wires
all six call sites; test injection unchanged. EXTRACTION.everyNQuizAnswers
moved to a config literal too. Plus the §5 one-line-per-seam logging in the
extraction pipeline: pipeline start, extractor/auditor start + per-step,
audit kept, merged — previously silent until completion, which made live
runs look like they never fired.
2026-09-01 01:47:47 +03:00
Amos 5dab8d12d4 Quiz hardening: cross-field schema validation rejects ungradeable quizzes
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.
2026-09-01 01:47:47 +03:00
Amos af90fadcda Extraction pipeline: extractor → auditor → merger — the sole graph writer (stage 5)
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).
2026-08-31 22:36:41 +03:00
Amos 59e1fee850 Turn services + agent DI: chat-handler becomes the composition root (stage 4)
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.
2026-08-31 22:16:03 +03:00
Amos 1167157c23 Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
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.
2026-08-31 20:00:12 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
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.)
2026-08-31 19:51:17 +03:00
Amos bfb31568fc Purge legacy pre-pivot scaffold (decided: extractor/merger-only architecture)
Delete src/extract/ (pi-session normalize/extract/merge stubs), src/ledger.ts,
src/schema.ts, src/derive.ts, src/queries/, src/cli.ts (npm run ingest/graph/
what-next), data/legacy-graph/, data/agent/ (pi agentDir vestige), and their
config.ts/package.json references.

The retroactive-bootstrap goal is dropped: the live chat store (AI SDK
UIMessages) is the only session format now, and the old pi JSONLs in
data/sessions/ stay on disk, inert. contracts.ts is the single schema.
2026-08-31 19:35:20 +03:00
Amos 806e1405d5 W8: graph-as-context injection + exact-match merge 2026-08-22 11:41:50 +03:00
Amos db833a3537 Wave 2 integration: deps manifest (sigma, graphology, puppeteer-core), test-run emission guard 2026-08-22 10:32:37 +03:00
Amos 8728573253 W4: graph store + emission + home graph view 2026-08-22 10:30:00 +03:00
Amos 9a99b01159 W5: probe/teach phases — divider + collapsed teach checks 2026-08-22 10:26:51 +03:00
Amos 6f1b35b2c2 W6: demo + visual artifacts, verifying builders 2026-08-22 10:24:02 +03:00
Amos ded163aa3f Repoint legacy V0 scaffold to data/legacy-graph — the live store owns data/graph exclusively 2026-08-22 10:11:58 +03:00
Amos 6a68fcfba1 Wave 2 prep: LedgerEvent.ref, demo bridge protocol, artifact stubs, sendText in ChatActionsContext 2026-08-22 10:03:20 +03:00
Amos c27f7f2f76 Base prompt: mermaid label-quoting rule (live session hit the lexer error) 2026-08-22 09:49:18 +03:00
Amos ef488224bc W3: presentation fixes — delimiters, line breaks, mermaid
- src/core/math-delims.ts: pure preprocessing for the \(...\)/\[...\] math
  contract — balanced delimiters rewrite to the two-dollar forms remark-math
  parses ($ is never math). Skips code fences/spans, escaped delimiters, and
  leaves unbalanced (mid-stream) delimiters untouched. Display math renders
  as a block: mid-paragraph gets blank-line padding; indented (list items)
  degrades to inline so list continuations don't half-parse.
- message.tsx: transform in the render path (children-memoized); math plugin
  now singleDollarTextMath: false — the currency hazard dies structurally.
  remark-breaks via the cjk remarkPluginsAfter slot (the bare remarkPlugins
  prop would replace the [remark-gfm, codeMeta] defaults).
- mermaid: dark theme + sans font via Streamdown's mermaid prop; dark-styled
  error fallback (components/mermaid-error.tsx) with error, source in
  <details>, and retry.
- globals.css (appended): @source for streamdown dist — its internal UI
  chrome (code blocks, mermaid containers, tables) was entirely unstyled
  because Tailwind v4 never scans node_modules; --color-sidebar → card; wide
  mermaid SVGs capped to the column; .katex-display scrolls.
- tests/render-markdown.test.tsx: 26 tests (transform unit + render). The
  math describe block in render-cards.test.tsx is updated to the new
  contract ($...$ now renders as literal text — pinned).
2026-08-22 09:38:54 +03:00
Amos 866352cb65 Base prompt: accuracy rule now points at the research tool for material facts 2026-08-22 09:37:54 +03:00
Amos df16750560 W2: async job infra + researcher sub-agent
- src/store/jobs.ts: per-job JSON store in data/jobs (create/get/update/list,
  dedup by toolCallId)
- src/agent/tools/jobs.ts: research tool — client-side (no execute), turn ends
  at the call; input per contracts ResearchToolInput
- src/agent/tools/web.ts: server-side web_search (Google CSE) + web_fetch
  (Readability/Turndown/linkedom, Jina fallback) — errors return as strings,
  never thrown into the agent loop. Deps: @mozilla/readability, linkedom,
  turndown (+@types/turndown dev)
- src/agent/subagents/researcher.ts: isolated ToolLoopAgent (web tools only,
  12-step cap), researcher prompt ported from pi
- src/agent/subagents/runner.ts: spawnPendingJobs hook + runResearchJob —
  fire-and-forget, activity line per tool call, fulfills the part in the
  LATEST chat record (output-available) before flipping job status; failure
  still fulfills the part so the teacher learns the outcome
- src/server/chat-handler.ts: onFinish spawn hook after saveMessages
- app/api/jobs/route.ts: GET ?chatId= polling endpoint
- components/artifacts/job-card.tsx + registry entry: polling activity card
  (2s) → syncFromServer on finish; research-brief card (markdown + sources)
- src/agent/prompts/20-jobs.md: research doctrine for the teacher
- tests/jobs.test.ts: store CRUD, spawn hook via scripted mock teacher,
  runner completion/failure with fake model + stubbed fetch
2026-08-22 09:36:15 +03:00
Amos 92fb8e56d8 Base prompt Phase 2: dependency map is built with plan_init, not mermaid 2026-08-22 09:28:25 +03:00
Amos 1e18d804fc W1: plan-tree tools — pure validated ops, eight server-side plan_* tools, tree persistence, teacher prompt fragment
- src/core/plan-tree.ts: pure tree ops (createTree/addNode/updateNode/removeNode/
  addEdge/removeEdge/setCurrent/markDone), DAG invariants (unique ids, existing
  endpoints, no self-loops/duplicates/cycles), cascade remove, version bumps on
  state changes only (true no-ops for idempotent setCurrent/markDone).
- src/agent/tools/plan.ts: plan_init (refuses re-init), plan_add_node (optional
  parents → requires edges), plan_update_node, plan_remove_node, plan_add_edge,
  plan_remove_edge, plan_set_current, plan_mark_done. Execute = getChat → op →
  saveTree → PlanToolResult with the full updated tree; PlanTreeError and missing
  chats become {ok:false, error, tree}.
- src/agent/prompts/10-plan-tree.md: tree-authoring doctrine (when: post-probe;
  grain: one reasoning step per node; discipline: set_current on start,
  mark_done on demonstrated mastery; freedom: mid-session restructure).
- tests/plan-tree.test.ts: every validation error path, version semantics,
  cascade remove, no-mutation, tool round-trips against the real chat store.
2026-08-22 09:27:27 +03:00
Amos 856eb67da1 Base prompt: \(...\)/\[...\] math delimiters, $ literal currency, mermaid encouraged 2026-08-22 09:15:29 +03:00
Amos c55bae0de6 Wave 0: shared contracts + extension points
- src/core/contracts.ts: plan tree / graph / ledger / job protocol / artifact
  registry types — the single source of truth for all workstreams
- tools: per-request factory createTeacherTools({chatId}) composing
  questionTools + planTools (stub) + jobTools (stub); quiz gains nodeId
- prompt composition: system-prompt.md + sorted src/agent/prompts/*.md
- ChatRecord.tree + saveTree; pending gate scoped to quiz/ask (+findPendingJobCall)
- chat.tsx: artifact renderer extension point + ChatActionsContext.syncFromServer
2026-08-22 09:06:24 +03:00
Amos 0baa00e418 UI iteration 2: toggleable sidebar, floating composer blob, pending-question morph
Sidebar (replaces the modal drawer):
- Inline, toggleable, no backdrop/focus theft — can stay open while working
- Open state persists across pages (provider in root layout) and reloads
  (localStorage; starts closed server-side to avoid hydration mismatch,
  stored value applied post-mount; default open on first run)
- Hamburger in page headers only when closed; close button in sidebar header

Floating composer blob (per reference screenshot):
- Floats over the transcript (absolute overlay), persists at bottom while
  scrolling, content fades behind it (gradient mask); pb-44 scroll clearance
- Concentric corner radii: blob rounded-2xl(16px) − p-1.5(6px) = send
  rounded-[10px] = 25% of the 40px button; CornerDownLeft icon per reference
- Stop moved from header into the blob (busy → square icon)
- prompt-input.tsx + 7 orphan ui files pruned (attachments/command palette
  unused); hand-rolled Composer is ~100 lines and render-tested

Pending-question morph (one text field on screen, never two):
- Quiz/ask-select pending → blob becomes the note field (send hidden, hint
  line above); card's internal note input removed; Submit consumes it
- Ask free-text pending → blob becomes the answer field; its send submits
  via the card's registered submit api (ref bridge); Enter works
- Chat owns the single text state; card clears it on submit
- canSend for answer mode mirrors the card predicate directly — reading the
  ref during render lagged one commit (found in browser testing)

Widths: one shared column (max-w-3xl + px-4) for transcript, cards, and
composer — all three measured pixel-identical (left 416, width 736).
Assistant Message max-w-full/MessageContent w-full (was max-w-[95%]/w-fit —
the 'cards are a bit shorter' bug).

User messages render plain text again — no math pipeline on learner-authored
text (currency hazard accepted for the model only, guarded by the prompt).

Tests: 58 pass (5 new composer pins; card tests updated to controlled
props). Verified live: note flow, answer flow, sidebar persistence, float,
fade, zero console errors — plus the live model spontaneously honoring the
$ currency escape contract in a continuation.
2026-08-22 04:28:13 +03:00
Amos 46d71380fa UI iteration 1: app shell + full LaTeX pipeline
Layout:
- Home = knowledge-graph placeholder (main view) with new-session input
  docked below; session list moved out of home into a hamburger drawer
- SessionDrawer: slide-over session navigation (backdrop/ESC close, active
  highlight, fetches /api/chats on open) on both home and chat headers
- Chat header loses the back button — the drawer is the navigation

Math (inline $ was silently not rendering):
- @streamdown/math defaults singleDollarTextMath:false — now
  createMathPlugin({ singleDollarTextMath: true }) in message + reasoning
- katex.min.css imported in layout (streamdown does not inject plugin
  styles; display math only looked right via browser MathML fallback)
- QuizCard/AnsweredCard route question/options/explanation through
  MessageResponse; user chat bubbles render markdown+math too
- Currency hazard: micromark-extension-math@3.1 lacks pandoc's
  closing-digit rule, so bare '$5 and $10' parses as math — defense is at
  the source: system prompt now instructs escaped \$5 / 'USD' for money

Hydration fix (React #418):
- QuizCard shuffled options with Math.random() — server/client orders
  diverged. shuffleOptions now takes an injectable RNG; QuizCard passes
  seededRandom(toolCallId) (mulberry32/FNV-1a in question-core). Same
  question → same stable order across reloads; still varies per question.

Tests: 53 pass (5 new math-rendering pins incl. fenced-vs-one-line $$
behavior and the escaped-currency contract). tsc + next build clean.
Verified live in browser: inline/display math in text + cards, drawer
open/navigate/active-highlight/ESC, no console errors, no font 404s.
2026-08-22 03:23:11 +03:00
Amos a72c902d30 v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
  default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
  lands as a typed tool part in input-available state; the card grades via
  src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
  reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
  saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
  + button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
  protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
  handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
  render smoke tests guard the cards (renderToStaticMarkup)

Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.
2026-08-22 02:18:27 +03:00
Amos 296cc1b7b6 v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests):
- duplicated messages: per-socket snapshot + global broadcast marking raced;
  replaced with a single append-only entry log (exactly-once per socket,
  structurally) — covered by tests/server.test.ts
- delayed/missing echo of own messages: optimistic append with sending/failed
  status, reconciled by text when the real entry arrives
- no thinking shown: thinking deltas streamed live (kind: thinking) and
  persisted in assistant entries so they survive reloads
- ask answers graded as wrong: AnsweredCard no longer applies quiz grading to
  ask answers (no ✓/✗, no verdict)
- broken option selection: native radio/checkbox inputs fighting React state
  replaced with button-based options (single source of truth, one-click select)
- no URLs: History API routing — / home, /s/<file> per session (deep links,
  refresh, back button); session view resumes its file on mount
- lost connection = dead UI: exponential-backoff reconnect with full resync
- stick-to-bottom transcript with jump-to-latest pill

Server: startServer() with injectable runtime factory (scripted model in
tests); createSession returns file for routing; scripted model gained thinking
turns. 51 deterministic tests green.
2026-08-22 00:57:16 +03:00
Amos 25063b2a3e v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite
- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces
- extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm)
- extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context)
- extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged)
- src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push
- web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming)
- src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides)
- tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm)
- scripts/e2e.ts: live smoke test (manual, real LLM, never a gate)
- teacher/system-prompt.md: end-your-turn rule for question tools
2026-08-22 00:19:49 +03:00
Amos 9a6af9697f v0 scaffold: teacher runtime (hermetic pi SDK), data model, ledgers, normalizer
- teacher/system-prompt.md: teach methodology + quiz/ask guidelines folded in
- extensions/: quiz.ts + ask-user-question.ts (hermetic copies)
- src/teacher/create-session.ts: isolated agent (noTools builtin, own agentDir/sessions/prompt)
- src/schema.ts + ledger.ts: append-only ledgers, known-vs-understood data model
- src/extract/normalize.ts: session JSONL -> canonical doc (verified on real session)
- extractor/merger/derive/what-next: specified stubs
2026-08-21 21:37:28 +03:00