The owner speaks in display values: "labels until zoom" shows ratio/0.5,
so the old default displayed 8× — and "default one" means display 1× =
ratio 0.5, one stop tighter than the round-1 value. Both ladders are
re-centered so the new defaults sit dead-center again (stop #5):
labelZoom: [0.125, 0.175, 0.25, 0.35, 0.45, 0.5, 0.75, 1, 1.5, 2, 3]
— default 0.5 (displays 1×); finer steps below the default, the old
familiar run above; old stored 4/24 snap to 3.
blur: [10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 120]
— default 40 dead-center, min 10 per the owner's suggestion, old
ceiling kept; old 4/5/7.5 snap to the new floor.
The dead-center ladder law is restored with no exceptions; migration
pins updated (blur 8.1 → 10, labelZoom 0.5 now a member).
1428 tests, tsc, lint, build green.
Two defaults moved by owner ruling (2026-10-07): the region blur
doubles 20 → 40 (softer, wider cluster tints) and labelZoom drops 4 → 1
(node labels hidden at the overview, shown once the camera reaches fit
zoom or closer). Stored values are untouched — only the absent-field
fallbacks moved. The dead-center ladder law gains two owner-moved
exceptions (labelZoom index 1, regions.blur index 7); every other
default stays stop #5.
1428 tests, tsc, lint, build green.
Four placeholders rewritten per the owner's set (2026-10-07): the
thinking fields are an invitation to stream-of-consciousness transfer,
and everything else is implied by the field's existence — no "the
teacher reads all of this", no mechanism, no em dashes, nothing that
wraps. The reading field's placeholder no longer breaks to a second
line.
quiz: "Why this answer? Whatever comes to mind."
reading: "Whatever comes to mind as you read."
practice: "Work here, think out loud."
pause: "Ask anything, or press Enter for the next one."
The answer-field placeholders ("Type your answer…") stay: brief and
functional already (owner ruling). Quiz placeholder pin updated.
1428 tests, tsc, lint, build green.
The first cut's flat 6.8px/char table was calibrated for a generic 12px
sans; the plan titles actually render in Source Sans 3 at 11px — much
narrower — so every box carried ~10px of invisible slack per side (the
owner's "a lot of empty space left and right of the text"). The class
rates are now least-squares-fitted against getComputedTextLength
measurements of the live plan titles (narrow 3.1, wide 9.2, caps+digits
6.0, default 5.4): residuals land within ±4px per line, and PAD_X stays
16 — equal to the vertical (44−11)/2. Smoke-verified: per-side padding
now reads 14.4–19.1px across all 12 nodes (was 22.4–28.6, 10/12 over
25). And per the owner's follow-up: the in-transcript boxes keep their
WHITE outlines (gray card fill good, box edges stay white).
1428 tests, tsc, lint, build green.
The plan skin drops its fixed 160px slabs: every box now sizes to its
wrapped text (px-per-char estimate + symmetric 12px padding, floored at
96px), the wrap limit rises 20→28 chars — the early-wrap fix, boxes
grow so lines stop breaking with room to spare — and every title line
anchors to its box midline (textAnchor middle, was a x=12 left inset).
The in-transcript snapshot block speaks the dock language now: boxes
fill-card stroke-border like the composer shells, text fill-foreground,
retiring the white-on-transparent mono look. The popup and the margin
diagram keep their status colors. Mermaid proper is untouched (owner:
it's fine as is).
Character-count estimation, not canvas measurement: the layout path
stays deterministic, synchronous, DOM-free — SSR and exact render pins
keep working. Verified live on the Relational-databases plan (12 nodes,
widths 174–215 tracking the longest line, no overflow, glow intact).
1428 tests, tsc, lint, build green.
The smoke found it: Tab from body on home preventDefault'd but never
focused — home does not render the shared Composer component; its
composer is the bespoke topic textarea, and the selector matched
nothing. The chain is now topicInputRef itself.
Also verified live this round: the one-Esc law on plan/chains/settings
(exactly one action, nothing stacked), Enter-submits from the Other
input (a real session filed the custom answer immediately), and
Shift+Tab staying the thinking-level cycle everywhere.
1427 tests, tsc, lint, build green.
Owner ruling (keyboard round 2026-10-06): Esc (or any key) does ONE
action, on the topmost active layer. The plan-open-while-dictating
double-act was the symptom: the plan popup listened at WINDOW level
with no claim, and same-node window listeners all fire — the global
chain (registered first) cancelled the dictation while the plan's own
listener closed the plan. One Esc, two actions.
The law (now the house pattern, documented at the chain):
- field-level handlers claim at their element (focus in a field: the
field's action) — unchanged, already correct
- every SURFACED layer claims at the DOCUMENT level with preventDefault
+ stopPropagation — document fires before the window chain, so the
chain stays silent under any surface
- the window chain is the bottom-up fallback: dictation (a live capture,
NOT a surface — it survives the Esc that closes a plan or overlay),
then abort (escAbortTarget layers its own targets)
Converted to the claim: plan popup, session overlay, chain table,
settings dialog, setup dialog, the delete/abort dialogs (window-level
stopPropagation was never enough — same-node listeners all fire), and
the stats popover. Popovers (popover-dismiss), menus, model picker, and
the sidebar context menu already claimed correctly.
1427 tests, tsc, lint, build green.
Plain Tab now navigates instead of being pure dead weight: the global
handler dispatches lh:kbd-cycle-fields and each composer-bearing
surface answers with its own chain, gathered from live DOM at event
time (the docks mount and unmount — refs would go stale):
- chat: reading-thinking → the active dock's field → the composer,
visual order, wrap; Tab from anywhere else focuses the first live
field (navigate in)
- new-session + home: the composer is the whole chain — Tab navigates
into it
Shift+Tab is untouched (the thinking-level cycle keeps it — owner
ruling); the quiz dock's rows↔thinking Tab claim stops propagation at
document level, so a claimed Tab never reaches the dispatch — no
double act.
1427 tests, tsc, lint, build green.
Slice 1 — the ask's custom-answer field: Enter submits directly. A
single-line input with no newline to protect, and typing has already
selected the row — every Enter (shift or not) rides the same submit()
seam as the button, dictation finish-first included. No more the
Esc-then-Enter dance to file a custom answer.
Slice 2 — Enter opens the waiting check (owner, keyboard round
2026-10-06): while a question waits with the dock CLOSED (behind its
reveal invitation, or collapsed by scroll) Enter is never dead. Any
live dictation finishes FIRST — the flush lands in the reading-thinking
field, the live claim, per the nothing-gets-lost law — then the check
reveals/expands. Two seams: the reading-thinking field's own Enter
(plain or ⌘/Ctrl; Shift+Enter keeps the newline convention — the dump
convention survives for deliberate breaks, and Enter stays a newline
once the check is OPEN), and a document-level listener for plain-focus
Enter — gated to skip typing surfaces and focused buttons, which own
their own Enter.
1427 tests, tsc, lint, build green.
Owner ruling (release polish round, 2026-10-06): the compaction
summary dialog is gone. The content is model-facing plumbing — anyone
who wants to inspect it reads their provider logs. Both dividers
survive as INERT markers: the moment divider's 'Context compacted'
note and the cut divider's 'HISTORY COMPACTED · 152k tok' seam chip
render as plain text; no button, no dialog, no state. The marker parts
and the server fold are untouched — this is a pure render-side
removal.
Pins: dividers render without <button> or cursor affordances, and the
marker's summary text never reaches the DOM. 1427 tests, tsc, lint,
build green.
Owner round (2026-10-06): markdown export of sessions, actioned from
the sidebar context menu — "Export as Markdown…" sits with the
management actions, above Delete (the destructive action stays last).
- GET /api/chats/:id/markdown (404 unknown): the whole session as one
attachment — text/markdown; charset=utf-8, filename from the slugified
title ("relational-databases-joins-normal-forms.md", non-ascii titles
fall back to "session.md"). Both session kinds export.
- The client is a same-origin anchor download; the route's
Content-Disposition names the file.
- sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id
anchors are extraction provenance, not learner-facing). The engine
joins the core barrel (pure, browser-safe) for its first consumer.
The practice join (owner-approved mid-round): practice tool results are
bare acks — statement, outcome, verdict, and the model solution all
live in the assistant's call arguments. The engine now folds them the
same way the view seam folds cards: Problem blocks render the pose's
statement (solution never leaks at pose time); Concluded blocks render
the outcome as prose ("Solved with hints.") + verdict, then the
pose-committed model solution as a blockquoted reveal via
problemInputForConclude (clean-poses-only, its refusal law). A
dismissed problem concludes as "The learner skipped this problem." —
no reveal. Errored poses render nothing (the failed-tool law).
1427 tests (route pins: headers, slug, anchorless body, 404; engine
pins: pose-time statement, the reveal, the dismissal refusal, the
errored-pose silence); tsc, lint, build green.
Owner report (Electron): the Chains and Import-notes buttons were not
clickable — clicking dragged the window. Root cause: the chrome column
lacked lh-no-drag. The drag mask resolves by DOM order, and the graph
chrome's column carries the carve exactly for this reason (the
2026-09-17 mask law). The minimal home's column now carries the same
class on the same shape — byte-for-byte the graph chrome's law — and
the pointer-events juggling is gone (it was browser-thinking; Electron
needs the region carve, not the pointer filter).
Summary refinement (owner, same round): every count on its OWN line —
no dot separators; the two blocks (propositions/chains, teaching/
practice sessions) part with one empty line (a dedicated h-4 spacer);
imported notes render as a COUNT ("4 imported notes"), never the file
list. KnowledgeSummary's contract changes sources: string[] →
importedNotes: number.
1419 tests (own-line pins: no " · " anywhere, h-4 parting, count-not-
paths, singulars); tsc, lint, build green.
The chains-as-home experiment is retired (owner, 2026-10-06): dragging
the table out of its overlay home caused every mess we saw — checkboxes
by default, the bar/pill collision, the clearance band. The eventual
plan is the DATA VIEW as home; until that is designed and built, the
rendering-off home is deliberately minimal:
- The SAME composer blob home already floats — teach/practice switcher
and pick pill untouched.
- The graph chrome's survivors in the graph view's OWN places: the
top-right column — actions row (Chains + Import, the same 36px band
pills) with the cost pill BELOW it. Import always renders (the
entry-point law). No node search, no render gear (canvas things).
- A quiet centered summary (KnowledgeSummary, a pure body — pinned by
the render suite): propositions · chains, teaching · practice session
counts, the imported-sources list as truncated muted rows; the empty
knowledge base says so and names the import entry point. Biased up
off the composer (pb-40), pointer-events-none.
- NO recent sessions (the sidebar owns that, owner ruling).
The chain table returns to overlay-only duty: ChainTableBody's
onClose-optional and topBarExtra experiment hooks are reverted, and
components/chains-home.tsx is deleted. MinimalHome renders the STANDARD
ChainTableOverlay behind its Chains button — the pick flow mirrors
GraphView's contract verbatim (chainSelectOpen raises the overlay in
select mode; Done reports the lifted pick set down).
Smoke-verified live, twice: the summary shows real counts (262 props ·
160 chains, 195 teaching · 17 practice, 4 source paths); overlay
open/close via button + X; pick flow — cluster check → bar "23 nodes →
17 chains · Clear · Done" → uncheck 2 props → Done → chip "21
propositions selected"; practice session starts from the composer;
settings restore returns the canvas home. (First run's "select mode
missing" was a tester artifact: the bar is selection-gated and prop
checkboxes render only when expanded — both confirmed working.)
1417 tests (new KnowledgeSummary pins); tsc, lint, build green.
Owner refinement on the chains home: the floating top-right chrome row
sat ON TOP of the propositions table — covering content reads badly.
The two survivors (Import notes — always, the entry-point law; the
cost pill when any run exists) now ride the shared TopBar's right
cluster, left of the search input, carved out of the drag band with
lh-no-drag — consistent with the graph view, where they already live in
the top band.
ChainTableBody gains topBarExtra?: React.ReactNode (absent on the
overlay — nothing changes there); ChainsHome's floating chrome row is
deleted; the empty state keeps its own centered import button.
Parked for the next design pass (owner, this round): (1) the pick flow
reads messy — checkboxes show by default, so the pill's select mode is
near-invisible and after Done the Clear/Delete bar + the practice pill
coexist with two Clear buttons; the original vision was checkboxes
appear ONLY when picking. (2) the composer's clearance band has no
outline — the docked area needs a visual edge.
1415 tests (new pin: topBarExtra renders in the bar with the drag
carve; overlay variant injects nothing); tsc, lint, build green.
The settings Graph section splits into two switches (owner, 2026-10-06):
- EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline,
auditor, prober graph tools. Key unchanged (graph.enabled), label now
names its job; off = the graph stops growing, nothing is deleted.
- GRAPH RENDERING (new, graph.rendering, default on): display-only.
Off = the home's main view is the CHAINS VIEW instead of the canvas;
data, reasoning-chain model, and the pipeline keep working untouched.
The chains home (components/chains-home.tsx): ChainTableBody in flow —
same body as the overlay, same powers (query + sort, selection, the
delete fence with its confirm dialog), but onClose is absent so the
top-bar X does not render (the table IS the page; ChainTableBody now
takes onClose optionally). The chrome survivors float top-right below
the top bar: Import notes (ALWAYS — the entry-point law) + the cost
pill (GraphCostControl, now exported). The composer blob persists over
everything; the table scrolls CLEAR of it via a fixed pb-40 band (text
under a floating box is noise — unlike the canvas, which runs under).
Pick flow mirrors the overlay contract exactly (home lifts the pick
set; selectMode swaps the bar's confirm to Done). The chip relabels to
"Pick propositions" (no "from the map" — there is no map); while
picking, the chip row hides so the floating bar above the composer is
the one voice. Settings resolve rendering through the one core
resolver + renderingEnabled(); read server-side per navigation, so the
switch applies on the next visit home.
Smoke-verified live (headless browser): chains home renders without the
X, import + search + composer clearance confirmed, pick flow checked 2
propositions -> Done -> chip count -> practice session started with the
graph context carried (2 props, 2 chains), settings restored.
Known follow-ups (flagged, not built): after Done the bar morphs to the
table's Clear/Delete management bar while the chip also shows the count
(two voices, same set — the table's own editor law); /chat/new's pick
chip still says "from the map".
Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection
render pins (Extraction label + rendering row), the home-variant
chain-table pin (no close X). 1414 pass; tsc, lint, build green.
Owner round (2026-10-06, release polish #3): first messages are always
long, and the auto-title's hard 72-char cut landed mid-word. The cut now
snaps to the last word boundary at or under TITLE_MAX_CHARS (72, exported
and tunable); whitespace collapses first so boundaries are single-space.
The one special case (owner-specified): a single word longer than the cap
— rare but real: identifiers, URLs — splits at the cap. A boundary landing
exactly at the cap is a clean cut. Seam unchanged: titleFromPrompt
(core/contracts/graph.ts), applied once server-side at chat creation.
Pinned in tests/title-from-prompt.test.ts (6).
Vision-assessment records the 2026-10-06 thinking round: propositions as
the unit, forecast semantics (mastery = P(correct unaided retrieval)), the
Beta-with-decay estimator (BKT rejected: binary-only, guess-less, never
forgets, unidentifiable at our volume; the weighted average completed with
prior + recency + uncertainty), the e-table over outcome x rung x missClass,
uniform 1/k credit (optional missed-field upgrade unruled), no new outcome
enum values, PRACTICE-ONLY perimeter (ruled — quiz nodes are session-scoped
plan objects, no honest mapping), mastery computed-on-read over the already
durable problem/conclude calls (no stored ledger, graph untouched).
Release-0.3 captures the owner's polish brain-dump (graph rendering toggle
separate from extraction + open home-view question, question keybinds/VIM,
session naming, markdown + graph exports, graph defaults, compaction dialog
Esc law, keyboard audit) and the schema-lock position with the persistent
surface inventory and pre-release gap list (schemaVersion stamps, pi-ai
pins).
Owner report 2026-10-06: practice sessions 'open and close instantly'
while teaching morphs. Live instrumented run (Element.prototype.animate
wrapped, three transitions recorded) proved the pose/conclude morphs
fire identically in practice (54px↔327.5px, 120ms, same easing) — the
asymmetry was the FOLD: the shell's height-morph key read
`mode:${pending ? expanded : "-"}` and pending (the question's tool
call) is null in practice, so a practice dock's bar↔form toggle never
changed the key — no height morph, instant jump, only the 160ms content
fade. The ternary predates practice sessions (aeef28d, August); the
remembered Oct-5 fix (ec8315b) added the fade only. Round 16's pause
flow raised fold exposure until the seam surfaced.
Fix: dockStateKey(mode, docked, expanded) — components/dock-state-key.ts
— widened to !!pending || !!practice (the fold machine's own ownership
rule). Typing growth keeps the key (never animates); the fold rides the
exact branch teaching has used for months. Pinned in
tests/dock-state-key.test.ts.
Diagnosed (owner report 2026-10-06, 'splattered from the middle on
every navigation'): the canvas's world is 350 nodes — 262 stored
propositions + 88 derived arg_ implication nodes the chain arrows
bend through — but the drag-commit sanitizer accepted only stored
proposition ids, and writePositions REPLACES the file: every
drag-commit purged the arg_ entries, so every mount re-seeded them
on a ~1306-unit ring (seedMissingNodePositions) — arrows streaking
out of the middle, bbox inflated, fit zoomed out. A drag annealed
them back (impLinks springs), the commit stripped them again.
Fix: the sanitizer's known set = deriveView(getGraph()).nodes —
propositions AND arg_ ids (stable: minted from the chain ledger).
The grew-check becomes the honest test — some CURRENT view node
absent from the payload (a merge landed mid-drag) → exactly one
absorbing settle; full coverage → none (the old proposition-count
comparison broke once args counted).
Live ledger backfilled once: 88 arg_ positions seeded at their
chains' centroids, propositions byte-identical (no settle, no
re-anneal). Pinned in tests/commit-positions.test.ts (hermetic
LH_DATA_DIR): args persist, unknown ids drop, no-settle on full
coverage, absorbing settle on a stale payload, garbage sanitizing.
Diagnosed: the transcript's MessageContent boxes carry overflow-hidden
(ai-elements/message.tsx) — the absolutely-positioned box rendered fine
over its own message block's content (the closing-words text under the
conclude card) and clipped the moment it crossed that block's bottom
edge onto the next message. Exactly the reported cut-off.
Fix: the box portals to document.body in fixed position (the dialogs'
law) — placed from the trigger's viewport rect at open time, below the
trigger, left-clamped to the viewport; z-50. Any-scroll-close means
there is nothing to track.
Owner corrections (2026-10-06): the chevron is gone (Metadata text
alone is the trigger); the data reverts to the original small register
(text-xs rows, mono values — the 15px pass was the misread: the font
question was about the metadata label, not the data); the box opens
BELOW the trigger, always.
Owner corrections (2026-10-06): no expand/collapse; a quiet METADATA
trigger sits at the card's bottom (no divider — it just sits there);
click opens the info box in the graph-view node card's exact register
(rounded-xl border bg-card p-4 shadow-lg, w-80, floating ABOVE the
trigger so the transcript scroller never clips it); ANY scroll
(capture-phase — every scroller in the tree) or click outside closes
it, plus Esc claimed from the layered chain (usePopoverDismiss, the
popover law). Values match the model solution's font size
(text-[15px], verified — the drawer's text-xs did not); labels keep
the eyebrow register.
Owner ask (2026-10-06): reveal everything the agent filled in when
writing the problem and concluding it, at the very bottom of the
conclude card, collapsible, arriving collapsed.
The fields, both calls:
- problem: mode (recall/discrimination/composition), target
proposition ids, statement, modelSolution (the last two ARE the
visible card — the drawer shows the metadata only)
- conclude: outcome, hint rung (R0–R5/null), miss class
(cue/discrimination/execution/careless/structural/null), retest.
Seams: problemInputForConclude (native-messages) walks the reveal's
walk and folds the WHOLE problem-call input onto the conclude part
(projectView join; same refused-pose law — an unacked pose folds
nothing); ArtifactPartProps grows problemInput. Targets resolve to
proposition NAMES at render (one lazy module-cached /api/graph fetch;
failure degrades to raw ids — observability never breaks the card).
The drawer: divider + chevron 'AGENT'S DATA' toggle (mono-caps
eyebrow register), definition rows with mono values, 'untagged'/
'none' honest empties, absent entirely when the calls filled nothing
(the dismissal line, legacy bare records).
The docks' useEnterSubmits pattern, mounted for the pause in ChatView:
window-level Enter (typing surfaces and buttons excluded, so the
focused composer/button keep their own lane — no double fire) calls
sendNext, which self-guards on nextReady (inert outside the pause;
the dock listeners are unmounted by then — the two never overlap).
Owner's keyboard-first directive: body focus, a scroll, anywhere.
Owner correction (2026-10-06): the floating chip clipped inside the
composer band. Replaced with the button morph: an EMPTY composer in
the between-problems pause renders a wide green 'Next problem' button
(bg-green-600 — the answered-card correct register, mono-caps label,
arrow glyph); typing anything returns the regular white send; busy
(steer/stop) outranks the morph. Click and Enter share the one
act() lane, so the button also teaches the Enter-on-empty behavior.
The wrapper div + chip are gone from chat.tsx; inputAppends widens
its record param to { messages; kind? } (the practice gate reads it
without a cast).
Owner ruling (2026-10-06): every concluded problem opens a pause. The
agent may speak its closing words about the learner's last thinking
after the conclude result, then ENDS ITS TURN — a system-prompt law
deliberately, per owner correction, NOT tool-level terminate. The next
problem is never posed until the learner explicitly moves on; the pause
is the session's teaching space (repairs, clarifications, follow-ups —
still surgical, never re-teaching).
The move-on signal is a first-class input: { type: "next" } appends a
practice-next marker (content = single-sourced NEXT_PROBLEM_TEXT — the
model's unambiguous go-ahead: pose per the selection rules or wrap if
the arc is complete). Honest 400s: practice sessions only, nothing
active (mid-problem input rides the dock), riding after any pending
dismissal synthesis exactly like text.
The fast path (owner approved: composer-band chip + Enter-on-empty):
- Composer.enterAction gains "next" — Enter on an EMPTY composer in
the pause fires the signal; typing anything makes Enter a send again;
busy (steer/stop) outranks the pause.
- The Next chip hovers at the composer band's top-right (pick-pill
chrome register) whenever the pause is live and a problem has ever
been posed; the placeholder becomes the hint ("Ask about this
problem — or press Enter for the next one…", exact);
- the transcript renders the signal as a compact centered NEXT PROBLEM
line (mono-caps chrome); session-core appends it optimistically.
CONCLUDE_ACK_TEXT rewritten to carry the law; doctrine §VIII gains the
pause law (owner-sanctioned edit #3); 00-base's conclude/dismissal
bullets rewritten + the between-problems section added.
The relational-databases incident, diagnosed and fixed:
TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).
OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.
Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
Owner report (OpenRouter logs, 2026-10-05): the post-compaction request
arrived with NO system prompt and NO tool definitions — everything
before the cut replaced by the compaction message.
Root cause: pi-agent-core's Agent seeds a SystemMessage carrying the
system prompt AND the tool declarations at state.messages[0]
(createInitialSystemMessage — 'messages declare the provider-visible
loadout'). The record never persists it (ui-stream's settleWith filters
the role), but convertToLlm DOES receive it — and the cut projection
sliced from firstKeptMessageId, dropping [0] from the wire: no prompt,
no tools.
Fix (pi's own law — verified against pi-agent-core + pi's coding-agent):
the projection now carries the leading system message through AHEAD of
the compaction head, exactly as pi's prepareRequest re-applies
context.systemPrompt + context.tools around its compaction projection.
The cut slices the RECORD's history; the chrome is never compactable
payload. Loop start skips [0] when chrome is present (no duplicate);
record-path callers are unchanged.
Pinned: chrome-first-through-cut, exactly-once without a cut,
record-path head-first.
- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
in a Context group — CommitInput mirroring the graph budget row, hint
resolved through the SAME core resolver the trigger reads; PUT rejects
below the 32k floor with the anti-churn rationale; own section
(compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
the marker (retrying-message register, no rule lines) and the longer
CUT divider emitted immediately before the first kept message
(phase-divider geometry, chip carries '· Nk tok'); both carry the full
summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
(pure exported body for SSR pins, the delete-dialog pattern; Esc +
Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
('compacting session…') gate on it (the server's registry mutex is the
structural gate; this is the affordance); dialog mounts beside the
abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
- src/server/compaction.ts: ensureCompacted (the turn-start gate — awaits
in-flight compaction, compacts synchronously when still over threshold),
kickCompaction (the settle kick — summary starts inside the learner's
reading/answering pause; question/problem poses end runs so the pause is
always a between-runs window), registry mutex on globalThis, anti-churn
guard (empty-span skip), summary via the zero-tool one-shot sub-agent
seam on the session's OWN engine (owner ruling), usage to the turns log
keyed by the marker, record-changed + compaction-changed events
- chat-handler: the gate rides with the BYOK/key gates (before appends,
beginTurn; record re-read after); the kick at the onFinish tail
(re-resolves engine fresh; silent no-op when engineless)
- events: compaction-changed {chatId, running} — running=true is the
client send-gate signal
- settings: compaction.thresholdTokens (factory 150k, floor 32k,
clamp-never-reject) resolved concrete in EffectiveSettings
- effective threshold = min(setting, contextWindow − 16k) over the live
catalog index
- overflow recovery needs no special path: the next turn's gate compacts
- 8 service pins (mutex, events order, chained spans, failure re-arm)
- core/compaction.ts: compactionCutIndex (backwards ~20k-token walk, NEVER
separates a tool result from its call — the cut extends to the call's
assistant message; a pending pose always survives), estimateTokens
(chars/4, pi's method), newCompactionMarker (header + summary; details
carry firstKeptMessageId/tokensBefore/model/at), serializeForSummary
(labeled text, tool results truncated ~2k), planTreeToText (verbatim
tree with transcript-derived statuses), buildCompactionPrompt (the four
ruled sections: learner understanding + evolution, teaching arc, plan
tree verbatim, current moment)
- toLlmMessages honors the cut: the ACTIVE (newest) marker renders ONCE
as the head; everything before firstKeptMessageId drops; corrupt cut
degrades honestly (everything renders, never throw)
- constants: KEEP_RECENT 20k, RESERVE 16k, DEFAULT_THRESHOLD 150k
- 16 test pins
- shared/10-voice.md (agent-neutral) + shared/15-accuracy.md (research-tool
truth contract) compose for teacher, prober, AND practice
- PRACTICE_SHARED_FRAGMENTS grows beyond 40-formatting.md
- practice 00-base.md drops its stale near-copy ("You have no research tool
here" was false since round 10); the solve-and-shortcut-hunt line folds
into the problem paragraph
- teaching system-prompt.md slims (Voice/Accuracy sections removed)
- pins updated: practice-agents composition, prompt-overrides segment mirror
A session whose material is not in the map is a SUPPORTED mode now:
target is optional (absent = untagged problem, poses freely), an
explicit [] heals away at the seam (the record's canonical form is
tagged-or-absent), and when targets ARE present the round-9 law is
unchanged — whole-graph validation, names normalized to ids. The
rejection message teaches the exit ('omit target entirely'), so a
model inventing targets for an off-graph topic converges to omitting
on the retry; omitting always works, no brick is possible. Prompt
carries the clause: material in the map → ids; not → omit, problems
stand on their own.
The filter's mb-2 stacked with the session list's built-in 8px top
padding, doubling that seam to 16px while the others read 8. The
column now runs 12 / 8 / 8 / 8 — the list's own padding carries the
last seam.
Both /chat/new drafts lose their TopBar — home's register now: drag
mask first in the DOM, floating sidebar-reopen toggle as the only
pointer-events-auto spot, the empty state carrying the orientation.
The chain picker's own bar then sits at the very top of the column,
exactly like the graph view — no stacked bars, no special-casing.
The chain table drops its 'Chains' title with it: the serif title
register is RESERVED — it always names a session; the table names
nothing, so its bar carries counts + search + close only.
The stack read 12/6/4/4/14 — the search→filter seam was the tightest
gap in the column and the filter→list seam 3.5× larger (its mb-1.5
stacked with the list's 8px top padding). One rhythm now: mb-2 on the
create block, the Sessions+search row, and the filter; the list's p-2
top padding IS the rhythm. The 6px inside the create block stays —
intra-group spacing, a different layer.
The overlay is absolute inset-0 — it resolves against its nearest
positioned ancestor. Mounted at the NewSession root it spanned the
whole window, over the sidebar and the traffic lights. It now hosts
inside the draft content column's relative container (the overlay
slot on DraftShell) — the same confinement the graph view gives it:
right of the sidebar, under the top bar, inside the session's section.
The sticky composer wrapper is a pointer-events-none mask; the pill
row sat outside the composer box's re-enable, so it rendered dead —
no hover, no click. One class, the 953b22b pattern the CTA row and
composer box already follow.
The SAME pill the home screen carries — above the composer, dashed
'Pick propositions from the map' when empty, count + Clear when picked
— now lives on the /chat/new?mode=practice draft. The draft has no
graph behind it, so the pill opens the standalone ChainTableOverlay
(select mode, controlled selection, Done bar) over the /api/graph
snapshot, fetched lazily on first open. The first send carries the
picks as graphContext: the created session is born WITH its ids-first
context marker (target validation's truth set) instead of pickless —
the same contract as the home screen's practice send. Teach drafts
render no pill; practice with no picks stays lean on the wire.
The practice registry widens beyond problem+conclude: read/ls/find/grep
(the learner-filesystem set, always), research + pdf jobs (research
honestly absent when no engine is live; demo/visual stay teach-only),
and the prober's map readers behind the SAME settings gate as the
prober's (graphToolsEnabled at the composition root). Base prompt
carries the conditional doctrine: reach for them only when the
situation genuinely requires — no propositions → look at the map;
learner references a file → read it; load-bearing uncertainty →
research it. Not toggle-filtered: problem/conclude are the session,
these are exceptions.
Four pins on the mastery-trace contract: a name target normalizes to
the canonical id (case-insensitive, whole-graph set), an id passes
through untouched, an unknown target rejects one-line AND does not arm
the gate (honest activation — the next valid pose goes through), and
the legacy node-source-less wiring stays unvalidated. The base prompt
now states the convention: target takes the proposition ids from the
session context — ids are how problems are traced.
problemTool takes the whole-graph node source (chat-handler passes
graphStore.readNodes, read fresh per pose): prepareArguments silently
normalizes name targets to canonical ids so the RECORD carries ids
(the mastery trace groups on them); execute rejects unresolvable
targets with a one-line invalidInputResult — no re-listing, the [id]
proposition list already sits in the agent's context. Validation set
is the whole graph, not the picks: pickless sessions and future
graph-tools poses stay valid. Absent node source (tests) = no
validation.
The mastery-trace contract: problem targets must be traceable to
propositions. resolveTargetId (pure, chains.ts) resolves a target
string to a canonical node id — id exact first, then name
case-insensitive — against the WHOLE graph (pickless sessions and
future graph-tools poses stay valid). The graph-context marker now
introduces each pick as [id] name: statement (ids were never shown to
the model before — names only); chains stay names: comprehension
context, not tags.
It scopes what the list shows, so it sits directly above it as a
caption row; search stays the top muscle-memory target. Column now
reads: LH band → New Teaching/Practice (stacked) → Sessions+search →
Teaching · Practice · All → list.
- SESSION SEARCH goes fuzzy on the SAME fuzzyScore the model pickers
use (de0c286's util — one scoring across every search surface):
ranked by score instead of raw substring; empty query keeps order.
- SIDEBAR: New Teaching / New Practice STACK (full-width rows); the
kind filter reads Teaching · Practice · All — the real modes first,
All as the escape hatch, mirroring the stack above. Home stays in the
LH band (owner-confirmed).
- TEACH DIVIDER, the stacked-dividers bug: the plan presentation and
the first tagged quiz call usually share ONE assistant message, so
the message-level seam drew Planning+Teaching back-to-back. New pure
teachStepIndex(parts) finds the STEP boundary governing the flip
call; the divider renders there (replacing that boundary's plain
hairline — a labeled rule supersedes it), prepending before the whole
message only when the flip is in its first step. The fold (chip,
sidebar state) is untouched.
- JOBS SUITE, flake round 3: the sweep scans the SHARED jobs dir, so
parallel test FILES' running records respawn into this file's
counters — the assertions go jurisdictional (own-job ids collected;
foreign respawns are not this test's business). 1331/1331.
- ZOOM: the ⌘±/⌘0 ladder is owned, not stock roles — applyZoom sets the
factor and broadcasts 'lh:zoom'; the renderer publishes --lh-zoom; the
gutter lengths (.lh-lights-gutter / .lh-closed-toggle) divide by it so
their RENDERED size is a physical constant at every zoom — the graph
button stays glued next to the lights, nothing slides under them.
before-input-event catches ⌘=/layout variants the menu's Plus may not
claim; did-finish-load re-pushes on reload; getZoom covers first paint.
The lights themselves stay at construction position — Electron 44 has
NO setTrafficLightPosition (options-only; the first run crashed on
it). HIG-acceptable: native lights hug the corner; the web chrome
compensates around them.
- JOBS SUITE FLAKE, actually fixed this time: the seams are
module-global and the sweep scans every record — a timed-out async
test left a running orphan + late wake that poisoned its NEIGHBORS
(respawned 3 !== 0). New afterEach fence: settle every tracked
running job, unregister aborts, seams reset to inert no-ops — every
test starts from identical state regardless of upstream flake.
Verified: two consecutive full-suite runs 1326/1326.
- ENTER SUBMITS REGARDLESS OF FOCUS (owner): dictation means the dock
fields are rarely focused. New useEnterSubmits hook — a window-level
listener inside both docks (mounted only while a problem/question is
active). Plain Enter anywhere outside a typing surface submits the
practice thinking / the teaching answer; typing surfaces keep their
own handlers, buttons keep their click-activation (the collapsed
Problem bar still expands on Enter), modified Enters untouched.
Teaching sessions get the same law as practice — one standard.
- MODEL DASHES: the round-5 join("-") edit never landed (the batch
script aborted on an earlier assert before reaching it) — the source
still joined with spaces while the commit message claimed otherwise.
Now actually joined with dashes: 'glm-5.3-flash' everywhere (picker,
setup dialog, stats bar); pins updated to match.
- SIDEBAR: the practice dumbbell moves to the far right of the session
row — titles sit flush with teaching rows, cleaner list.
Under npm test's parallel load the event loop starves and the 8s
wall-clock budget expired before healthy conditions were ever observed;
a timed-out delivery then leaked a late wake into the NEXT test (the
respawn hook is module-global), poisoning even synchronous neighbors —
the recurring double-run-guard / recovery-sweep failures. waitFor
returns the instant the condition holds, so the generous budget costs
nothing when healthy. Full suite 1326/1326.
- THE 3CH RAIL: 'AGENT’S THINKING' (16ch, flush) − 'YOUR THINKING' (13ch)
= 3ch of mono — the rail widens to 3ch (border + padding), keeping the
chevrons aligned while giving the quote room to breathe (the 1ch rail
read cramped). YOU row follows.
- THE STANDARD CHORD everywhere: Enter submits, Shift+Enter is a
newline, ⌘/Ctrl+Enter works too — the teaching thinking field drops
the ⌘-only Gmail convention; the hint reads '↵ submits · ⇧↵ newline'
in both docks, shown in text mode too (it Enter-submits as well).
- The transcript-end optimistic placeholder joins the register: small
mono caps THINKING… (was text-sm sentence case).
- Model names keep their dashes: prettyModel renders 'glm-5.3-flash'
exactly as written (no auto-caps, no dash→space join).