Commit Graph
15 Commits
Author SHA1 Message Date
amosblomqvist 9362baf36e practice polish: no phantom wake, Practice header tag, honest meta
- needsContinuation: a persisted problem-pose ack (terminate:true ended
  the turn deliberately) owes no continuation — reloads after a pose no
  longer wake a phantom turn; error results stay owed like any
  interrupted tool.
- Chat header: practice sessions swap the phase chip for a quiet
  Practice tag (no probe→teach arc applies) and hide the coverage ring
  (practice never extracts — an ever-filling ring would lie).
- ChatSummary gains pendingProblem (activeProblemOf at projection); the
  sidebar meta says 'Problem waiting' for a practice row with a posed
  problem. Kind threads through Chat/[id] page.
- Home picker chip gains Clear (picks were otherwise irremovable).
2026-10-04 22:06:22 +03:00
amosblomqvist e76e42f764 practice entry points: sidebar filter, practice draft, graph picker
- Sidebar: the three-way kind filter (Teaching/All/Practice, persisted
  in localStorage) above the search; two new-session doors (Teaching →
  /chat/new, Practice → /chat/new?mode=practice); practice rows carry
  the dumbbell badge and 'Practicing…' busy meta.
- Draft: ?mode=practice swaps the copy (title, empty state, 'What do
  you want to practice?') and startDraftSession posts kind:'practice'.
- Graph entry (home composer): teach|practice switch on the stats row's
  left edge (StatsBar leadingSlot); practice mode shows the propositions
  chip that opens the chain table as a picker (its floating bar becomes
  'Practice these' — no delete fence); the picks seed the opening prompt
  ('Propositions from the map:…') while the title stays the typed text.
2026-10-04 22:01:42 +03:00
amosblomqvist 3e9a85d60f per-session models: every session owns its model + thinking level from birth
The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of
any pick, while composer and settings UIs agreed with each other and lied
about the run. Root cause: a fossil default-settings record (settings.json
chat section, frozen pre-slots-wave) was stamped verbatim into every new
chat at birth by the new-session flow, and resolveConversationSpec prefers
the per-chat record over the strong slot forever.

Owner redesign — models are per session, always; the strong slot is the
new-session default:

- POST /api/chats stamps the newborn verbatim from the strong slot
  (model + thinking level); no client settings PUT on create anymore
- the composer control is dual-bound: in-session it reads/writes the
  session's own record (GET /api/chats/:id, PUT .../settings); on the
  draft surfaces it binds the strong slot (picking there = changing the
  default)
- thinking levels follow the identical mechanics (confirmed by owner)
- the zombie default-settings system dies whole: useDefaultSettings,
  /api/settings/defaults, src/store/default-settings.ts, the chat
  section of UserSettings (sanitizer strips fossil copies), the legacy
  default-settings.json fold, and DEFAULT_SETTINGS_FILE
- catalogDefaultsFor collapses to the strong slot alone
- thinking display for a legacy unstamped session resolves exactly like
  the engine (session ?? strong ?? model ladder)

Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot,
the unset level, and the copy-not-live-view regression; defaults pins
rewritten strong-only; legacy-migration suite deleted.
2026-09-28 23:30:11 +03:00
amosblomqvist f3003d357a Wave D: native AgentEvent wire + client session rewrite (SDK purged)
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).
2026-09-26 01:52:47 +03:00
amosblomqvist 7ab47d4fcb Wave B: the native message record — input events, projection, adapter death
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.
2026-09-26 00:37:46 +03:00
amosblomqvist 5ab52f07f6 BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards
- existing sessions: a send that lands the 503 models_not_configured envelope
  renders as the SETUP FLOW, never the error banner — the record refetch drops
  the refused orphan (the gate refuses before the write-ahead persist), the
  draft goes home to the composer, and Save auto-replays it (owner ruling);
  parseModelsRefusal reads the envelope from the transport's verbatim error
  text (exported pure, tested)
- persistent 'Set up your models' CTA above the chat composer while
  slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) —
  legacy per-chat overrides may still send with the pill present; the server
  gate stays the truth
- SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with
  their pick (matched in the federated catalog — lapsed keys don't prefill),
  unpinned tiers carry the mark; Save still requires all three;
  prefillFromSlots exported pure, tested
- refused jobs read as INFO: dispatch card shows a quiet 'needs model setup'
  line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs'
  activity reads 'needs model setup' instead of 'failed'; real failures keep
  the red tone; isModelsRefusal added to the envelope's home module
- agent-section ModelsBody.slots type honest to the nullable contract
2026-09-22 18:12:57 +03:00
amosblomqvist f6626f1703 Fix wave chunk A: no Default option, composer = strong slot, catalog revalidation
Item 1 — no Default in the slot selectors (owner, fix wave): a
"default" model is not a thing in this app. Picking Default used to
delete the slot and re-arm the first-run gate — incoherent. Now the
model menus carry NO clear row at all (a slot holds a pick or reads
"—"), the thinking menus carry a "—" row (menu-none) that clears only
the thinking pin, and the hint line is the roster only — the resolved
factory pin is never named. Master's amendment confirmed: the
factory-pin hint is SUPERSEDED entirely (Platform removes the pins).

Item 2 — the composer control IS the strong slot (owner: "one and the
same"): ModelControl (model-picker.tsx) reads the raw models section
(GET /api/settings) and the federated catalog, and writes
PUT /api/settings/models via composeSlotModel — the same seam the
settings Agent section and SetupDialog use. Props retired: chatId,
settings, onSaved, fallbackModelId, noDefaultModel — the control is
self-sufficient and identical on every surface (composer, home dock,
/chat/new). The per-chat model PUT is gone from the UI; legacy
per-chat overrides keep resolving server-side. StatsBar/DockOverlay
plumbing slimmed accordingly; chat.tsx drops its per-chat settings
state and initialSettings prop.

Item 3 — the composer catalog cache no longer freezes per page load:
fetchCatalog(force) invalidates, and every model-menu open revalidates
in the background (stale-while-revalidate — cached list shows
instantly, the fresh federation swaps in). Owner's repro verified live:
a key saved mid-session widens the menu without a reload.

Item 4 — stale doc comments fixed: model-catalog.ts's "OpenRouter
catalog"/"multi-provider future" (Platform's file — mechanical,
flagged for review) and model-picker's retired "config-pinned"/
per-chat-PUT narrative.

Live-verified on 3001: unpicked slots read "—" everywhere; model
menus have no clear row; the "—" thinking row clears the pin but
keeps the model; composer pick writes strong and preserves cheap/
vision siblings; thinking pill writes strong.thinkingLevel; groq key
added mid-session appears in the open menu without reload. Caught in
live verification: the record fetch was mapping the WHOLE settings
record into the models section (picks never wrote) — fixed to read
body.settings.models.

Gates: typecheck, lint, 807/807, build.
2026-09-22 16:40:31 +03:00
amosblomqvist 48fbd988f1 Wave-5 chunk B: three model slots, search-engine row, first-run setup flow
Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):

AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
  Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
  (visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
  catalog (/api/models = keyed providers only, fresh per call — saving
  a key in Keys immediately widens the picker); the vision slot
  filters to image-capable models (Model.input); the menu's highlight
  now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
  the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
  provider bookkeeping-relevant. Usage stamps render provider-qualified
  (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
  bar's turn lines.

SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
  the pin removed (auto-activation). The live reason line comes from
  /api/search; no engine key at all grays the whole row + disables the
  trigger (a keyless choice would only block auto-activation).

FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
  for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
  CTA while unconfigured; submitting a topic opens the SetupDialog WITH
  THE DRAFT PRESERVED; Save stores the models section and the queued
  message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
  provider → stage 1 deep-links the settings dialog on Keys (open now
  takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
  all three required). Closable without configuring — browsing stays
  possible; the gate is the send, not the dialog. Layered Esc: an open
  slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
  pre-save 'configured' (the flow now forces the replayed send); menu-Esc
  used to close the whole dialog.

Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
2026-09-22 13:54:29 +03:00
amosblomqvist bd3086194f feat: no baked-in default model — the new-session controls ARE the default settings page
Owner reframe (2026-09-18): the model + thinking + context controls on
the home graph view and /chat/new are not a draft overlay on a baked-in
default — they ARE the settings page for the default model, and the
user's last choice IS the default. BYOK first boot has no keys and
possibly no OpenRouter, so there is NO default to show: the controls
render 'no model' ('—') until the first pick.

The flash was the deeper symptom: the surfaces rendered from client
state that started empty and fetched truth afterward, so every visit
flashed the config pin (GLM/low — a dev artifact) for a few frames.
Fix at the root: both pages are now force-dynamic server components
that read the record (readDefaultSettings → initialSettings) and hand
it to the client — the first frame shows truth. Dynamic routes aren't
cached client-side (staleTimes.dynamic = 0), so every navigation
re-reads the record; the useDefaultSettings hook keeps only save-side
state (optimistic set + PUT). The GET-on-mount and its touch-guard are
gone.

ModelControl gains noDefaultModel (the default surfaces only): unset
settings render no model/level, never the catalog pin; the thinking
menu no-ops until a model exists. Chat surfaces keep the catalog
fallback — the engine runs the config pin when a session record has no
override, so the control keeps showing what will actually run.

Renamed draft→default throughout: data/default-settings.json,
GET/PUT /api/settings/defaults, useDefaultSettings. New sessions
still inherit the default on creation exactly as before.

Live-verified: first-boot 'no model' with zero flash, picker +
Shift+Tab cycle, values held across session→/chat/new→home roundtrips
and a reload (10×100ms samples, never the old default), zero console
errors.
2026-09-18 09:37:20 +03:00
amosblomqvist 0f6e36f85f feat: the sessionless draft view — "+ New session" creates nothing
The sidebar button no longer POSTs a record (no more "Untitled session"
litter): it opens /chat/new, a sessionless draft view visually identical
to a fresh chat (same header + probe chip, the same ConversationEmptyState,
the same composer blob + stats dock). The session comes to exist only
when the learner sends the first message — and that message becomes its
title (startDraftSession: POST /api/chats {title: msg} → PUT the lifted
pre-chat settings → redirect /chat/<id>?prompt=<msg>, whose initialPrompt
path delivers the message as turn 1 — the home composer's proven contract).

- app/chat/new/page.tsx: the static draft route (static beats [id]).
- components/new-session.tsx: DraftShell (pure render seam) + NewSession
  (router wiring); the model picker's empty-chatId branch defers its save
  and lifts the selection, so pre-chat model/thinking rides the creation.
- components/sidebar.tsx: newSession is a plain router.push("/chat/new").
- tests/render-new-session.test.tsx: 6 pins — the create-on-send contract
  (title = message, settings PUT, no-op on empty text) + the shell render.

Live-verified end to end: button → draft (0 creations), pre-chat Qwen
pick, send → exactly one session titled with the message, settings
persisted, redirect + turn 1 running. Gates 431/431.
2026-09-14 09:06:48 +03:00
amosblomqvist de0c286311 feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
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.
2026-09-12 06:02:40 +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 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 d1d28d6bb4 Session switch: land at the bottom instantly with the question dock open
- Chat now remounts per session (key={chat.id}): fresh StickToBottom,
  fresh DockOverlay (no cross-session morph from the previous shell
  height), fresh useChat.
- Conversation initial="instant" (was "smooth"): the mount scroll to
  bottom is a jump, not a slide.
- DockOverlay skips the pending-question scroll/expand effect for the
  session's INITIAL pending question (dock starts expanded; the initial
  scroll positions us) — previously it fired a second smooth
  scrollToBottom right after the library's initial scroll (the visible
  'pause, then the quiz pops and pushes everything up').
- The dock-variant fade/rise no longer plays for the session's opening
  variant; reserved for mid-session transitions.
Verified live: client-side switch settles at d=1px from the bottom within
one frame of content swap, dock open from the first frame, both
directions (quiz session, plain session).
2026-08-22 06:45:32 +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