- switch the math authoring/rendering contract from \(...\)/\[...\] to the
standard $...$ inline / $$...$$ display: prompts (20-question-tools,
40-formatting), singleDollarTextMath on the renderer, legacy reader kept,
and escape-debris recovery extended to dollar spans (mirroring micromark
delimiter-run rules)
- exact answer membership now precedes debris cleanup in
coerceCorrectAnswer (calculus incident: an exact sentence-final-period
label was stripped as model debris, then rejected); option keys are
passed at the boundary/grading seams, the dock no longer pre-coerces
- a complete, phase-correct JSON-escaping example rides the probe/quiz
tool descriptions (serialized from one canonical body; quiz leads with
type+nodeId), plus the one-sentence JSON-escaping rule in 40-formatting
- ask option descriptions render markdown/LaTeX in the pending dock;
settled cards keep the details line and option descriptions
retroactively, read from the persisted call arguments (chat passes
part.input to AnsweredCard)
1566/1566 tests on both navigator configs; typecheck, lint, production
build, standalone packaging, and Electron-runtime smoke all green.
The tools_fails_2 diagnosis (glm-5.3 dropping trailing required fields):
- probe-phase quiz/explain renamed to probe/recall — a shared name carried
the probe call shape into teaching (the first teach quiz dropped nodeId);
display seams accept the old names forever
- teach quiz+explain schemas lead with the required pair (type, nodeId);
explain gains the predict/check type and joins the reveal law (checks
collapse, predictions arrive open; no-type records render check-style)
- plan_init/probe_done results go context-slim (owner, context diet):
content = validated nodeIds + binding-id reminder / fixed ack; details
keeps the tree for the popup and {ok:true} — serializer-verified that
details never reaches the model; failure echoes dropped
- the cards carry the tool's own name (Probe/Recall) and the quiz type
reads on the label (Prediction/Check) in composer and transcript
Owner verdict on the accent step: way too light - in-card thinking
drops the band entirely (rail only, identical heights). The rail
narrows again: 14px gap inside the 3ch inset (rail calc(3ch-14px),
about 8px) - the wide rail was distracting. Total inset still 3ch,
chevron law intact.
Owner round four on the user rows: (1) the rail thins - the gap inside
the 3ch inset grows 5px -> 8px, so the rail is calc(3ch-8px); total
inset still 3ch, chevron law intact. (2) The band padding drops again,
5px -> 4px (py-1). (3) The in-card thinking band steps to bg-accent -
the same ONE-STEP lift the chat band gets (background->card on the page,
card->accent on the card); card-color on card-color was invisible, the
accent gives the card rows a visible band with identical heights. (4)
pr-4 on the band rows - the expanded text wraps EARLY instead of hugging
the band's clear right edge.
Owner round three on the user rows: (1) the band padding halves,
py-2.5 -> py-[5px] — subtle, not tall; same for the messages. (2) The
in-card thinking joins the band: identical styling and heights for
collapsed thinking in the chat and inside question cards (card-on-card
is quiet by design; revert if it reads too light). (3) The left gap
MATCHES the new 5px padding, so the rail fills the rest of the 3ch
inset - border-l-[calc(3ch-5px)] pl-[5px]: a thick muted rail, total
inset still 3ch (the chevron-alignment law survives). The banded prop
dies - one geometry, cards included.
Owner round two on user-vs-agent clarity (the chat-comeback was tried
and set aside): we go all in on documents. No chat sides. User messages
and every transcript user-thinking row are full-width CARD-COLOR BANDS
- the band is the authorship mark against the agent's transparent
prose. No rounded corners, no borders besides the rail.
The rail keeps its muted color but is now 6px wide (was 2px): the
artificially wide 3ch inset (the chevron-alignment law with the agent's
flush rows) is re-split - rail eats the air, the rail-to-text gap drops
from 3ch-2px to a natural 3ch-6px, and the total inset stays 3ch so the
chevrons still align. The 'You' label stays on messages - one register
with the thinking labels.
In-card thinking (inside answered cards) takes the rail but NO band:
card-color on card-color would be invisible; geometry alone carries it.
Transcript rows arrive expanded (clearly sent) - the reading lift now
opens on arrival too; the in-card row stays collapsed. Cmd+T still
rules all rows.
Also this round: the answered-card last-child rhythm law
([&>*:last-child]:mb-0) - on ask cards the thinking row's my-3 stacked
with the card's p-4 into a 28px void below (12px above); the card's own
padding is the bottom rhythm when a row ends the card.
Blockquotes: the callout override had displaced Streamdown's own styled
blockquote component, so plain quotes rendered as bare unstyled text
since that commit. The pass-through branch now restores Streamdown's
verbatim classes (border-l-4, muted italic).
Explanations tucked for ALL questions (owner): the prediction's Why /
Hide-why toggle is universal — every quiz answered card sits its
explanation behind the same collapsed button. Same pattern, one law.
The thinking row's rhythm: my-1 -> my-3. The 4px margin hugged the
options on non-graded answered cards (owner report); 12px matches the
card's mt-2/mt-3 spacing grammar everywhere the row renders.
Side-based authorship (owner round, 2026-10-07): the document shape
blurred user into agent — three left-rail idioms (user text, user
thinking, quoted prose) collided once quotes rendered. User rows go
RIGHT: 'You' messages and practice thinking cards hug the right edge at
85% width with the rail mirrored (border-r), text still reading
left-to-right. In-card thinking (inside the answered card, the agent's
context) stays left. Transcript thinking rows arrive EXPANDED — the
thinking was clearly sent. Cmd+T (toggle-user-thinking, chat scope,
cheat-sheet row) sets every user-thinking row to one state via a module
store: explicit sets stomp individual toggles, late mounts honor the
last set (the state initializer reads the store, so static render
reflects it too).
Owner round 4 (2026-10-05):
- THE RAIL IS THE MISSING CHARACTER: rail + padding = exactly 1ch of
the mono heading font. 'YOUR THINKING' (13ch) + rail == 'AGENT
THINKING' (14ch) flush — the chevrons line up vertically, no fixed
columns. The YOU heading joins the rail (label inside, like the
thinking rows) — one quote geometry everywhere.
- HEADING REGISTER: YOUR THINKING / AGENT THINKING / YOU all use the
card eyebrows' small mono caps (text-xs = the 11px rung, font-medium
uppercase tracking-wide) — the app chrome is JetBrains Mono.
- SUBMIT THINKING: the practice button is now literally the question
dock's Button (h-9, px-3.5) in one p-1 row with the hint left
('↵ submits · ⇧↵ newline') — the keyboard hints are back. Corner
radii concentric in both docks: shell 16 − 6 = field 10 − 4 = button 6
(rounded-[10px] fields, rounded-[6px] buttons — the old rounded-xl/12
was the 'too large' culprit).
- MODEL NAMES: prettyModel no longer auto-capitalizes — names render
exactly as their providers write them ('glm 5.3 flash').
- Quiz explanations pulled closer (mt-3 → mt-2).
Owner round 3 (2026-10-05):
- CONCLUDE CARD: the stacked same-weight headings are gone. Plain
'Concluded' eyebrow + the outcome in the quiz cards' EXACT chip
register. Colors per outcome: solved green (the 'correct' twin),
solvedWithHints amber (succeeded with cost), surrendered red (the
problem won), movedOn muted (the agent's call, not a verdict against
the learner). Verdict as plain 50% prose under the heading — the
'Verdict:' label is gone — then a divider, then the labeled model
solution.
- ALL-CAPS: 'YOUR THINKING' and 'AGENT THINKING' — display strings only
(the model-facing marker label stays sentence-case). The two rows are
equal-length over the shared rail, so the chevrons align vertically.
Dock field labels match.
Owner round 2 (2026-10-05), Slice F:
- USER TEXT: the right-aligned bubble is gone. User messages are
full-width document rows — muted 'You' label, muted quote rail,
full-opacity text. Learner words are content, not a chat-app tell.
- USER THINKING (everywhere): arrives COLLAPSED as a trigger row in the
exact register of the agent's reasoning rows (muted label + chevron +
hover affordance); expanding opens the quote block — rail, 50%
opacity, italics. The inner-voice signature. The ThinkingBlock loses
its shell/border: pure document material.
- QUIZ CARDS: the answer's thinking is the SAME collapsible row, same
placement, just inside the container; the explanation drops to 50%
opacity (upright — italics stays the thinking signature).
- AGENT'S THINKING: the duration bookkeeping is gone (it was live-turn
state that died to 'a few seconds' on remount anyway) — the settled
row reads static 'Agent's thinking', pairing with 'Your thinking'.
Spinner + shimmer stay the working signal while streaming.
- Glance law: user thinking = rail + 50% italic; agent thinking = no
rail; user text = upright, full opacity, 'You' label.
Owner round (2026-10-05): the same act (a human thinking in text) had two
visual languages — the quiz cards' quote block vs the practice container
with a collapsing chevron and raw pre-wrap text that read oversized.
- components/thinking-block.tsx: the quiz quote-block recipe verbatim
(rail + uppercase label + lh-prose at foreground/90) carried by a
faint user-side shell — ml-auto, max-w-[95%] mirroring the user
bubble's geometry, px-4/py-3 rhythm. Never collapsible. Rails reuse
the existing vocabulary: primary = committed register, muted =
working register.
- ThinkingCard becomes the practice adapter: 'Your solution' (primary)
/ 'Your thinking' (muted) — one component, two registers.
- The reading channel LIFTS out of the answered card: chronologically
prior to the question, it renders above it as its own user-side
block under the plain 'Your thinking' label; the in-card inset and
the 'while reading' phrasing are gone.
- lh-card-enter (globals.css): the one-shot 150ms fade+rise mount,
the app's motion register; honors reduced-motion.
The teaching cycle gains its motivation question. The teach quiz schema
now carries a REQUIRED type field — predict (posed before the teaching,
arrives open, the learner predicts the next step) or check (posed after,
arrives collapsed behind the reveal). The prober's quiz stays role-less.
- core: quizInputObject.type union + normalizeQuizType + the reveal law
as pure quizNeedsReveal; QuestionData.questionType; note → thinking
rename (legacy reads fold back); readingThinking channel on answers;
compactAnswerForModel emits thinking / thinkingWhileReading
- tools: descriptions stripped to mechanics — QUIZ_OPTION_CRAFT and
EXPLAIN_DOCTRINE deleted, doctrine moved to the prompt layer
(reverses the 2026-09-27 placement, owner 2026-10-03); shared
quizDescription/explainDescription builders differ only in deltas
- prompts: shared/20-question-tools.md rewritten (option craft,
transcribed-speech leniency, the two thinking channels); new
teacher/28-predictions.md (cycle, roles, skip rule, resolution law);
20-teaching + 30-question-principles scoped to the new taxonomy
- UI: predictions render open with a Prediction eyebrow, verdict shows
but the explanation tucks behind a toggle; the reading-thinking field
mounts while a check waits (dictation claims it until reveal);
answered card separates thinking vs thinking-while-reading blocks
The rail paint is extracted to components/confidence-rail.tsx (ramp,
tick stack, geometry laws) and shared by both surfaces so they cannot
drift: the dock's live slider paints the rail under its disc, the
answered card renders it disc-less — the missing handle itself says
'record, not control'. Same look otherwise: thin rail, stop color,
solid ticks, % read-out on the right, dock-mirrored placement (below
the options, above the thinking).
Renders only where a confidence exists: quiz answers with one sent;
never on 'I don't know' (none was sent), never on explain/ask, never
on pre-feature records (optional field, absent).
Two follow-up rulings on the explain tool:
1. No 'I don't know' row — not needed in free recall. If he doesn't
know, he says so in his own words; his text is the only answer
channel. Dock: text mode is rowless again (the composer IS the
surface); doctrine and the shared prompt chunk updated; the format
branch and card revert to quiz-only dontKnow.
2. explanation + correctAnswer collapse into ONE field — the separate
explanation was quiz remnant (MC needs the why outside its options;
a free-text commitment carries its own reasoning). Schema is now
{question, correctAnswer, details?}; the reveal shows the single
committed answer; no explanation snapshots anywhere on the explain
path.
1047/1047 tests, typecheck/lint/build green.
Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.
- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
recall' in Agent settings' Tools group): free arms explain and drops
quiz for BOTH agents (prober variant carries no nodeId, same pattern
as the probe quiz); mechanics keys never pass filterEnabledTools —
quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
no conditional system-prompt chunks): explain carries the commitment
rule; the MC option-building craft moved out of the prompt into the
quiz description; shared/20-question-tools.md slims to the
quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
answers deliberately feed no ledger evidence (no client grading to
key on — a future ruling can parse the model's grade)
1049/1049 tests, typecheck/lint/build green.
The system: the ladder is the truth, everything snaps to it.
- Prose snaps to the ladder's 16px rung (--lh-prose-size 16.5→16; the
half-pixel was a reskin-era hand-tune with no visible payoff and a
system cost). lh-prose, lh-input, and the session titles all read the
one variable — one reading size, now a ladder citizen. @theme inline
inlines literals without emitting runtime vars, so the variable can't
var()-link to --text-lg: documented as the one deliberate duplication
(change one, change both).
- The 10px rung merges into 11px: one pixel apart was a distinction with
no visible difference, and 10px sits at the mono legibility floor —
quiet labels (Sessions, Background agents, dock hints, model-picker
footers) get their quiet from color + tracking, not a notch of size.
All 13 text-xxs uses → text-xs; --text-xxs token removed.
- Derived constant follows: TEXT_CAP_SCALE 16.5/12.5 → 16/12.5 (1.32 →
1.28; test pin 232.32px → 225.28px) — the plan-graph text cap now
tracks the snapped prose size.
Live: title 16px, Sessions/Background-agents labels 11px, composer 16px
with the 20px lh-input line box. Gates: typecheck, lint, 469/469.
Lane note: components/graph-context-marker.tsx (graph-stream file) had a
mechanical text-xxs → text-xs swap — required: the token removal
otherwise leaves a dead class in their file.
components/ui/glyph-mark.tsx: extracted vector outlines from the system
Zapf Dingbats font — U+2713 (LaTeX \ding{51}, the hand-drawn check) and
U+2715 (\ding{55}, the bulky cross) — as static SVG geometry on an
ink-bounded viewBox with a y-flip path transform (inverse of font axes).
This matches the pifont shape LaTeX renders, not a random glyph. Colors
stay: green/red like the verdict chips; the span keeps data-mark pins.
- Option rows: items-start → items-center — the icon span's top-misalign
came from the row alignment; now marks center across the row.
- The literal ✓/✗ look (owner preference over the round-3 lucide set)
re-rendered as hand-rolled SVG with thick rounded strokes (strokeWidth
3, round caps/joins) — the glyph shape, as a true vector, sized like
the row text. Test pins updated to data-mark=check|x.
The muted 'Why' box's px-3 double-inset with card p-4 — the W-label sat
deeper than the option-box edges. px removed (py-2 stays for vertical
air); the label now aligns with everything else on the left edge. The
rounded bg-muted box is bg-matched so its padding was invisible anyway.
- ✓/✗ in the answered card: replaced literal glyphs with lucide CheckIcon/
XIcon (strokeWidth 2.5) inside a fixed size-4 flex box — text elements
never carried the right size/vertical alignment, vectors do. Test pins
updated (lucide-check/lucide-x classes).
- All user-typed INPUT text now matches markdown output: lh-prose class
replaces the earlier font-prose (family-only) band-aid on the composer,
question-dock text/thinking/Other fields, home topic input, model search
box, and answered-card 'your thinking'/free-text records. lh-prose sets
family+size (16.5px) so placeholders inherit correctly.
- Home view: StatsBar under the composer (model+thinking control) — the
DOCK geometry matches the chat view (max-w-[46rem] px-4) so nothing
shifts when a session starts; pre-chat selection persists via deferred
save (ModelControl chatId-empty) + PUT after POST /api/chats.
- Answer marks (✓/✗): same visual size as row text, vertically centered;
colored green-500/red-500 matching the verdict chips.
- All user-typed inputs (composer, question-dock text/thinking/Other,
answered-card 'your thinking' and free-text records) now render
Source Sans 3 via the font-prose utility. Placeholders inherit the
family via the element.
- Answered-card explanation: markdown wrapper made truly inline so the
'Why:' label + the paragraph sit on one line — removes the oversized
leading gap.
--text-xxs (10px) joins the ladder; every component-level text-[9|10|11px]
literal in owned components now pulls from the ladder (graph-view.tsx and
home.tsx untouched — graph stream). Cosmetic pins updated where touched.
- The learner's own typed Other answer now renders INSIDE the options
container in the answered card, styled exactly like its selected
siblings (border-primary bg-accent) — it is an option, not a loose
quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
(metaKey || ctrlKey — the Gmail convention, same handler on every
platform). Plain Enter stays a newline: the field is for dumping.
A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
discoverability (text mode keeps Enter-to-submit, no hint).
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:
- A labeled composer-TWIN field ("Your thinking") between the options
and Submit, docked with the submit button inside it exactly like the
chat composer's send. The placeholder invites dumping ("Why this
answer? What are you unsure about? Say as much as you like — the
teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
that fills on selection + inline input, styled like its siblings — so
picking Other never displaces the thinking field; typed text survives
deselection.
- The answered card promotes the thinking to a quoted first-class
block above the verdict, labeled "Your thinking" — the transcript
reads as a dialogue of thinking, not a buried "Note:" footnote.
The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
The 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.)
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.
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.