a2db80b72dae81c29bded66ea693fa7eed07aca4
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a2db80b72d |
compaction: the usage-anchored trigger + visible session state (round 15)
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. |
||
|
|
85022ae143 |
practice hardening: weak-model argument heal + parked dismissals mid-turn
The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.
- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
where the schema expects a literal union / boolean / null / number /
array is JSON.parse-d and kept when it satisfies the schema. Valid args
pass through untouched (same reference); unhealable values stay for
validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
the turn's settle path (onFinish, after persistTurn, before endTurn —
the same ordering law as the record write itself) applies it against
the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
in-flight turn; the dialog line says so (busy prop).
No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
|
||
|
|
814c2ad412 |
delete the batch practice-problems module (superseded by practice sessions)
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from history if ever revisited. Removed: core practice contracts + pipeline helpers, the PracticeStore port + adapter + data dir path, server service + API routes (sets, clusters picker, practice aborts), the practice pages + dialog/exam/list UI, chain-table's Generate handoff, sidebar nav, activity-dock practice rows, and the partitioner/ problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5). Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain, chainsForSelection, formatChainLine — the chain table and graph emission still consume them. |
||
|
|
78c7393909 | push architecture slice 1: core event bus + /api/events SSE channel (notify->fetch infra, no consumers yet) | ||
|
|
27c5d9df19 |
practice slice 4 — pipeline, registry, API, feed
src/agent/subagents/practice-pipeline.ts (sibling of practice.ts —
orchestration vs calls): createPracticePipeline(graphStore+practiceStore
+chatStore+PracticeModels). Fire-and-forget front doors returning
{set, done}: createProblemSet (refuse → resolve selection (chatId =
ANY-provenance membership per brief; nodeIds raw) → chains → snapshot →
createSet partitioning → registry row → runPartitioner → stubs land
(generating) → ONE generator per problem behind the process-wide
3-permit abort-aware semaphore (__lhPracticeSlots, extraction idiom) →
per-problem failure isolation → ready (errored problems included);
abortPractice (idempotent: controllers + 'Aborted' marks; dropped
grades keep answers, re-submittable); submitAnswers (ready-only,
already-graded rejects, one grading job per answer under the same
semaphore); deleteProblemSet (abort-first). Per-set write mutex on
globalThis (__lhPracticeLocks, audit-lock idiom); registry
__lhPracticeRuns one row per set per kind; boot sweep marks orphaned
partitioning/generating sets 'Interrupted (server restarted)'. Typed
errors: EmptySelection/UnknownChat/SetNotFound/SetNotReady/BadAnswers.
Usage folds stage logs into the registry row (v1: completion-folded).
src/server/practice.ts: thin service — strict params parsing, typed
error → response mapping (503 envelope family, 400/404/409), list/get.
Routes: POST+GET /api/practice/sets (202 {id}), GET+DELETE
/api/practice/sets/[id], POST /api/practice/sets/[id]/answers (202).
Composition root: chat-handler wires createPracticePipeline (engineFor
fresh + providerKeyMissing gate) exported as ; barrel re-
exports. Feed: observability activityFeed() += practice rows
(ActivityPractice); dock blobs (process 'practice', GraduationCap
icon, set-keyed abort); /api/activity/abort kind 'practice'.
tests/practice-pipeline.test.ts — 13 tests: end-to-end (stubs mid-
flight via gated generators, coverage complete), failure isolation,
3-permit semaphore pin, abort mid-generation, registry shape pins,
refusal-before-create, chatId any-provenance selection, typed errors,
answers→grades land + graded rejects, boot sweep (orphan + live-row),
delete. 1114/1114, tsc 0, lint 0, build 0.
|
||
|
|
3e9a85d60f |
per-session models: every session owns its model + thinking level from birth
The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of any pick, while composer and settings UIs agreed with each other and lied about the run. Root cause: a fossil default-settings record (settings.json chat section, frozen pre-slots-wave) was stamped verbatim into every new chat at birth by the new-session flow, and resolveConversationSpec prefers the per-chat record over the strong slot forever. Owner redesign — models are per session, always; the strong slot is the new-session default: - POST /api/chats stamps the newborn verbatim from the strong slot (model + thinking level); no client settings PUT on create anymore - the composer control is dual-bound: in-session it reads/writes the session's own record (GET /api/chats/:id, PUT .../settings); on the draft surfaces it binds the strong slot (picking there = changing the default) - thinking levels follow the identical mechanics (confirmed by owner) - the zombie default-settings system dies whole: useDefaultSettings, /api/settings/defaults, src/store/default-settings.ts, the chat section of UserSettings (sanitizer strips fossil copies), the legacy default-settings.json fold, and DEFAULT_SETTINGS_FILE - catalogDefaultsFor collapses to the strong slot alone - thinking display for a legacy unstamped session resolves exactly like the engine (session ?? strong ?? model ladder) Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot, the unset level, and the copy-not-live-view regression; defaults pins rewritten strong-only; legacy-migration suite deleted. |
||
|
|
7cfd8b5bc7 |
Background turn runner: every turn is a server-side process, clients are views
Owner ruling 2026-09-25. runPiTurn executes a turn decoupled from any HTTP
response: an unbounded memory-only event log (finite by construction; the
30-min liveness TTL bounds the pathological case) plus a subscriber set.
The POST /api/chat stream and the new GET /api/chats/[id]/stream are both
subscribers — identical SSE dialect; late subscribers get FULL replay
(starting at the start chunk) then live until finish + [DONE]. A view
cancelling (reload, navigate) now detaches ONLY — never aborts the turn.
The remote kill is the abort channel: POST /api/activity/abort gains
kind: "turn" (id = chatId; not_running 409 like jobs); handle.abort sets
sawAbort + agent.abort(), every subscriber closes cleanly, the partial
persists through the usual settle (onFinish runs once, then endTurn,
then unregisterTurn LAST so a view that finds nothing finds the record
current). No running turn on reattach: the cheap terminal — one
{"type":"turn-settled"} event + [DONE] (settledTurnResponse).
The live smoke caught what the unit suite had masked: the agent_end
close() had been left behind in the restructure — ReferenceError every
turn, steered into the error path; fixed and pinned (clean turns must
never emit an error chunk; finishReason stop present).
|
||
|
|
c86430e8bf |
Wave 7 chunk B: remove the embeddings machinery — the ladder collapses
The owner's wave-7 ruling: embeddings die entirely (git history is the
stash); clustering is birth-session derived (chunk A, 950ddfd), labels
are session names, and the prober's graph tools replace injection.
- settings: GraphSettings collapses to { enabled } — embedding,
embeddingModel, clustering, labeling all die. Migration story: stale
record keys are UNREAD and dropped by the sanitizer's reconstruction;
the next graph-section PUT (replace-whole) persists the collapsed
shape. The settings-panel GraphSection renders one master row.
- deleted: src/server/embeddings.ts (adapters, resolveEmbeddingPortFor,
embedNewNodes, embeddingPort, embeddingModelCatalog), the
GET /api/models/embeddings route, scripts/embed-graph.ts, tests for
all three, and the /api/models/embeddings picker surfaces in the UI.
- prices: EMBEDDING_PRICES overlay + callCost fallback die. cost-summary:
the embedding stage fold dies — back to three stages (extraction/
audit/naming) per the owner ruling; historical stages.embedding spend
in old run records is simply not displayed.
- ports: EmbeddingPort/EmbeddingResult/EmbeddingTask die. The embeddings
LEDGER surface (readEmbeddings/writeEmbeddings + EmbeddingsLedger +
EMBEDDINGS_FILE + graph-deletion's vector pruning) STAYS — inert data,
kept only so graph-context.ts compiles until Teacher's pass removes
it; the seam dies in the follow-up interlude.
- chat-handler (approved cross-lane unwiring, wave-6 precedent): the
extraction pipeline's embedder becomes a documented no-op (nothing
embedded, nothing spent, ledger untouched) until Teacher's pass
removes the phase; the graph-context injection dispatch gate, the
graphContext deps wiring, the setContextRespawn seam, and
turn-service's triggerGraphContext are gone — injection is dead
code pending Teacher's file deletions.
- scripts: backfill-graph/import-notes pass the same no-op embedder;
replay-layout derives clusters from birthSessionPartition (the same
partition the real settle uses) instead of the embeddings ledger.
- settings-body: parseGraphSettingsBody reads the master switch only —
old-shape keys in a body are ignored, not rejected; bad shapes 400.
- next.config: the @huggingface tracing-exclusion guards die with the
last reference.
Cross-lane flags: extraction.ts/graph-context.ts keep stale embed
comments + the embed phase until Teacher's full pass (their parallel
interlude owns those files — zero overlap kept by design);
semantic-clusters.ts + its test stay one more round (extraction.test.ts,
Teacher's file, still imports it — they delete it with the namer cases);
ChatSettings.graphContextTokens + the graph-context-control UI stay
until Teacher+UI.
Gates: tsc clean, eslint clean, 885/885 tests, build compiles.
Live-verified on the port-3004 dev tree: PUT /api/settings/graph with
dead keys collapses on write, bad shapes 400, GET reads {enabled: true},
/api/models/embeddings 404s.
|
||
|
|
0a4a9a11de |
BYOK provider embeddings (wave 6 chunk B): kill local transformers
Replace the @huggingface/transformers local embedder (~316MB ONNX cache) with an EmbeddingPort seam: graph.embeddingModel (nullable "provider:id" slug) selects an OpenRouter/OpenAI/Gemini adapter, resolved fresh per call from settings + the auth store so key rotations land immediately. - src/server/embeddings.ts rewritten: slug grammar, injected-fetch adapters (OpenRouter input_type, Gemini taskType batching, OpenAI index-ordered rows), honest resolution (null = skip-with-info, set model with missing key = honest error), embedNewNodes with model-change re-embed wipe + ghost pruning + usage steps, federated catalog (OpenRouter /api/v1/embeddings/models — VERIFIED live, the inverse path 404s; OpenAI text-embedding- filter; Gemini embedContent filter). - GET /api/models/embeddings: federated catalog, empty when no keys. - Extraction tail records stages.embedding usage into the run's money channel; EMBEDDING_PRICES overlay in core/prices.ts (outside the sync-generated block), callCost falls back to it; cost-summary folds the fourth stage; graph-context injection resolves its embedder per run, fails honestly when unconfigured, records query tokens. - Settings: GraphSettings.embeddingModel (null factory), section parser validates slug shape (id may contain colons — OpenRouter :free suffixes, live catalog fact). - Removal: @huggingface/transformers uninstalled, PATHS.modelCache and onnx tracing entries deleted, @huggingface tracing-excludes guard added, stale sharp/libvips comments updated to historical. Live-verified on port 3004: catalog federates 36 rows (33 OpenRouter + 3 OpenAI), embedNewNodes embedded 10 nodes via the free liquid/lfm-2.5 model (dim 1024, 435 prompt tokens, 1 call, $0), settings PUT rejects bad slugs with 400. Gates: typecheck, lint, 895/895 tests, build. |
||
|
|
f1373d5de5 |
feat(search): Tavily + Brave behind a search-provider port; Google CSE retires
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
auth is the Bearer header, not the legacy body api_key; Brave is
X-Subscription-Token) + engine resolution per owner decisions: a key
ALONE activates its engine (no engine choice needed), explicit
agent.search.engine pins the preference (both keys: Tavily first),
a pinned engine with a missing key is honestly unavailable — no
silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
(CSE closed to new customers, dies Jan 2027 — stored values become
preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
(length guard) join. UI keys-section TOOL_IDS now derives from the
catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
the settings gray-out; researcher-disable semantics ride the same
signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
web.ts's WebSearchResult is now the core SearchHit (re-exported).
|
||
|
|
cbaf6df454 |
feat(keys): first-class BYOK key management — store, catalog, /api/keys
The auth/keys wave (2026-09-19): the server now owns the flat auth.json map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev, gitignored). The store reflects itself into the child's env at boot with fill-gap precedence — real env (incl. .env.local in dev) always wins — so every consumer keeps its plain process.env reads and saved keys take effect immediately, no restart; main.cjs's spawn-time injection retired (an env copy would shadow every settings-page change). Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with shape-only validation (no live pings — owner decision), 400s on bad pastes, last-4+length masking (plaintext never leaves the store), unknown file vars preserved + surfaced. Routes: GET /api/keys, PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the env-sanitizing prologue (a dev shell may export real keys; tests use throwaway values only). |
||
|
|
524bf6024a |
feat(prompts): composeInstructions honors overrides — the P2 segment reconciliation
The other half-wired seam: Platform's settings 'agent.prompts' switches had no consumer in the agent layer, and their effectivePromptSegments mirrored the PRE-sources-v1 loader — the shared/ fragments dir (the learner-files doctrine) was invisible to it, so a naive delegation would have silently dropped the segment from both agents' prompts. - src/core/prompt-segments.ts (NEW, pure): the mirror-layout path grammar + segment types, defined exactly once; core stays zero-IO. - src/agent/prompt-segments.ts (NEW, the CANONICAL discovery): base + shared/ + per-agent fragments, override resolution, allSegmentPaths. Composition is an agent concern; the agent layer reads its own tree (the architecture law pins agent and store apart — the first direct refactor attempt tripped the enforced import/no-restricted-paths zone, hence the three-layer split). - src/store/prompt-overrides.ts: slimmed to the override-file CRUD behind the core port; shares the core grammar. Its doc no longer claims to be the canonical mirror. - teacher-agent: composeInstructions(kind, switches?) composes through effectivePromptSegments — a segment whose switch is ON and whose override file exists composes the override text. Switches-less calls use the cached pure-source composition (byte-identical to the old loader, pinned by test); AgentBuildOptions.promptSwitches threads effective.agent.prompts per turn, composing fresh so override-file writes flip the very next turn. - server barrel: re-exports the agent module's resolution for the settings routes (server→agent, the lawful direction). Tests: byte-identical source pin, override swap-in (on/off/file-absent), shared/ overridability in both agents, agent-construction threading, updated segment-list pins with shared/ in composition order. This file keeps owning PROMPTS_OVERRIDE_DIR (parallel-test discipline). Gates: 671/671, typecheck, lint, build. |
||
|
|
cb4fe25c89 |
wave 2 (platform stream): settings storage & plumbing foundation (P2)
- src/core/settings.ts: versioned DELTA record schema + sanitizer (unknown keys preserved)
— general/chat/agent{models,tools,prompts}/appearance/graph sections
- src/core/settings-resolution.ts: pure resolveSettings (factory defaults → user delta),
extraction/graph-context channel predicates
- src/store/settings.ts + prompt-overrides.ts: SettingsStore/PromptOverrideStore adapters
wired through stores.ts (effectiveSettings() fresh per call); lazy migration of
default-settings.json into record.chat
- app/api/settings/** routes (record, section bodies, prompt overrides per segment)
- chat-handler: graph gates at trigger call-sites + toolToggles injection seam
- config.ts: SETTINGS_FILE + PROMPTS_OVERRIDE_DIR paths
587/587 tests; typecheck/lint/build clean
|
||
|
|
bd3086194f |
feat: no baked-in default model — the new-session controls ARE the default settings page
Owner reframe (2026-09-18): the model + thinking + context controls on
the home graph view and /chat/new are not a draft overlay on a baked-in
default — they ARE the settings page for the default model, and the
user's last choice IS the default. BYOK first boot has no keys and
possibly no OpenRouter, so there is NO default to show: the controls
render 'no model' ('—') until the first pick.
The flash was the deeper symptom: the surfaces rendered from client
state that started empty and fetched truth afterward, so every visit
flashed the config pin (GLM/low — a dev artifact) for a few frames.
Fix at the root: both pages are now force-dynamic server components
that read the record (readDefaultSettings → initialSettings) and hand
it to the client — the first frame shows truth. Dynamic routes aren't
cached client-side (staleTimes.dynamic = 0), so every navigation
re-reads the record; the useDefaultSettings hook keeps only save-side
state (optimistic set + PUT). The GET-on-mount and its touch-guard are
gone.
ModelControl gains noDefaultModel (the default surfaces only): unset
settings render no model/level, never the catalog pin; the thinking
menu no-ops until a model exists. Chat surfaces keep the catalog
fallback — the engine runs the config pin when a session record has no
override, so the control keeps showing what will actually run.
Renamed draft→default throughout: data/default-settings.json,
GET/PUT /api/settings/defaults, useDefaultSettings. New sessions
still inherit the default on creation exactly as before.
Live-verified: first-boot 'no model' with zero flash, picker +
Shift+Tab cycle, values held across session→/chat/new→home roundtrips
and a reload (10×100ms samples, never the old default), zero console
errors.
|
||
|
|
a94a108e74 |
feat: draft settings persistence — shared model/thinking/context record for the new-session surfaces
Both new-session pages (the home graph view's topic dock and /chat/new)
held their model + thinking + knowledge-context choices in plain
useState(undefined) — every navigation reset them to config defaults.
Now both surfaces read/write ONE server-side record
(data/draft-settings.json via GET/PUT /api/settings/draft, the
render-settings pattern): whatever the learner last left the controls on
is what every later visit shows. useDraftSettings loads on mount with a
touch-guard (a slow GET never clobbers a fast choice), saves
optimistically. Session creation still carries the choice into the new
chat exactly as before; per-session settings are untouched.
The PUT validation is the exact per-chat contract — parseSettingsBody
extracted verbatim from app/api/chats/[id]/settings and shared by both
routes (unknown model/level → 400 with the supported list, never a
silent clamp). 400s on garbage, {} = the clear-overrides write.
Live-verified: Cmd+K+Enter model pick, Shift+Tab thinking cycle, context
slider — all held across home → session → home → /chat/new, and carried
into a created session's record.
|
||
|
|
05712037b2 |
Live physics drag + splat cluster regions
The renderer grows a live d3 sim (createGraphSimulation, shared with the server settle): pointerdown on a node pins fx/fy and pointer capture (preventDefault kills the compat mousedown, so d3-zoom never pans); the first CLICK_SLOP-exceeding move reheats to alphaTarget 0.3; release un-pins and the sim anneals (release-and-relax) — alpha < 0.005 settles it, a 250ms debounced onCommit PUTs the rest state. Verified live: 123fps during a 320ms drag (regions rebuilding every 3rd frame), 85-tick settle, then zero idle frames. A slop-free press stays a click (select, no reheat, no commit). PUT /api/graph/positions sanitizes (finite coords, known ids) and if the graph grew mid-drag runs ONE incremental settle with the client's arrangement as anchors (50-id PUT test: anchored drift 0.15). Cluster regions swap convex hulls for a low-res world-space SPLAT bitmap: per-member radial-gradient discs (alpha 0.2) + intra-cluster edges as round-cap strokes (Minkowski dilation — concave shapes finally render), upscaled with bilinear smoothing (the upscale IS the blur, no ctx.filter: Safari). One tiny blit per frame replaces 32 clusters x 4 offset Path2D polygons re-rasterized at DPR 2 (the old GPU coil-whine path); during the live sim the bitmap rebuilds every 3rd frame. cluster-geometry.ts retires convexHull/offsetPolygon/traceRoundedPath (tests follow; clusterHue stays). Region labels stay (centroid + clusterHue, inverse-gated on zoom). |
||
|
|
ecadededd9 |
World-space rendering: geometry cached once, zoom pays only rasterization
Fixes the zoom frame-rate collapse and rebuilds draw() around a single camera transform, per the user's Obsidian-style sizing proposal. Bug fix first: the eased zoom leaked a queued rAF callback per frame -- advanceZoom applies each step through d3-zoom, whose zoom event called markDirty() from inside the tick loop, scheduling an extra draw while the loop scheduled its own. Draws multiplied per displayed frame; sustained wheel ran at ~20fps against a 120Hz display. markDirty now skips scheduling while inTick (verified: 62 frames/500ms during a 28x zoom burst, matching pan). World-space model: every draw call now runs in graph coordinates under one setTransform(k). Sizes are defined as screen-px-at-fit and converted once per geometry build (/ kFit), so nodes, node labels, arrowheads and the hover halo scale with the graph as you zoom (0.25x painted area at half zoom, exactly k^2) while edge widths and hull borders stay viewer-relative hairlines (/ k). Cluster hull geometry (convex hull + three offset fills + solid boundary -- dashes dropped for now) is built as cached Path2Ds per data/visibility/settings change; camera moves do zero geometry math, making zoom per-frame cost identical to pan. Label wrapping runs once per build, not per frame. Region labels stay viewer-relative (18px at any zoom) as UI chrome. Picking radius scales with the world-sized discs. Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now settles in ~260ms instead of ~750ms. Resize still paints synchronously. Also fixes barrel-law lint violations from the settings wave (import from @/src/core and @/src/server, not deep module paths). |
||
|
|
de0c286311 |
feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.
Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
(off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
"minimal" was silently clamped to low by streamSimple since the swap;
nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
from the catalog, thinking level from settings ?? config, CLAMPED to the
model's supported list (a settings change between models can never send
an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
read by chat-handler at turn composition: agents are stateless per turn,
so the change is next-turn-effective by construction. The turns log
still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
(core/contracts/models.ts) + config defaults; a second provider joins
the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
provider/model or unsupported level → 400 carrying the supported list,
never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
shipped openrouter.json (offline; same source as the engine + picker).
All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
thinking buttons; menus open ABOVE; fuzzy search over "provider id
name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
price/context; the thinking menu lists exactly the chosen model's
supported levels (GLM: low|high|max — no dead options, no "off").
Model switch drops an incompatible pinned level (falls back to config,
which clamps honestly) rather than carrying a 400.
Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).
Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
|
||
|
|
83a00800e9 |
Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the streams so nothing renders against guessed shapes: - contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting, one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns — a DELIBERATELY parallel append-only usage log, not per-message metadata: the client re-POSTs its message list every turn and its copies never carry server-stamped fields, so anything inside messages gets clobbered by the next persist (the wave-3 merge-rule lesson). - contracts/chat.ts: PlanBlockData — the persisted data-plan-block part (full DAG on plan_init, one-level neighborhood on plan updates, snapshot semantics so history renders what was true then). - contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's accumulated accounting, written by the runner. - core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent per messageId (abort replays / wake retries append once); listChats no longer leaks turns into the list payload. - core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's published pricing via scripts/sync-pricing.ts) + pure folds: callCost, turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent fields render as absent, never zero. - server/observability.ts: barrel-approved surface for the transport layer (fixes the import-law violation in the graph status route that slipped through the wave-3 merge verification). Capture wiring (agent onStepFinish -> persist seam, plan-block emission) lands next as master seam work; the streams start against these shapes. |
||
|
|
a7ca33548b |
Sessions keep running across navigation: turn liveness + write-ahead persist
The bug: answering a quiz and navigating away mid-turn, then returning, re-prompted the same question. Root causes: (1) the answer was durable only at onFinish — every mid-turn view read the stale pre-answer record; (2) the server had no notion of a turn in flight; (3) remount treated the stale record as current, with no re-attach path. Worse, the stale dock let the quiz be answered again, racing two turns into a last-writer-wins saveMessages clobber. Fix (four pieces): - Write-ahead persist in chat-handler: the request body IS the client's canonical state — saved to disk BEFORE the stream starts. The answer is durable the instant the request arrives; crashes recover via the existing mount continuation. - Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId → in-flight map with a TTL leak guard; beginTurn rejects concurrent turns (409) so one turn ever owns a chat. endTurn runs in onFinish AFTER persistTurn, so pollers that see not-in-flight always read a finished record; a turn-start failure clears the registry (no lockout). - GET /api/chats/[id] reports turnInFlight; the chat page passes initiallyInFlight to the client. - Client re-attach (components/chat.tsx): while in flight, busy shimmer + composer gated + mount continuation suppressed; poll /api/chats/[id] until the turn finishes, then syncFromServer adopts the completed transcript. Stop stays local-stream only (a server turn has no client request to abort). Verified by scripted-model tests: concurrent turn 409 + registry lifecycle, write-ahead durability mid-turn, failed-turn lockout guard. |
||
|
|
1167157c23 |
Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.
The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
pure — it takes the storage ports as explicit parameters (GraphDeps).
No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
(chatStore/graphStore/jobStore); bodies otherwise verbatim.
NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
(they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
and chat-handler wire from it; agent (plan tools, sub-agent runner)
call the port objects. The shell surface (@/src/server exports) is
unchanged.
Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
|
||
|
|
e92140f993 |
Shell/core import law: public API barrels + ESLint enforcement (stage 2)
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
|
||
|
|
a72c902d30 |
v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
The teaching agent no longer runs on pi. Stack: - ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL, default anthropic/claude-sonnet-4.5 with reasoning tokens - quiz/ask_user_question = client-side tools (no server execute): the call lands as a typed tool part in input-available state; the card grades via src/core/question-core.ts; addToolOutput sends the verdict; the teacher's reaction continues the same assistant message - resume-mid-quiz is free: the pending tool part persists in the transcript - persistence: one JSON per chat (data/chats/), full UIMessage transcripts, saved onFinish (also on abort) - UI: AI Elements (owned copy-paste components, pruned to the five in use) + button-based QuizCard/AnsweredCard (ask answers never graded) - pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom protocol all deleted (recoverable from git history) - tests: question-core units carry over; chat-flow integration drives handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens); render smoke tests guard the cards (renderToStaticMarkup) Carried over unchanged: teacher system prompt, question-core, schema/ ledger/derive/extract pipeline, data model. |
||
|
|
296cc1b7b6 |
v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests): - duplicated messages: per-socket snapshot + global broadcast marking raced; replaced with a single append-only entry log (exactly-once per socket, structurally) — covered by tests/server.test.ts - delayed/missing echo of own messages: optimistic append with sending/failed status, reconciled by text when the real entry arrives - no thinking shown: thinking deltas streamed live (kind: thinking) and persisted in assistant entries so they survive reloads - ask answers graded as wrong: AnsweredCard no longer applies quiz grading to ask answers (no ✓/✗, no verdict) - broken option selection: native radio/checkbox inputs fighting React state replaced with button-based options (single source of truth, one-click select) - no URLs: History API routing — / home, /s/<file> per session (deep links, refresh, back button); session view resumes its file on mount - lost connection = dead UI: exponential-backoff reconnect with full resync - stick-to-bottom transcript with jump-to-latest pill Server: startServer() with injectable runtime factory (scripted model in tests); createSession returns file for routing; scripted model gained thinking turns. 51 deterministic tests green. |
||
|
|
25063b2a3e |
v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite
- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces - extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm) - extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context) - extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged) - src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push - web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming) - src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides) - tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm) - scripts/e2e.ts: live smoke test (manual, real LLM, never a gate) - teacher/system-prompt.md: end-your-turn rule for question tools |