Rulings: each problem generation is its own visible dock job (the
extraction per-chunk pattern), and practice shares extraction's
semaphore — one process-wide 3-permit pool for ALL background LLM
jobs; total background LLM concurrency bounded at 3.
A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts —
globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the
fresh-take/handed-off-permit deadlock discipline preserved for
BOTH callers. extraction.ts's withExtractSlot is now a thin
wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and
__lhPracticeSlots both die. The partitioner now acquires a permit
too (it's a background LLM job — it didn't before).
B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows
are replaced by setId:partition (phase partitioning, label
'Partitioning — title'), setId:gen:problemId (queued→generating,
'Problem i/N — title'), setId:grade:problemId (queued→grading,
'Grading i/N — title'). Each row carries its OWN abort controller
and its OWN usage (one PracticeStepLog per row — the aggregate
partitioner/generator/grader folding dies). Row key = feed id =
abort key (the dock dup-key law).
C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row:
THAT problem only (error 'Aborted', siblings continue, set settles
ready with one errored problem); partition row: the whole run;
grade row: that grade drops (answer kept, re-submittable).
abortPractice(setId) composes from row aborts + the synchronous
persisted marks (a set-abort's verdict stands before the settles
re-read it — the settle step keeps an existing error status).
/api/activity/abort kind practice now takes the ROW key (charset
admits ':').
D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity
(id = row key, problemIndex/problemTotal); dock blobs render per
row, labels carry the i/N; the aggregate row is gone.
E. Boot sweep unchanged at set grain; the live-row check is now a
key-prefix scan (any live row of the set protects it). The
updatedAt heartbeat covers the new row scheme as-is.
Tests (20 in the pipeline suite): per-problem row shape/uniqueness/
i-N labels (partition row frozen behind a gated partitioner; per-row
usage ISOLATION pin — b's log never shows on c's row); abort-one-gen
(sibling completes, set ready with one errored problem); partition-
row abort; queued grade-row abort (dead-ticket path: answer kept,
re-submittable, sibling lands); shared-pool identity + contention
(practice queues behind extraction-style holders); registry/dock
feed mapping. Test isolation hardened: unique per-store set ids,
afterEach registry/lock/pool reset (a timed-out test can no longer
poison the rest).
architecture.md: jobs-pattern paragraph rewritten (per-problem rows,
shared pool, abort routing).
1149/1149, tsc 0, lint 0, build 0.
The live import surfaced it: a multi-chunk notes source renders N feed
rows, and the dock keyed every one by run.chatId (the SOURCE id) —
React's duplicate-key error. The activity feed also dropped run.part
entirely, so all chunk rows showed identical titles.
- ActivityExtraction/FeedExtraction gain a unique row id (source id, or
'src#i' for chunk jobs) and part; the feed title carries the '· i/N'
suffix like the graph status route
- toBlobs keys extraction blobs by the row id; chatId stays the SOURCE
id (identity for links)
- abort sends the SOURCE id for extractions — aborting any chunk row
kills the source's whole fan-out (the new source-wide abort), and the
abort route's id charset rejects '#' anyway
- new pin: one source, two chunk rows → two blobs, distinct ids, same
chatId; dock fixtures updated
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:
1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
(classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
(GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
dispatches after save AND delete.
2. Setup stage 1 is self-contained: provider picker (MenuShell +
ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
re-runs on it, and the stage flips to the pickers on its own.
onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.
3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
parseModelsRefusal) joins the revert-draft-and-open-setup interception;
activity-dock reads 'needs a provider key'; dispatch-card shows
'Add a key' — both deep-link setup, which lands on stage 1 by itself.
4. Dictation trio: the token route's only 503 (missing Deepgram key)
becomes the client-authored DICTATION_KEYLESS short line; the errored
idle click clears the error and deep-links Settings -> Keys ->
DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
-> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).
Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
Platform's context-meter pair (04e336d, 18cc493) left two client seams
in my band; this closes the arc:
1. STATS-BAR CATALOG JOIN: contextWindow's denominator resolves ONLY
from pi's live catalog — stats-bar now builds the provider:id ->
contextWindow index client-side (catalogLimits in model-picker.tsx,
the client twin of the server's catalogContextLimits join) over the
picker's existing /api/models fetch/cache (useModelCatalog exported;
module-level dedupe, one fetch feeds both ModelControl and the
meter). Catalog pending or failed -> undefined index -> limit null ->
USAGE-ONLY (Platform's interim render IS the null path; the
honest-render precedent, never a fake window).
2. DOCK NUMERATOR CONSISTENCY: toBlobs' context fold was still
last-step input+output only — now the same cache-inclusive footprint
the session meter uses (all four directions; provider "input"
EXCLUDES cacheRead/cacheWrite, so the old fold read wildly low on
cache-heavy jobs). Same metric everywhere.
Review of Platform's interim edits in this band: verified as-shipped —
stats-bar's usage-only render (summary + popover), FeedJob.contextLimit
with the line-hides null treatment, and the render-test rewrites all
fit. Only fix needed: none beyond the above.
Tests: catalogLimits pinned pure (index shape, provider:id keying,
missing-model absence); toBlobs pinned on the cache-inclusive
numerator (10k+0.5k+120k+30k = 160.5k against a 200k feed limit).
889/889.
Live-verified on :3001: seedplan's bar reads "18.7k / 1M" (footprint
18725, catalog 1048576); a nope/unknown-model chat renders "12.8k"
usage-only with cost "-"; the popover detail matches; the dock blob's
context line renders "946 / 1M" from a server-joined feed (an
unintentionally real runner-adopted job — the full chain, live).
Owner ruling (2026-09-23, amendment to 04e336d): no hardcoded fallback
exists anywhere. contextWindow(record, limits) resolves the denominator
ONLY from the live index (provider:id -> contextWindow straight from
pi's catalog); a model missing from it (historical session, package
update, failed fetch) reads limit null and the meter renders
usage-only — the honest-render precedent, never a fake window.
- src/core/prices.ts: the 1354-entry CONTEXT_LIMITS table (65KB) and its
generation apparatus deleted; the fold returns limit: number | null.
PRICES untouched (regen verified byte-identical via the reworked
sync-pricing script).
- scripts/sync-pricing.ts: generates PRICES only; contextWindow leaves
the priced map, the guard, and the template.
- The dock meter's owner-selected treatment: the activity feed carries
the limit server-side. catalogContextLimits() (server/model-catalog.ts)
builds the index over the same keyed providers the picker federates;
activityFeed() joins it onto every running job (ActivityJob.contextLimit,
null when the model is missing — the dock reads it off the feed, no
client-side table, no regression while the UI's catalog-join chunk lands).
- components/activity-dock.tsx: toBlobs reads job.contextLimit (import
of CONTEXT_LIMITS gone); stats-bar renders usage-only on null limit
(mechanical interim — UI stream's join chunk follows).
- tests: usage.test.ts pins the no-fallback contract (index hit / absent
/ empty / missing-model -> null); activity-feed.test.ts pins the
server join (catalog model -> 1048576, unknown -> null, no-usage ->
null); render pins updated to the interim usage-only render.
Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
row (owner ruling: in the Tools section, not the tool-toggles list —
its subject is the map, its mechanism is agent capability). Writes
agent.graphTools {enabled} (absent = on, the toggle discipline) via
the section PUT; composeGraphTools pure seam. Its own key in the agent
contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
sessions' knowledge into new ones — the prober reads the knowledge
map" (owner wording). The honest interplay composes both: when
graph.enabled is off the row dims + disables with a "Graph is
disabled" hint (the effective gate itself is server-side, per-turn
fresh). AgentSection reads the graph section (record.graph, passed by
the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
which the old single-line fillText could not bound. wrapRegionLabel
(pure, exported): word-wrap first, char-split long paths (the
node-label discipline), capped at 3 lines, overflow folded into an
ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
once per geometry build at the SCREEN-fixed size (viewer-relative
labels — the wrap never goes stale mid-camera-move), drawn centered
line-by-line at the centroid. Deleted-session clusters keep their
hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
the knowledge-context slider, died in c690181) — removed, and pre-chat
views now render NOTHING on the left (owner ruling this round: a
"— · —" summary over an empty popover is pure noise) — the dictation
middle just widens. Stale knowledge-context comments swept in
chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
(useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
home.tsx (pre-chat dock comment).
Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.
Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
The owner's zero-configuration ruling (2026-09-23): nothing configures
the graph beyond chat models. The prior-knowledge injection — its budget
dial, its runner, its marker part, its dock/dispatch entries — is
removed across all bands; the prober now reads the learner's persistent
knowledge map on demand through two gated tools instead:
- list_sessions: every birth session, names + kinds + counts + dates
(graphSessionIndex, Platform's core seam)
- list_propositions(session): what was born there + the session's own
latest understanding level + that evaluation's date
(graphSessionPropositions) — names/levels/dating ONLY, no statement
text (owner ruling: the map's organization is the prober's business,
its prose is not)
Registered via createProberTools when chat-handler passes graphStore
(the graphToolsEnabled gate decided server-side, per-turn fresh); the
prober doctrine gains the map-review nudge at session start.
Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS
are plain records again), semantic-clusters.ts dies with its test, the
embedder dep's doc tells the wave-7 stub story, and ports.ts's
readClusterLabels/readNamingRuns surface stays untouched for Platform's
ledger-port interlude.
848/848 tests, typecheck/lint/build green.
- 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
Learner source files, on the teacher's own terms (nothing auto-injects):
- read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit,
2000-line/50KB truncation with use-offset continuation notices, image
files (jpg/png/gif/webp/bmp) returned as native image content parts
(new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both
the prober and teacher registries (user-decided 2026-09).
- pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage
via pdf_info (pdfjs text layer: per-page text/image/math-density),
targeted pdf_extract, regex pdf_search (hits count as consultation),
and pdf_render — pages painted by pdfjs onto a canvas shim in headless
Chrome (the pdfium viewer doesn't paint headless), screenshot fed back
image-in-the-loop (visual-maker idiom, providerSafePng applied). The
unread-finish gate bounces a zero-consultation brief once. Teacher gets
a text brief with page citations only; renders never leave the loop.
- MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the
look-at-renders workload wants the vision-proven model; catalog listed
glm-5.3-flash as image-capable but qwen is the known-good daily driver).
- UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch
card + activity-dock kind entries (type-forced, flagged for UI review).
- Prompts: 'Learner source files' section in teacher/30-jobs.md; read
line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions
per the self-contained-tools principle.
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.
The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):
1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
right after the write-ahead persist in handleChatRequest: its only
input is the first typed message (the topic), just made durable — so
embedding + cosine scan run CONCURRENT with the prober's turn and
the dock blob appears the moment the session starts, not after turn
one finishes. Extraction stays at onFinish (it needs answers
accumulated DURING the turn).
2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
(claimed at turn start, persisted write-ahead) used to wait for the
next turn's completion to appear — the full record sync mid-stream
is forbidden (it breaks the SDK's in-place continuation merge), so
adoption was deferred to idle. New append-only seam: core
mergeAdopted() inserts the record's new message ids in canonical
order (in-flight streaming tail kept last) and never rewrites a
local message; any other shape bails to the full-sync-at-idle net.
Wired at three points, no new standing polls:
- turn-start window: three bounded record fetches (400ms/1s/2s)
after a send catch the claimed steer while the turn streams;
self-terminating, survives the submitted->streaming flip.
- jobs-poll settle: mid-turn settles adopt append-only NOW, the
owed full sync still runs at idle.
- wake-adoption poll: each tick adopts persisted wake content, so
steers show at the wake turn's START, not its finish.
The dock stays honest with zero gap: jobs hold "running" while
queued behind a pending question ("delivering context"), then flip
done at claim/delivery — visible from start to delivery, and the
turn-start nudge (useActivity().refresh) clears it within ~150ms.
3. THE MARKER IS CLICKABLE (observability). The knowledge-context
SystemLine becomes GraphContextMarker: quiet line, popover with the
delivered propositions IN DELIVERY ORDER (most similar first — the
order the budget filled), each with its folded understanding level;
Esc / outside pointerdown close. The part data was already the audit
trail — now it's readable at the seam where it landed.
Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.
- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
step 128, 0 = off); GraphContextData + graphContextText; the
graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
under budget, most-recent UnderstandingLevel fold, count cap 64,
parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
— vector-space parity by construction); the runner (startGraphContext-
Injection one-shot gates; runGraphContextJob validates ledger parity);
wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
(nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
"Knowledge context"; model-picker PUT now spreads settings (a model
pick can no longer clear the sibling override)
Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
The old activity dock (fixed bottom-right, every view) was a cross-session
widget parked inside the session view — the same boundary confusion that
got the plan window moved. The sidebar is the app's cross-session surface,
so the agents section lives at its bottom strip now (user-locked design):
- ALWAYS present: empty means empty — the header ('Background agents',
matching the Sessions label language) sits alone; nothing hides and
nothing decorates (the total-cost span only renders when populated).
- Populates and grows when processes run: same blobs (sub-agents +
extraction runs, one poll, inferred cost, elapsed heartbeat), capped by
max-h-[40dvh] + internal scroll so a pile never crowds the session list.
- Per-blob END button (unchanged abort contract); the detail popover
becomes an INLINE expansion — the section scrolls, a floating popover
would clip at its bounds.
- Sidebar-native skin: border-t separator matching the sidebar's edge,
session-row hover language, no floating-card chrome.
- Sidebar closed = the learner opted out; no ambient signal for now
(user decision — invisibility until a clearer observability story).
- Narrow-mode count badge/drawer machinery retired with the dock.
app/layout.tsx: ActivityDock mount removed, ActivityProvider stays (the
sidebar consumes the context). Live-verified: populated rows with inline
detail + session link, abort makes the row vanish, idle renders the
header alone, closed sidebar hides the section with nothing floating.
Gates: typecheck, lint, 423/423 tests.
--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.
POST /api/activity/abort landed on main (fde46c2). 404 not_found /
409 not_running mean the process reached the desired end state on its
own — refresh and clear the abort state, no error surfaced. Genuine
failures (network/400) show 'Abort failed — try again' on the blob.
One quiet row beneath the composer/question shell (not flush to the
screen bottom; static height, outside the morphing shell):
- LEFT: context window + session cost, clickable → per-turn detail
popover (context, total, unpriced count, one row per turn with
model/steps/cost). All folds from core/prices.ts — sessionCost,
contextWindow, turnCost — no cost math in components.
- RIGHT: model + "Thinking —" — a placeholder slot for the coming
model/thinking-level CONTROL (later work with its contract changes);
display-only today, model read from the last turn's usage,
pretty-printed (Glm 5.3 Flash).
- Data: the record's turns log, seeded on mount, refreshed when the
local stream settles, kept fresh by the existing record re-syncs.
No turns yet → "—" everywhere; unpriceable turns never fold into a
fake $0 (the doctrine).
- formatUsd/formatTokens moved to components/usage-format.ts (shared
with the activity dock); prettyModel joins them, pure + tested.
One reserved corner of the shell, one blob per running BACKGROUND
process — session sub-agents (research/demo/visual) and global
extraction runs. Teaching turns are deliberately NOT here: the dock
exists to surface what the learner can't otherwise see; a running
teaching turn is already fully visible where it happens (user decision).
- Data: ONE poll of GET /api/activity (jobs across ALL chats,
title-joined, oldest-first + extraction runs), folded client-side
with core/prices.ts (jobCost). Adaptive interval — 2.5s while
anything runs, 10s idle, 4× slower on hidden tabs; a failed fetch
keeps the last feed (no flicker to empty on a blip).
- Per blob: kind icon, elapsed (ticking), cost, activity line, and an
explicit END on the blob's face. Click → detail popover (context
line, model, steps, open-session link). The context line is the LAST
model call's tokens vs the model's limit (JobUsage.lastStep — pending
agents-stream contract; "—" until it populates), NEVER a cumulative
sum. Absent data renders "—", never zero (the doctrine).
- Header: total running cost (sum of priced blobs, "—" when nothing
is priced). Narrow viewports collapse to a count badge → drawer.
- END posts to /api/activity/abort {kind, id} — the backend is being
composed by master over the streams' mechanisms; until it exists a
click locks the button and the popover says "Abort not available
yet" (never a dead control).
- Idle renders NOTHING. Mounted once in the root layout; every view
gets it for free.
toBlobs / formatElapsed / formatUsd / formatTokens are pure + tested;
SSR render tests pin blobs, the "—" doctrine, the face END, and the
priced total.