c4e934c9dc9f03b5b22c90d438abdc89573ff60a
137
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b50013551 |
extraction cleanup: events ledger dies, markdown source view, auditor-as-linker (2026-09-27 rulings)
The graph becomes raw reasoning chains only. Quiz outcomes and understanding observations are no longer formalized; assessment moves to the future practice module. Events ledger removed end-to-end: LedgerEvent/LedgerEventKind/ UnderstandingLevel/ExtractionAttribution/AuditResult.dropped and snapshot nodeObservations deleted from contracts; quizEvent builder and the events step of mergeFindings gone; appendEvent/readEvents dropped from the port and store; LEDGER_FILES loses events.jsonl with a one-time purge of a stale file next to the nodes ledger; graph-deletion and the delete dialog lose event purge + quiz-answer impact counts; GraphSessionProposition becomes a pure birth list. Whole-session markdown source view: new core/session-markdown.ts is the shared formatting engine (id-anchored speaker blocks, quiz/ recall/question blocks, tool one-liners) — the extractor's prompt view and the future user-facing export render from one engine. Coverage fingerprints deliberately stay on the old line format (content-hash semantics; re-formatting never re-opens covered history). The whole session goes to the extractor — no phase cut. Auditor refocused as the identity linker: never sees the source; explores the graph via new auditor-specific tools in agent/tools/graph.ts (find — glob over name/statement/aliases; list_sessions; list_propositions with full statement text; no timestamps) instead of an injected full-graph dump; equivalence bias — paraphrases merge, only genuinely distinct propositions stay apart; no drop channel, the extractor is fully trusted; may draw bridging edges (typed follows/supports) between existing nodes and the batch, resolved by the merger with chat-scoped message-less provenance. Agentic loop, maxTurns 16. UI: the node detail card gains an edges section above the sessions list (follows-from / supported-by / what-follows / what-it-supports, rendered only when present, click-to-select neighbors); the understanding-note chips are gone. Prober list_propositions reworded to what a session covered — names only, no levels. docs/architecture.md + README updated to the as-built pipeline. Gates: typecheck, 1059/1059, lint, build. |
||
|
|
2f39766528 |
free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)
Sycophancy mitigation as a structural commitment: the model writes its full correctAnswer (plus explanation, in that order) BEFORE the learner sees the question; the commitment rides the persisted call args, is revealed on submit, and grades his free text in the model's next reply — the client never grades free recall. - tools.explain setting (segmented 'Quizzing — Multiple choice | Free recall' in Agent settings' Tools group): free arms explain and drops quiz for BOTH agents (prober variant carries no nodeId, same pattern as the probe quiz); mechanics keys never pass filterEnabledTools — quiz stays unwritable (the 2026-09-19 wedge ruling stands) - doctrine lives pi-natively in the tool descriptions (owner ruling — no conditional system-prompt chunks): explain carries the commitment rule; the MC option-building craft moved out of the prompt into the quiz description; shared/20-question-tools.md slims to the quiz-agnostic core - display seams: parseExplainInput (LaTeX-debris restoration included), dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard reveal (The answer / Why, no verdict chips), [Explain answer] format - phases/plan seams recognize both quizzing tools' nodeId tags; explain answers deliberately feed no ledger evidence (no client grading to key on — a future ruling can parse the model's grade) 1049/1049 tests, typecheck/lint/build green. |
||
|
|
876185eb6e |
chore: pi family 0.85.1 -> 0.87.1 (finishTurn, transcript contexts, typebox 1.3.27)
Mechanical fallout only; semantic deltas flagged in the wave report:
- shouldStopAfterTurn -> finishTurn ({action:'end'} wrap; loop order verified
equivalent for Wave C stranding)
- transcript rework: seeded leading SystemMessage filtered at both
state.messages read seams (ui-stream settleWith, pi-run slice); prompt
and tool declarations now ride the transcript
- streamOnce sites normalizeContext() (Context -> TranscriptContext)
- AgentToolResult details / ToolCall.arguments JsonValue narrowing (casts)
- AUTH_PROVIDER_KEYS regenerated (meta added); glm-5.3-flash price pin
follows the 0.87.1 catalog (0.15/0.5/0.05)
- vanish probe: 0/12 vs 2/6 baseline; live smoke green on PORT=3004
|
||
|
|
b84ab1cdc0 |
fix: reasoning always renders above its message; dock drops delivered subagents at delivery
TWO owner-reported bugs from the live red-circle session (chat_muhpyoll_27305e23), both investigated to root cause first: 1. THINKING BLOCKS UNDER THE MESSAGE. GLM emits trailing reasoning tokens, so the persisted content lands [text, thinking] (4 of the 6 thinking-bearing messages from [21] on in that session) - and the view canon presented arrival order verbatim, rendering the reasoning UNDER its own message. Fix: projectView stable-partitions each assistant message - reasoning parts first (their own order kept), then text/tool parts. Owner ruling 2026-09-26: the reasoning belongs above, always. Covers live partials and settled records alike (one projection canon). NOT addressed (owner ruling): the plan_tools "missing" claim in the same session - wire-verified 11 tools incl. plan_init; GLM confabulation, no mitigation wanted. 2. DOCK CARDS LINGERING AFTER DELIVERY. /api/activity lists jobs with status "running", and the status flipped only in settleSteers at the DELIVERING TURN'S onFinish - so a subagent whose card was already visible in the transcript (marker injected mid-run) stayed in the background dock until the teacher finished teaching. Fix: flip the job at the moment the result reaches the learner - noteSteerVisible (jobs.ts) called from BOTH visibility seams: the marker's message_end wire event (live steer injection, ui-stream) and the write-ahead persist (the wake's input appends, chat-handler). finalizeJob is idempotent; settleSteers/runner-200 re-run it as a no-op, and the registry's delivery promise still settles exactly as before. Tests: 1001/1001 - view-canon hoist pin (trailing [text, think, call] projects reasoning-first; think-first unchanged), noteSteerVisible unit pin (flip at the seam, idempotent, handoff no-op), and the mid-run steering e2e now holds the turn open past the injection and pins the job flipping done at the marker's message_end WHILE the turn still streams. Live-verified read-only on the owner's server: the original session reloads with every reasoning trigger above its message. |
||
|
|
ad0a6e5201 |
feat: Wave C pivot - steer markers, not follow-ups
Owner correction (2026-09-26, later): a real lesson is ONE continuous teach->quiz->teach run - there are no run-would-stop moments mid-lesson, and the run ends that DO exist are quiz stopper exits where delivery must not fire. Subagent results must land BETWEEN messages INSIDE the run, never splitting a toolCall from its toolResult. Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue: the loop drains it at TURN BOUNDARIES (after a completed assistant message AND its tool results - the bonded pair survives injection) and again at RUN START (a continuation drains before its first LLM call), then CONTINUES the run with the marker in context. steeringMode "all" replaces followUpMode. registerLiveAgent moved BEFORE runPiTurn and now hands pending markers itself: the start poll drains the queue SYNCHRONOUSLY inside the runPiTurn call, so a later registration would miss it. A marker stranded by a quiz-terminated run therefore rides the ANSWER run's first LLM call - the native replacement for the old continuation coalescing. Settlement reconciliation, the idle wake, probe-handoff exclusion, and the registry-as-truth design are UNCHANGED; stranding is now exactly the stopper-exit case. Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers pending markers into the new run, parked wake never fires); chat-flow 24/24 (new e2e through the real composition - a gated mid-run turn, a marker delivered while the turn streams, record shape [call, result, MARKER, response] with the marker adjacent to the completed unit, the model SEES the marker in its next prompt, zero wakes); full suite 999/999, tsc/lint/build green. Live smoke (PORT=3004): research dispatched in the probe turn parked behind the dangling quiz; on the quiz answer the marker injected at the answer run's START poll - record [answer result, marker, teacher response], job flipped done/delivered at settlement, brief card rendered with its sources toggle. Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section. Deferred (owner list): thinking blocks rendering after messages; vanishing tool-call output tokens. |
||
|
|
7f51259fc6 |
feat: Wave C - pi-native steer/follow-up delivery
Delivery now rides pi's OWN follow-up queue when a run is live, and a carry-everything idle wake otherwise. One registry, two vehicles; claimSteers and continuation coalescing are deleted (owner rulings, 2026-09-26: follow-ups only; one queue; the wake is the only appender). THE LIVE RUN (vehicle 1): the chat-handler registers the streaming turn's agent (registerLiveAgent, next to registerTurn); the pump hands every eligible queued marker to agent.followUp() - pi polls that queue when the run would otherwise stop, injects the markers (message_start/message_end on the live wire), and CONTINUES the same run. followUpMode "all" joins every handed marker in one continuation turn. The question stopper runs before the follow-up poll, so an open quiz is never overridden - a handed marker strands at settlement and waits (today's pacing). probe-handoffs never take this path (the handoff must START the teacher's run - a record-level branch). SETTLEMENT RECONCILIATION: settleSteers(chatId, persisted) at onFinish (after unregisterLiveAgent) resolves+flips entries the run delivered and returns stranded ones to pending - the registry stays the truth; the dead agent's queued copy is abandoned. This is what makes the handoff safe: pi owns no retry for a stranded follow-up. THE IDLE WAKE (vehicle 2): waitForIdle (unchanged gates) then ONE turn carrying EVERY pending marker as its input appends - dequeue-at-fire (the Wave B regression lesson: the wake run's own follow-up poll must never see its own cargo; a 409 pushes the cargo back and retries). CLIENT: applyWireEvent now upserts non-assistant messages by id, so in-run injected markers render live instead of dropping until settle. Tests: jobs 44/44 (live handoff, settlement stranding, handoff-waits, one-wake-carries-all); chat-flow 23/23 (the double-append pin drives the real pump through the new input); session-core 28/28 (custom-role wire appends). Full suite 997/997, tsc/lint/build green. Live smoke (PORT=3004): research job delivered mid-probe - parked behind the dangling questions exactly per semantics, landed once on the open gates, teacher folded the brief in and re-dispatched; the second (good) result rendered the full 10-source brief card and the lesson continued from it. No duplicate ids anywhere in the record. Known gap flagged to owner (NOT from this change): the server accepts malformed answer events via raw API (answers without the answers[] shape) and AnsweredCard then crashes the page render - surfaced by the smoke's scripted answers, repaired by hand in the scratch record. |
||
|
|
43ef004ab0 |
fix: pump wake turns double-appended their steer (Wave B regression)
A pump-fired wake turn carries its steer in the input appends, but the pump only shifts its queue entry AFTER the turn registers - so the handler's claimSteers popped that very steer too. The record-side steerDelivered filter checks the PRE-write-ahead snapshot and cannot see the carried copy: the marker persisted twice with the same steer_<jobId> id, two view messages shared one React key, and the client crashed on delivery (live: steer_job_muhlokbf_84e9373c). This exact bug was known and fixed 2026-09-18 (the old claimSteers received the incoming body messages and finalized-without-appending anything the request already carried). Wave B's turn-root rewrite kept only the record-side filter and lost the carried-side half. The fix restores the carried filter: a steer whose id the input already carries is claimed-and-finalized but never appended a second time. Regression pin in chat-flow.test.ts drives the REAL pump end to end (deliverJobResult with a real job record -> waitForIdle -> scripted wake turn) and asserts exactly one copy + the wake response. Two test lessons baked into comments: chatIdOf resolves a job steer's chat through the JOB STORE (a synthetic jobId with no record silently no-ops the pump), and deliverJobResult resolves at CLAIM time, not at turn completion (the record assertion waits for the wake response). The live-corrupted record (chat_muhlcltz) was deduped by hand. |
||
|
|
f3003d357a |
Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record). |
||
|
|
7ab47d4fcb |
Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
|
||
|
|
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).
|
||
|
|
e4049e4bf8 |
Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a pinned slot whose provider key was deleted passed the gate and died mid-stream masked to 'An error occurred.' The key is now gated where the model is — by what would RUN (env-first keyValue truth, so an env-shadowed key passes). - core/settings.ts: the provider_key_missing envelope family (code, provider, slots, human line naming the provider from AUTH_PROVIDER_KEYS labels) + ProviderKeyMissingError + isProviderKeyRefusal matcher. - chat-handler turn gate: refuses 503 before beginTurn when the resolved conversation's provider key is gone (slots: [strong]). - Job runner: same check via an injected dep (agent layer cannot read the key store) — a keyless engine refuses the spawn through the existing failure-shape convention, no phantom run. - Extraction pipeline: refuseIfStagesUnready refuses BEFORE any batch (first offender: extractor before auditor); the trigger skips info- level, /api/graph/import surfaces the 503 envelope per file. - Tests: 8 new pins (turn gate both ways, runner refusal, pipeline first-offender + pass-through, envelope factory, matcher); the three turn-driving suites seed OPENROUTER_API_KEY like any real setup. - Doc fix: stale EXTRACTION.chunkTokens comment in the manual-extract route (left over from eed4b4e). Known residual (pre-existing, flagged for master): the manual extract route logs BOTH config refusals as console.error (fire-and-forget path); the coverage ledger keeps everything uncovered either way. |
||
|
|
73027f49bf |
Cost channel cull: the cluster-naming fold dies — two stages, two channels
Off UI's d651f93 flag (displayCostTotals already isolates the display to extraction + audit; the naming stage row and naming runs were filtered out client-side). This culls the server side of the same ruling. THE STANDING RULING, REVERSED BY ITS OWN PREMISE: NamingRunUsage / readNamingRuns / appendNamingUsage were kept at the wave-7 namer kill ONLY because cost-summary's HISTORICAL naming stage read them. With the fold culled, that premise dies — so the whole ledger read path goes too, following the wave-7 embeddings precedent chain: consumer-less ledger seams are deleted, and the on-disk naming-runs.jsonl stays INERT (real trees keep their historical spend bytes; they are simply not displayed anywhere — the same treatment as historical stages.embedding spend). - core/cost-summary.ts: two channels in (chat + notes front doors), two stages out; RecentRun.kind collapses to "extraction" (labels field gone); CostSummaryInput.naming gone; totalDollars = extraction + audit. - ports/store/contracts/config: appendNamingUsage + readNamingRuns ports, store impls + guards, the NamingRunUsage interface, and LEDGER_FILES.namingRuns die. graphRevision no longer counts the file (a smaller revision value is harmless — reads never compare). - components/graph-view.tsx (mechanical, flagged): the defensive .filter(run.kind !== "naming") guards a kind that no longer exists. - Tests: cost-summary's both-channels fold pins; the naming fixtures and the appendNamingUsage store pin die with the seams. |
||
|
|
3327309e8b |
The all-sessions coverage GET dies: the per-session block is the coverage surface
Extraction-UX close-out interlude (master dispatch 2026-09-24, off UI's ff1ec7a confirmation): nothing client-side consumes GET /api/graph/coverage — the sidebar block and the chat-bar ring both read GET /api/chats/[id]'s coverage block. The dead route (app/api/graph/coverage/route.ts) is deleted; the manual-extract front door (POST /api/graph/coverage/extract) stays by design. No server helper was orphaned — the route was composition-only over shared seams (chatStore, extractionStatuses, stores, coverageOf). The sessionCoverage doc + the chats/[id] route comment now point at the per-session block as THE coverage surface. Pin (tests/coverage.test.ts): the route file's absence (prompt-overrides' structural-pin precedent) + the Extract POST's survival — a merge cannot resurrect the dead surface silently. |
||
|
|
2e9fd01c0c |
Extraction-UX data layer: the chunk budget becomes a graph setting; the chat view gets a per-session coverage read
Owner-locked extraction-UX redesign (dispatch 2026-09-24), part 1 (data
layer; the UI chunk follows).
THRESHOLD -> SETTING
- config.ts's EXTRACTION.chunkTokens dies; the default moves into
FACTORY_SETTINGS.graph.extractionChunkTokens (4096, the old constant's
value). GraphSettings gains the optional field, Required at resolution
via the standard ?? FACTORY pattern — pre-redesign records resolve the
default cleanly.
- FLOOR 500 (the implementer's call, documented on the field): every run
pays the graph-carrying prompt overhead out of the write channel's
money — a smaller budget fires penny-batches that cost more per
extracted token than the content is worth. The sanitizer CLAMPS up
(bd68486 clamp-never-reject discipline: fractions floor, junk drops);
the settings PUT REJECTS below-floor with the rationale in the message.
No ceiling: a large budget just delays extraction — the user's stated
cost/thoroughness tradeoff, not a misconfiguration to police.
- The trigger (turn-service.triggerExtraction) takes the budget as a
parameter — chat-handler passes effective.graph.extractionChunkTokens,
the fresh per-turn read, same as every other gate.
- The pipeline's BATCH side threads through the same number:
ExtractionPipelineDeps gains an OPTIONAL getChunkTokens getter with the
factory default (Teacher's 24 test fixtures stay untouched; the two
call-site lines inside extraction.ts are the only cross-band edits,
flagged). chat-handler + backfill/import-notes wire the live
effectiveSettings — trigger and batches share one budget, always.
PER-SESSION COVERAGE READ
- sessionCoverage(chatId) (server/chats.ts): the chat view's ring-meter
block — totalMessages/coveredCount/uncoveredCount/coveredTokens/
uncoveredTokens (the same coverageOf derivation /api/graph/coverage
serves, scoped to one chat), running (chat-kind extraction registered
for this id), and lastFoldedAt (the chat's newest coverage row's fold
— a cheap single pass; null before any fold). GET /api/chats/[id]
carries it as coverage: {...}; unknown chat -> null (the route 404s
first).
- /api/graph/coverage (the all-sessions list) compiles untouched — the
per-session read is a scoping of it, not a supersession; the UI's
sidebar/ring chunk decides which surface it consumes.
Tests: factory/sanitizer pins (clamp-up, fraction-floor, junk-drop),
resolution pins (absent -> 4096, stored 2000 wins), settings-body pins
(valid/absent/fraction/below-floor 400/junk 400), sessionCoverage pins
(derived split, newest-fold scoping, cross-session isolation, running via
the registry, null on unknown chat). 893/893.
|
||
|
|
4ded547cb5 |
Context-meter amendment: CONTEXT_LIMITS dies — pi's live catalog is the only limit source
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. |
||
|
|
2d133f1133 |
Single-node deletion: full death by node id (node interlude, owner-locked)
The remedy for undeletable nodes (preserve-graph orphans from session deletion; notes-import nodes with no chat behind them): DELETE /api/graph/nodes/[id], full-death semantics — the user pointing at the node IS the intent; references from other sessions do NOT preserve it. - core/graph-deletion.ts deleteNodeTraces: the node's every line, every edge touching it, every quiz/observation event naming it, its mapping lines, and its resting position — one synchronous atomic rewriteLedgers call; only the ledgers that changed rewrite (mtimes stay honest). Blast radius is exactly the rows naming the node id; notes source records stay (deleting a proposition is not deleting the file). Derived data self-heals on read: the birth cluster shrinks below MIN_CLUSTER_SIZE and stops rendering. - nodeDeletionImpact (GET /api/graph/nodes/[id]): the confirm dialog's pre-flight facts — edge count, quiz/observation history, referencing sessions. The snapshot's edges + nodeSessions cover part of this, but the raw event history was nowhere on the client — hence the read. - Guards (server/graph.ts deleteGraphNode), decided + documented: NO turn/jobs gates (they never write graph ledgers; the orchestration is synchronous end to end, nothing interleaves). THE EXTRACTION GATE: 409 while ANY pipeline run is registered — a mid-flight run holds pre-deletion findings and could re-reference the dying node. The run is never aborted (its spend belongs to another session — outside this deletion's blast radius; the UI retries once the dock drains. A run started after the deletion is legitimate re-learning: fresh ledgers, fresh node id, and mergeFindings' existingIds guard ignores linkTo ids that no longer exist — no dangling edges, no resurrection. Live-verified on dev data: impact read, full-death stats, honest 404 on repeat, and the file cluster fell 3→2 and unrendered itself. 859/859 tests, typecheck/lint/build green. EOF ) |
||
|
|
d1b46132b9 |
Wave 7 close-out: the embedding ledger seam dies; notes birth via mapping lines
Part 1 — the ledger + remnants interlude (master-dispatched, owner ruling 2026-09-23: nothing configures the graph beyond chat models): - The embedding ledger surface is gone: readEmbeddings/writeEmbeddings ports, the EmbeddingsLedger contract, EMBEDDINGS_FILE (config, store impls, graphRevision source list), and graph-deletion's vector-pruning step. On-disk data files stay untouched (inert history). - The extraction embedder seam dies wholesale: ExtractionPipelineDeps drops the embedder dep (chat-handler + backfill/import-notes stubs go with it), the 'embedding' phase leaves ExtractionPhase, the usage capture/persist helpers die, and stages.embedding leaves ExtractionRunUsage (historical records keep their shape — reads are lenient). Extraction is two stages again, like the local-embedder era. - Consumer-less remnants: latestQuizOutcomes + QUIZ_EVENT_KINDS, clusterContentId, matchClusterLabelings + Community + ClusterLabeling, the readClusterLabels/appendClusterLabel port seams + store impls + LEDGER_FILES.clusterLabels. NamingRunUsage/readNamingRuns STAY (standing ruling — the cost pill renders historical naming spend); clusterHue + MIN_CLUSTER_SIZE stay (live). - The 'labeler' role dies: BACKGROUND_MODEL_ROLES, ROLE_SLOT, ModelRole, the catalog pin projections, and the UI roster strings. RolePins keys shrink with the union. The notes birth fallback (owner-approved 2026-09-23, surfaced by the interlude's live smoke: production notes imports create provenance-EMPTY nodes (importNotes passes messages: null — the file's anchor is the mapping line), so the provenance-only birth rule left every imported file unclustered and invisible to the prober's session list, against the locked file-birth ruling. A provenance-empty node now births under its FIRST mapping line's chatId (ledger order); only notes-born nodes ever hit the fallback (chat nodes always carry provenance; a re-encountered node's link-append re-opens the provenance rule). Deletion-safe: mapping lines of deleted chats are stripped alongside their provenance. Live-verified on port 3004: a fresh notes import clusters under its src_ id with the file path as the label, end to end with no embedder. 845/845 tests, typecheck/lint/build green. EOF ) |
||
|
|
6b13583e96 |
wave 7: the prober reads its map — graph-context injection dies whole
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. |
||
|
|
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.
|
||
|
|
0fcffdffaf |
Birth-session clustering + the prober graph-tools data seam (wave 7 chunk A)
Embeddings are dying (chunk B removes the machinery); the clusters they
used to carry become the session that created them — derived on read.
- New src/core/session-clusters.ts: birthSessionPartition groups merged
nodes by their FIRST SURVIVING provenance point's chatId (owner-locked
2026-09-23). Deletion is self-healing by construction: stripped-birth
survivors re-attribute to their earliest remaining contributor, and
empty-provenance nodes stay unclustered. Notes imports are birth-sessions
too (owner ruling) — their label is the file path.
- graphSnapshot: clusters = the birth partition filtered >= MIN_CLUSTER_SIZE
(the hull presentation rule), id = session id, label = session name (chat
title / file path; a deleted record renders unlabeled). The persisted
cluster-labelings join dies; no LLM naming pass needed anymore.
- settleGraphLayout derives the same partition from readNodes — drops its
readEmbeddings dep. One cluster concept everywhere.
- Prober data seam (core/graph.ts, pure ports-driven like the snapshot):
graphSessionIndex (every birth session: id, name, chat|file kind,
createdAt/updatedAt, birth-proposition count, recency-ordered) and
graphSessionPropositions (a session's birth propositions with the
extractor's latest understanding level and THAT event's own timestamp —
owner ruling: quiz outcomes are ledger evidence, they never move the
evaluation date). Teacher's tool handlers consume these in-process.
- New agent.graphTools subsection { enabled?: boolean } (owner ruling:
the toggle gates prober tool REGISTRATION — agent capability, agent
section, its own subsection, NOT the tool-toggles map; absent = ON).
Lenient sanitizer, strict settings-body parser, passthrough resolution,
and graphToolsEnabled(effective) = graph.enabled && toggle for Teacher's
registry wiring.
Gates: tsc clean, eslint clean, 923/923 tests, next build compiles.
Live-verified on the worktree dev data: 10 nodes → 2 clusters labeled
with their chat titles; session index + propositions carry levels and
their own timestamps.
|
||
|
|
b1d4eadb93 |
embedNewNodes: usage-on-error + pay-once (partial-failure accounting)
Teacher's Wave-6 review flagged a money-ledger hole: a mid-batch embed throw discarded the spend of batches that had already landed (extraction only sees success results). Owner ruling 2026-09-23 (option A, pay-once): the landed vectors persist too — a retry embeds only what remains, so each text is billed exactly once. - embedNewNodes never throws on embed failures: per-batch try/catch, result gains `failed` (error message), `embedded` counts what actually landed, and usage carries the completed batches' steps either way (usage-on-error). - Zero-landed failures write nothing — the old ledger (its own model/dim) survives untouched. Stale-wipe semantics unchanged. - scripts/embed-graph.ts: honest failure output (lands count, not the in-memory total), exit 1 on failure. - tests: 4 new cases — mid-run failure (32 land, 8 remain; retry resumes and re-bills only the rest), first-batch failure (no write, no spend, old vectors survive), success carries no failed key. Live-verified on port 3004: bogus-model 400 from OpenRouter → exit 1, ledger untouched; healthy re-run embeds 0 (no re-spend); reset scratch setting to null. Gates: typecheck, lint, 899/899 tests, build. |
||
|
|
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. |
||
|
|
a6338511d3 |
feat(graph): two-mode session deletion — the first destructive ledger operation
DELETE /api/chats/[id]?mode=preserve-graph|delete-nodes (mode required): - preserve-graph: only extraction bookkeeping dies (coverage rows, legacy checkpoints) — graph ledgers byte-untouched - delete-nodes: the chat's ledger traces die. Node survival = surviving provenance point / mapping line (notes imports carry empty provenance) / surviving event. The creator's aliases + provenance are stripped from a surviving node's create line (owner ruling 2026-09-23: name/statement/createdAt are the shared identity); orphan nodes, their edges, events, mappings, positions, embedding vectors and cluster members die; cluster labels stay (immutable) Gates: 404 unknown, 409 turn_in_flight / jobs_running (with jobIds), 503 extraction_winding_down after a 10s abort-and-poll; the purge is one sync block (ledgers → jobs → chat record last). rewriteLedgers is atomic (tmp + rename). graphRevision (max mtime over graph data files) rides the activity feed + the response as the change stamp. Anti-resurrection: the chat pipeline re-checks chat liveness at each merge — a run that slips past the wind-down aborts with nothing merged or checkpointed; its partial spend dies with the chat. |
||
|
|
b18294948d |
feat(keys): LH_IGNORE_ENV_KEYS — the fresh-user switch + dev-fresh launcher
The owner needs exact new-user simulation as a one-command launch (repeatable first-run BYOK testing). Option A, owner-approved: env-hiding switch + launcher script. Fix 1 — LH_IGNORE_ENV_KEYS=1 in the keys seam: when set, every env key is invisible app-wide; only auth.json counts. The mechanism is boot-time DELETION, not seam-local filtering: pi-ai resolves provider keys from process.env NATIVELY at every request (verified — auth/context.js's defaultProviderAuthContext reads live env, no boot snapshot), so deleting the managed vars from process.env in initAuthEnv is the ONE mechanism that hides keys from every consumer — model engines included, plus the three direct process.env readers (visual-maker, pdf-reader, openRouterApiKey). realEnv stays EMPTY under the switch (nothing counts as env), and the fill-gap still reflects auth.json keys so a fresh user can add keys mid-session and engines see them with no restart. keyValue reads file-only under the switch as belt-and-suspenders (a post-boot env reintroduction stays invisible to the seam). Unset switch = today's behavior, byte-for-byte (server-keys.test.ts pins it). Fix 2 — scripts/dev-fresh.sh (npm run dev:fresh): fresh throwaway LH_DATA_DIR (.fresh-data/, wiped each launch, --keep preserves), and — the subtle bit — LH_AUTH_FILE redirected INTO the fresh dir: dev's AUTH_FILE default is the repo-root auth.json, which may symlink the real keys; the fresh instance must never see it. Sets the switch, dedicated port (default 3005, --port overrideable, standing assignments 3000-3004 refused), prints URL + data dir, safe to rerun. .fresh-data/ gitignored. Test: tests/fresh-env-keys.test.ts (own process — the switch is boot-time): the engine-path deletion asserted directly, the env/file collision resolved to the file, status rows report file sources only, mid-session saveKey/deleteKey round-trip, idempotent re-init, and the unset-switch env-wins read seam live-checked. Live-verified on 3005 via the launcher: zero federated models, all providers keyPresent false, no present keys, no auth.json/settings.json in the fresh dir; then mid-session ZAI save → 7 zai models federate instantly, source 'file' — while .env.local's real keys stayed invisible throughout. Gates: typecheck, lint, 863/863, build. |
||
|
|
9387418864 |
feat(models): clamp-on-write for pinned thinking levels + DEFAULT_THINKING_ORDER in the core barrel
Fix 1 — clamp, never reject (owner ruling, interlude 2026-09-22): a model swap with a pinned thinking level must JUST WORK. The owner hit the write-side 400 live (Claude Fable 5.1/xhigh → GLM 5.3 flash: "z-ai/glm-5.3-flash does not support thinking level xhigh"). The supported-level verdicts in validateChatSettings and validateModelsSettings are DEAD — the WRITE clamps the pin to the new model's nearest supported level (pi's clampThinkingLevel, the same translation piEngineForSpec applies at runtime) and stores the clamped result; the server is the truth — the record shows the clamped level after write. New pure helpers in model-catalog.ts, applied by ALL THREE write routes (section, defaults, per-chat — one verdict seam, so every surface that used the old 400 gets the clamp uniformly): - clampModelsLevels: every slot's level clamps against the SLOT'S OWN model (the parser already requires a model on each slot write). - clampChatLevel: the body's own model, else the record's resolved strong slot (the chain's fallback); no model anywhere → rides unchecked (runtime clamps at engine build; the turn gate refuses the model-less chat anyway). Fix 2 — DEFAULT_THINKING_ORDER moves from config.ts to core/settings (core barrel, AUTH_KEYS precedent — one source of truth, no drift). The UI imports it to auto-populate a thinking level on FRESH model picks; the ordering has no other client role. Legacy unset records keep the runtime defaultThinkingLevel fallback at engine build untouched. Import sites updated: agent/models.ts, tests. Verified live on 3004: the owner's exact case stores max (not 400); a bare chat-level write clamps to low against the strong slot's glm; unknown provider/model still 400s with the provider list. Tests: the level-verdict suites became clamp suites (the owner's case pinned verbatim: xhigh→max, minimal→low, off→low, supported pass-through, level-less slots ride); the machine-aware expectBareLevelVerdict chain is retired — clampChatLevel takes the strong slot as an explicit param, so the tests are pure again. Gates: typecheck, lint, 839/839, build. |
||
|
|
bfa906e2f8 |
feat(models): BYOK all the way down — factory pins deleted, per-need refusal gates
Owner decision 2026-09-22: 'it doesn't make sense to have any default
model configured anywhere; the application is bring your own keys.'
config.ts: MODELS deleted; ModelRole becomes an explicit 9-role union;
DEFAULT_THINKING_ORDER (medium, low, high, minimal, xhigh, max) bakes
the unset-level default — first entry the model supports; explicitly
set levels keep pi's native nearest-clamp.
core/settings-resolution: the factory layer dissolves — the chain is
role override → slot → NOTHING. resolveSettings(record) takes no
factory. Empty chains resolve to null/undefined; a thinkingLevel-only
slot is inert (stored, never run). resolveConversationSpec: chat.model
override over the strong slot, null when both empty. resolveRoleSpec
spans all nine roles for the job/extraction engines.
Refusal envelope (core/settings.ts): modelsNotConfigured(missing) —
{error, code: 'models_not_configured', missing: [slot tiers]} +
ModelsNotConfiguredError with .envelope. Per-need gates, each honest:
- chat-handler: universal turn gate (new + continuing turns) returns
503 before beginTurn — no liveness, no write-ahead persist, no graph
trigger before the gate fires.
- runner.runJob: unresolvable engine lands the job as error with the
envelope wording; no phantom loop, no result steer.
- extraction: refuseIfStagesUnconfigured throws before any stage
(turn-service info-skips so coverage retries next turn; the import
route surfaces per-file code+missing); labeler-less runs skip
cluster naming with a console line and still merge.
Write side: slot entries must carry a model — level-only slot writes
are 400s (absent slot key = clear). piEngineForSpec resolves unset
thinking levels via DEFAULT_THINKING_ORDER; piEngineFor now throws
honestly; thinkingLevelFor/modelIdFor deleted; teacher-agent, pi-run,
visual-maker, pdf-reader all read engine.level.
model-catalog: defaults/slots/rolePins null when the chain is empty
(contracts nullable) — no phantom ids in payloads. Stores drop
MODEL_PINS entirely.
Tests: pin-leaners converted to explicit injection (per-chat model
pins on scripted chats — exactly what the setup flow writes; faux
engines for agent-construction tests). New coverage: the 503 gate
envelope, the runner refusal, the extraction refusal + labeler skip.
Gates: typecheck, lint, 815/815, build.
|
||
|
|
f6626f1703 |
Fix wave chunk A: no Default option, composer = strong slot, catalog revalidation
Item 1 — no Default in the slot selectors (owner, fix wave): a "default" model is not a thing in this app. Picking Default used to delete the slot and re-arm the first-run gate — incoherent. Now the model menus carry NO clear row at all (a slot holds a pick or reads "—"), the thinking menus carry a "—" row (menu-none) that clears only the thinking pin, and the hint line is the roster only — the resolved factory pin is never named. Master's amendment confirmed: the factory-pin hint is SUPERSEDED entirely (Platform removes the pins). Item 2 — the composer control IS the strong slot (owner: "one and the same"): ModelControl (model-picker.tsx) reads the raw models section (GET /api/settings) and the federated catalog, and writes PUT /api/settings/models via composeSlotModel — the same seam the settings Agent section and SetupDialog use. Props retired: chatId, settings, onSaved, fallbackModelId, noDefaultModel — the control is self-sufficient and identical on every surface (composer, home dock, /chat/new). The per-chat model PUT is gone from the UI; legacy per-chat overrides keep resolving server-side. StatsBar/DockOverlay plumbing slimmed accordingly; chat.tsx drops its per-chat settings state and initialSettings prop. Item 3 — the composer catalog cache no longer freezes per page load: fetchCatalog(force) invalidates, and every model-menu open revalidates in the background (stale-while-revalidate — cached list shows instantly, the fresh federation swaps in). Owner's repro verified live: a key saved mid-session widens the menu without a reload. Item 4 — stale doc comments fixed: model-catalog.ts's "OpenRouter catalog"/"multi-provider future" (Platform's file — mechanical, flagged for review) and model-picker's retired "config-pinned"/ per-chat-PUT narrative. Live-verified on 3001: unpicked slots read "—" everywhere; model menus have no clear row; the "—" thinking row clears the pin but keeps the model; composer pick writes strong and preserves cheap/ vision siblings; thinking pill writes strong.thinkingLevel; groq key added mid-session appears in the open menu without reload. Caught in live verification: the record fetch was mapping the WHOLE settings record into the models section (picks never wrote) — fixed to read body.settings.models. Gates: typecheck, lint, 807/807, build. |
||
|
|
d85d8a58a0 |
fix(tools): wire the searchAvailable gate + research/pdf in the probe phase
Fix wave (2026-09-20), two owner-approved fixes: Fix 1 — the searchAvailable gate was dead code on the live wiring path: createTeacherTools RECEIVED ctx.searchAvailable but dropped it when building the inner JobToolContext, so jobTools' research gate always took its ?? true default (jobTools-direct tests passed; the production path never saw the signal). Threaded through — the gate now reads the live server signal on the teacher registry too. Fix 2 — the prober registry gains the research + pdf dispatch tools (owner ruling, 2026-09-20): these two are phase-omnipresent — the prober can verify facts or read learner PDFs while assessing. demo/visual stay teach-phase-only (they need the lesson's plan context). Same gating as the teacher side: toggle-filtered, research subject to the search gate (honestly absent when no engine is live — the prober's dispatch closes over the SAME jobTools builder, one gate, one semantics). probe_done stays always-on-by-construction; the learner-fs set untouched. - ProberToolContext (extends ProbeDoneContext with the job wiring); createProberAgent takes it; chat-handler injects jobStore + spawn + searchAvailable into the prober ctx. - TeacherToolContext.spawn's stale input union fixed (PdfInput was missing from the TYPE — runtime already passed it). - Tests: the threaded-gate describe in web-search.test.ts pins the LIVE factory path both ways (teacher + prober); question-tools' registry key-sets gain research/pdf; the prober toggle test pins the research:false path. Gates: typecheck, 807/807 tests, lint, build — all green. |
||
|
|
372d7f9975 |
feat(extraction): stage engines resolve the slot chain — engineFor on the pipeline
Chunk B, item 3 of the search port + slots wave (2026-09-20): the flagged gap closed — ExtractionModels deps were engine VALUES, keeping extractor/auditor/labeler config-pin-bound while the job runner went slot-native. The pipeline now takes the runner's exact treatment: - ExtractionPipelineDeps.engineFor: (role) => PiEngine — resolved FRESH per stage call, so a settings PUT between stages lands. Direct test values (extractor/auditor/labeler) remain the priority seam, checked FIRST; absent values fall to the chain; absent chain → config pins (unchanged). - All six stage call sites (chat + notes extract/audit, the cluster namer x2) resolve through stageEngine(role, direct). - chat-handler's extraction composition injects the full chain — piEngineForSpec(resolveRoleSpec(role, readSettings(), MODEL_PINS)) — same wiring as the job runner. Chunk B, item 4 (session-level slot-anchored model picks): VERIFIED already complete from Platform's model-slots wave — the conversation engine chain (chat-handler) anchors on effective.models.strong, the picker's default surface (catalogDefaultsFor) falls back to strong, both pinned in tests. No phase split (user decision, 2026-09-20). Capability-filtered pickers live in the settings UI (main 343be10). Tests: engineFor fills the stages end-to-end (both propositions merged through the chain, consulted in pipeline order) + the direct-value priority pin (engineFor that throws when consulted). Gates: typecheck, 795/795 tests, lint, build — all green. |
||
|
|
ed264c936d |
feat(search): web_search onto the injected port — Google CSE retired, research honestly disabled
Chunk A of the search port wave (2026-09-20), per master's assignment: - web.ts: the Google Custom Search backend is gone — web_search now runs on the core search port (SearchProvider), injected as a dependency (import law: the tool never imports server code). createWebTools(search) resolves the LIVE provider fresh per call; null = the honest unavailability string (no silent fallback, owner decision). web_fetch unchanged (needs no engine). - runner: JobRunnerDeps.searchProvider — the server composition threaded into the researcher; jobs already in flight when a key disappears still fail honestly through the same seam, never crash. - jobTools: the research dispatch tool DROPS from the teacher's registry when ctx.searchAvailable() is false (owner decision: registry-level drop — the model never sees the tool, no doomed dispatch, no error card). Absent seam = available (tests). chat-handler injects server/searchAvailable + searchProvider. - tests: stubFetch (the Google wire stub) retired; jobs.test.ts threads a recording stub provider through all four research runners and asserts the scripted query hit the port; new web-search.test.ts covers the factory contract (null → honest string, hits, error strings, fresh resolution, web_fetch independence) + the registry drop matrix. Gates: typecheck, 793/793 tests, lint, build — all green. |
||
|
|
f9e3a4c43d |
feat(models): strong/cheap/vision model slots + multi-provider engine federation
Model slots wave (2026-09-20), chunk B:
- settings: new `models` section {strong, cheap, vision} slots (each
field independent, same shape as a role override); ROLE_SLOT maps all
nine roles to tiers (strong=prober+teacher, cheap=researcher/
demoBuilder/extractor/auditor/labeler, vision=visualMaker+pdfReader);
resolution chain per role: override → slot → factory pin.
- config: ModelSpec gains provider (all pins openrouter, the BYOK
default); MODEL_PINS in stores.ts now spans all nine roles (the strong
slot's factory rep IS the prober pin).
- engine: piModels() registers EVERY builtin provider catalog;
piEngineForSpec(resolved spec) is the new engine factory (pi-ai
resolves per-provider keys from env itself); piEngineFor stays as the
config-pin fallback. chat-handler resolves the conversation chain
(chat override → strong slot → pin) and injects engineFor into the job
runner, closing the old gap where per-role overrides never reached
job engines.
- /api/models: models federate ONLY keyed providers (providers surface
with keyPresent, env-wins, fresh per call); slots (resolved tier pins)
join rolePins; catalogDefaultsFor inherits the strong slot; the models
settings section gets the same catalog gate as chat.
- pricing: PRICES/CONTEXT_LIMITS uniformly keyed provider:id
(regenerated from ALL pi-ai catalogs, 1354 keys); usage stamps
(TurnUsage, JobUsage, extraction stages) switch to provider:id —
owner decision: no legacy compat, old stamps render unpriced.
|
||
|
|
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).
|
||
|
|
ec38f91a2b |
feat(keys): free-form model-provider keys — pi-ai provider table on /api/keys
Provider-keys interlude (owner spec, 2026-09-19): any pi-compatible
provider's key is manageable, not just the curated set.
GET /api/keys now also returns providers: {id, envVar, label}[] — every
pi-ai provider that takes a plain api-key env var (33 today), derived
from pi-ai's own provider→env-var table. No-drift mechanism: the table
is generated from pi-ai (builtinProviders + findEnvKeys over a
truthy-for-every-var proxy env) and pinned by
tests/auth-provider-keys.test.ts, which re-derives the truth at test
time and asserts EXACT equality — a pi-ai upgrade fails the gate loudly.
Owner-spec exclusions: github-copilot (OAuth), amazon-bedrock (AWS),
google-vertex (ADC), azure-openai-responses (base URL), the cloudflare
pair; anthropic keeps ANTHROPIC_API_KEY only. OPENROUTER_API_KEY
graduates from the curated catalog to the provider table (dedupe — the
UI pins it always-visible); curated stays Deepgram + the Google search
pair. Statuses: curated + present vars + unknown file vars — absent
provider keys never spam rows; the dropdown is discovery.
Write side: PUT/DELETE accept provider env vars (generic truncation
guard — trim, no whitespace, ≥12; family checks stay on the curated
three). Present-in-env provider vars surface as status rows so
env-wins/shadow semantics stay honest.
|
||
|
|
a3b6a495d3 | Merge branch 'feat/edge-glyph' | ||
|
|
a828d9b6d9 |
feat(models): rolePins on GET /api/models — the background roles' resolved pins
Settings interlude (owner request, 2026-09-19): the route now also returns
rolePins: Record<BackgroundModelRole, {model: {provider, id},
thinkingLevel}> — the six background roles' RESOLVED models (the user's
agent-models override falling back to the config.ts MODELS pin), so the
settings UI shows each role row's 'Default: <actual pinned model>' instead
of a generic config caption. Same resolution the agent factories use
(effectiveSettings), so the caption can never drift from what runs.
Pure projection (rolePinsFor) + the fs wrapper (catalogRolePins); pins
tested against config defaults and model/thinkingLevel-only overrides.
BACKGROUND_MODEL_PINS exported from stores.ts for the test (was local).
|
||
|
|
3e263a18cc |
Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- Cluster namer bills the money channel now: per-step usage + model captured via onStep, persisted as NamingRunUsage records to data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted runs persist too — money spent either way). callUsage promoted to extraction.ts module scope for reuse. - core/cost-summary.ts: the pure fold behind the pill — chat runs, notes-source runs, and naming runs into stage totals (extraction / audit / naming) + newest-first recent runs. Dollars INFERRED read-time via prices.ts, never stored; unpriced models excluded-but-counted (render '—', never a silent zero); tokens always sum. - GET /api/graph/costs (src/server graphCosts composition, thin route). - Graph view: bottom-left cost pill (cumulative dollars headline) + expandable breakdown panel (stage rows with tokens secondary, recent runs with time/title/merged/cost); fetched on mount, refetched when a pipeline run finishes; merged into one bottom-left stack with the running-pipeline pills. - Tests: pure-fold suite, naming capture wiring (full importNotes -> naming path), store idempotency + strict reads. |
||
|
|
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. |
||
|
|
6c42393e42 |
feat(tools): the P2 toggle bridge — toolToggles first-class, definitive vocabulary
Reconciliation of the diverged tool-toggles seam (2026-09-19, master's P2): Platform injected the resolved delta as an ad-hoc intersection on AgentBuildOptions while the factories' enabled? param sat unread — the seam was inert on both sides. - teacher-agent: AgentBuildOptions gains toolToggles?: EnabledTools; both constructors thread it to the factories (createTeacherTools / createProberTools). An explicitly injected opts.tools registry is never filtered — the caller owns it. - Vocabulary ADJUDICATED with the user (2026-09-19): TOGGLEABLE_TOOLS is the definitive list — the job tools only (research/demo/visual/pdf). quiz/ask_user_question stay always-on forever (the prober's flow is graded-quiz brackets -> probe_done; quiz-off collapses assessment and teaching doctrine is built on graded verification — the wedge danger); the learner-fs set, plan tools, and probe_done also always-on. - settings-body (write side): unknown tool keys reject with 400 + the supported list, mirroring the prompt-switch validation; the read-side sanitizer stays lenient for forward-compat. - chat-handler: the intersection is gone (field now first-class) and the seam comment states the adjudicated vocabulary. Gates: typecheck, 28 new/updated tests green (tool-toggles-bridge, question-tools, settings-body), chat-flow + settings suites green. |
||
|
|
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
|
||
|
|
487f5d4cea |
wave 1 (platform stream): background-observability accuracy + owner-driven follow-ups
- observability: liveness-gated stale-job recovery, extraction run registry on globalThis with explicit 'embedding' phase, /api/jobs force-dynamic, client refresh nudges at every seam - headless: providerSafePng canonicalizer (multi-IDAT Chrome PNGs → single-IDAT) fixing qwen 400s - composer: Enter routes through pure enterAction — accepts a pending plan verdict (owner decision) - markdown chrome: session-scoped mermaid overlay + md-blocks component set + pan-zoom math, corner-copy (clipboard grant in electron main), math/table/code block fixes, heading weight 900 543/543 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.
|
||
|
|
597b3f43e1 |
fix: wake-turn steer delivered twice — claimSteers re-claimed the steer the pump's request already carried
The wake pump fires its turn with the steer INSIDE the request body but dequeues the queue entry only after the turn returns 200 — so the entry is still queued when the handler claims. The record doesn't carry the steer yet (write-ahead persist hasn't run), so claimSteers claimed it a SECOND time and the handler appended it: outgoing = [...body, ...claimed] held two copies of the same deterministic steer id, and the write-ahead persist saved that for the whole wake turn. The client's adoption poll rendered both -> React duplicate-key warning (steer_ctx_<chatId>). withMessageIds deduped only at onFinish, so the final record looked clean. Fix: claimSteers(chatId, incoming) — a queued steer whose id is already in the turn's incoming list is being delivered BY this very turn: finalized and resolved, never appended again. |
||
|
|
7902984bd6 |
feat: Electron desktop wrapper — LearnHarness.app (macOS arm64)
The web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.
Bands:
1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
PATHS.modelCache routes the transformers.js embedding-model cache
(~300MB) to <data>/models when the seam is set — the default cache
inside node_modules is read-only in a packaged bundle. All existing
fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
(read-only asset, ships inside the bundle by design).
tests/data-dir-seam.test.ts covers set/unset via child process.
2. Standalone build (next.config.ts + scripts/build-server.mjs):
output 'standalone'; outputFileTracingExcludes data/**/* keeps the
repo's real session data out of the bundle (verified: an unexcluded
scratch build traced 34 files / 716K of real chats + ledgers).
Two empirically-verified tracer gaps required outputFileTracingIncludes:
pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
/api/models without them) and onnxruntime-node's sibling dylib
(libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
and .next/static per docs, refuses a leaked data/ dir, and asserts
the runtime-read prompt .md assets are traced.
3. Electron shell (electron/main.cjs, no preload):
- single-instance lock (two instances would fight over the data dir);
- free port via listen(0); spawn via process.execPath +
ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
- BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
merged into the child env only when absent from the real env
(auth UI is the settings milestone; file is hand-editable);
- poll until the server answers (no ready event), then open a
contextIsolation+sandbox window; deny-by-default permission
handler (media allowed only from the loopback origin — mic/STT
groundwork); will-navigate pinned to origin, window.open denied
with external links to the system browser; macOS Edit-menu roles;
- quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
mirrored to userData/logs/main.log;
- dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).
4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
files = electron shell only; the whole .next payload ships via
extraResources from .next (filter standalone/**/*) — NOT from
.next/standalone directly: electron-builder hard-excludes a copy's
root node_modules and the default pattern is a literal match, so
node_modules/ and .next/ were silently dropped and the server died
with 'Cannot find module next'. Asar holds no native code.
Unsigned (identity: null) until a Developer ID cert exists; hardened
runtime + audio-input entitlements laid now for the STT milestone.
desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).
Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.
Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
|
||
|
|
2678f04e7a |
feat: extraction coverage — message-granular ledger + token-batched runs (d132373 vision)
THE MESSAGE IS THE UNIT OF COVERAGE (vision-extraction-coverage.md): a per-chat append-only ledger (coverage.jsonl) records which messages the pipeline has extracted, by content fingerprint over exactly what the extraction view sees (one shared line builder — core/coverage.ts); the uncovered remainder is DERIVED at read time, never dual-tracked. A quiz answer merging into the asking message re-opens only that message; plan-block data-part attach never re-opens anything. - The trigger (turn-service) replaces every-3-quiz-answers with uncovered-tokens >= EXTRACTION.chunkTokens (4096) — quiz-less sessions extract too, content size is the honest signal. - runExtraction is now chunked: token-budgeted batches of the oldest uncovered consecutive messages (a tool call + result never split), each merged batch checkpointing its messages' coverage atomically — an abort or error loses at most the in-flight batch; merged batches stay covered. - Observation refs are batch-tagged (obs:<batchTag>:<sourceId>): re-runs of the same batch dedupe; a re-opened message's fresh understanding lands as a new event. Fixes the old obs:p1 position-collision. - New API: GET /api/graph/coverage (exact per-session derived coverage + running flags, most-uncovered first) and POST /api/graph/coverage/extract (manual run for one session, 409 while in flight). - Graph view chrome: the coverage button opens a panel with every session's covered/uncovered tokens and a per-session Extract button — the manual answer to aborted runs, errors, and dormant tails. Renders even when the graph is empty: an empty graph with uncovered sessions is exactly when it matters. Polls only while open. - Notes imports keep the whole-file checkpoint path (coverage undefined). - Tests: pure-core coverage suite (11), chunked-pipeline suite (3: multi-batch + no-op, abort-keeps-earlier-batches + remainder re-run, re-open alone + data-part invisibility), panel render suite (5); pendingQuizAnswers and its tests deleted as dead code. 486/486. Live-smoked on real GLM: manual extract of "teach me modal realism" ran one batch, wrote 4 coverage entries, panel flipped to "1.3k covered · complete" with the button disabled, graph refetched (nodes 11), usage persisted with real stats. |
||
|
|
31978f3fc3 |
fix: background-delivery ground rules — the dock is the source of truth, delivered content shows the moment it persists
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.
|
||
|
|
1d45d77303 |
feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
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. |
||
|
|
f5edc7f0a5 |
Semantic topic clusters: local embeddings replace Louvain
Louvain only ever found the reasoning chains (the graph is disconnected by nature — every connected component became its own cluster) and edge-orphans could never join anything. Clusters are now RELATED TOPICS: propositions grouped by meaning. - Embedder (src/server/embeddings.ts): in-process transformers.js v4 running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download), MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs, L2-normalized so cosine = dot. Model/dim mismatch voids the ledger (full re-embed). Injected at the composition root — the agent layer never imports the server layer (architecture law). - Ledger (data/graph/embeddings.json): model + dim + vectors + the persisted cluster partition. Reclustering runs at the END of every embedding batch (post-merge hook, both front doors) — never per read (the agglomerative pass is ~1.5s at 338 nodes). - Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers stay singletons by default; attachStragglers is the all-in toggle. Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans land in real topics, 10 stragglers. - Consumers (snapshot, settle, namer, replay) read the persisted partition — one cluster concept everywhere; core-survival label matching unchanged (20/26 topics kept labels). - Louvain retired: detectCommunities + graphology* deps deleted (graphology-louvain had leaked into the client bundle since the clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed. - scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s including the one-time model download). |
||
|
|
975a78a304 |
Cluster gravity: per-cluster centroid pull force in the engine
The 'virtual node' realized as a centroid force: two allocation-free O(n) passes per tick (membership resolved once at sim creation; Float64Array sums). Measured indistinguishable from force-off at 338 nodes (271ms vs 272ms per 400-tick settle). - createGraphSimulation: SimOptions.clusterPull + linkStrength + velocityDecay (physics controls plumbing); degree-scaled link stiffness so linkStrength=1 is exactly d3's default - settleGraphLayout split into layout-settle.ts (it computes the Louvain partition for the pull); layout.ts stays browser-barrel-pure - barrel no longer re-exports clusters.ts: graphology + louvain are fully out of the client bundle (verified against built chunks) - replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15 tuned by sweep: cluster spread 63->36 world units, centroid separation and total span unchanged (no blob merge, no collapse) |