@genesislcap/ai-assistant 15.19.6 → 15.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/ai-assistant.api.json +605 -72
  2. package/dist/ai-assistant.d.ts +404 -25
  3. package/dist/chat-driver.cjs +341 -28
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +341 -28
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +630 -20
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +33 -7
  9. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +63 -2
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +9 -3
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  14. package/dist/dts/config/config.d.ts +44 -0
  15. package/dist/dts/config/config.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +187 -5
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts.map +1 -1
  20. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  21. package/dist/dts/utils/context-tokens.d.ts +156 -0
  22. package/dist/dts/utils/context-tokens.d.ts.map +1 -0
  23. package/dist/dts/utils/history-transform.d.ts +76 -14
  24. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  25. package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
  26. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
  27. package/dist/esm/components/chat-driver/chat-driver.js +179 -34
  28. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +12 -4
  29. package/dist/esm/main/main.js +391 -21
  30. package/dist/esm/main/main.styles.js +128 -0
  31. package/dist/esm/main/main.template.js +64 -29
  32. package/dist/esm/state/debug-event-log.js +1 -1
  33. package/dist/esm/utils/condense-history.js +1 -5
  34. package/dist/esm/utils/context-tokens.js +339 -0
  35. package/dist/esm/utils/history-transform.js +101 -19
  36. package/dist/esm/utils/resolve-context-budget.js +84 -0
  37. package/package.json +16 -16
  38. package/sandbox/README.md +93 -4
  39. package/sandbox/controls.ts +77 -10
  40. package/sandbox/fixtures.ts +163 -6
  41. package/sandbox/sandbox.css +54 -1
  42. package/sandbox/sandbox.ts +384 -7
@@ -3586,6 +3586,27 @@
3586
3586
  }
3587
3587
  }
3588
3588
  },
3589
+ {
3590
+ "kind": "method",
3591
+ "name": "syncContextGuard",
3592
+ "privacy": "private",
3593
+ "return": {
3594
+ "type": {
3595
+ "text": "void"
3596
+ }
3597
+ },
3598
+ "description": "Push the mid-loop context guard down to the driver (GENC-1567).\n\nCalled wherever the inputs can change — the resolved provider status, and a\nfreshly built driver. A driver that never receives one simply runs without\nthe guard, which is the right default: it fires only when a real context\nwindow is known."
3599
+ },
3600
+ {
3601
+ "kind": "field",
3602
+ "name": "contextGuardPolicy",
3603
+ "type": {
3604
+ "text": "ContextGuardPolicy | undefined"
3605
+ },
3606
+ "privacy": "private",
3607
+ "description": "Guard policy pushed to the driver, or `undefined` when the host has switched\nthe gate off — so `enabled: false` really does mean no local blocking of any\nkind, UI or mid-loop.\n\nA MARGIN, not a threshold: the driver resolves the window of the provider\neach call actually goes to. The fallback window rides along because the\ndriver cannot learn it any other way — without it, a host that knows its\nwindow while its provider does not report one had `fallbackContextLimit`\nprotecting the composer and nothing else.",
3608
+ "readonly": true
3609
+ },
3589
3610
  {
3590
3611
  "kind": "method",
3591
3612
  "name": "loadProviderStatuses",
@@ -3938,13 +3959,40 @@
3938
3959
  "description": "Whether persistence is currently active (on + a provider) for this session.",
3939
3960
  "readonly": true
3940
3961
  },
3962
+ {
3963
+ "kind": "field",
3964
+ "name": "contextBudget",
3965
+ "type": {
3966
+ "text": "ResolvedContextBudget"
3967
+ },
3968
+ "description": "Context-headroom thresholds for the active model (GENC-1567) — the warning\nand block lines, plus the tail budget compaction aims at.\n\nThe agent's own `contextReserve` is deliberately NOT folded in here. This is\nthe AMBIENT budget, which protects any turn from any agent; a per-agent\nreserve is an additional, larger requirement checked when that agent takes\nownership of a flow, where there is a turn context to resolve its function\nform against.\n\nOverhead is read off `messages` (the Redux mirror) rather than the driver's\nown history, which the compaction plan uses. That is safe rather than sloppy:\n`estimateSystemOverhead` only looks at messages BEFORE the first usage-bearing\none, and the two lists agree completely that early — the drift between mirror\nand driver history is only ever at the tail.",
3969
+ "readonly": true
3970
+ },
3971
+ {
3972
+ "kind": "field",
3973
+ "name": "compactionPlan",
3974
+ "type": {
3975
+ "text": "CompactionProjection | null"
3976
+ },
3977
+ "description": "What compacting right now would reclaim, or `null` when it would not be\nworth it (GENC-1567). Sized against FoundationAiAssistant.contextBudget,\nso the plan aims at this model's window rather than a fixed default.\n\nReads `messages` to stay reactive as the conversation grows: the driver's\nhistory is not a tracked observable, so every consumer of this keys its\nre-evaluation off the Redux mirror.",
3978
+ "readonly": true
3979
+ },
3980
+ {
3981
+ "kind": "field",
3982
+ "name": "contextGate",
3983
+ "type": {
3984
+ "text": "'ok' | 'warn' | 'blocked'"
3985
+ },
3986
+ "description": "How close this conversation is to the model's context window (GENC-1567).\n\n**Derived, never latched** — the opposite of FoundationAiAssistant.blocked.\nA budget wall stays set because nothing the user does fixes it; this clears\nitself the moment the conversation shrinks, which is precisely what the user\nis being asked to do. The two must not be conflated: `blocked` is also\ndeliberately preserved across `resetSession`, and a context gate obviously\nmust not survive starting a new chat.\n\n**A flow in progress is never blocked, only warned.** Machine state lives\noutside the transcript, so a stateful flow is not desynced by compaction —\nbut the summary can still drop detail the flow's own prompt leans on, and\nblocking mid-journey would strand the user between a flow they cannot finish\nand a compaction that is a poor idea right there. Letting the flow complete\nand gating on the way out is the lesser harm; the headroom reserve exists so\nthis is rare rather than routine.",
3987
+ "readonly": true
3988
+ },
3941
3989
  {
3942
3990
  "kind": "field",
3943
3991
  "name": "compactable",
3944
3992
  "type": {
3945
3993
  "text": "boolean"
3946
3994
  },
3947
- "description": "Whether the menu's \"Compact\" action would do anything (GENC-1351 §5.7).\nDelegates to `driver.canCompact()` so the gate uses the exact same history\n`compact()` acts on one source of truth. Also reads `messages`, purely to\nstay reactive as the conversation grows: the driver's history isn't a tracked\nobservable, so the button keys its re-evaluation off the Redux mirror.",
3995
+ "description": "Whether the menu's \"Compact\" action would do anything (GENC-1351 §5.7,\nGENC-1567). Delegates to the driver's plan so the gate uses the exact same\nhistory `compact()` acts on, judged against the same budget one source of\ntruth for both whether a cut is legal and whether it reclaims enough.",
3948
3996
  "readonly": true
3949
3997
  },
3950
3998
  {
@@ -5421,7 +5469,7 @@
5421
5469
  "type": {
5422
5470
  "text": "readonly string[]"
5423
5471
  },
5424
- "default": "[\n 'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',\n \"`sessionUsage` is what this session spent: `costUsd` (USD, provider-reported per request and summed, cache discounts already applied) plus the four TOKEN BUCKETS — `uncachedInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Those four are disjoint and safe to add up for a total-tokens figure; the per-message fields further down are NOT (see kind:'message'). Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a big token count against a small cost means the prompt was mostly cache hits, not a missing charge. It covers sub-agent turns and spend a compaction banked, so for the CONVERSATION it is the authoritative total: prefer it over re-summing the timeline, which can be short by whatever the ring buffers evicted. The same figures appear under `meta.context`. It is as current as the snapshot it came from: the assistant's own download stamps a fresh one, but a log stitched by another tool can carry an older one — compare `meta.timestamp` with the last timeline entry, and if it is well behind, read these totals as historical and fall back to summing the timeline. One scope caveat: it is derived from the transcript, so it excludes billed calls that produced no message (a blank or refused attempt that was retried — see kind:'turn'.`usage`). Summing the turns can therefore come out HIGHER than this, and the difference is exactly that discarded spend, not a double-count.\",\n \"What 'session' means for those figures: the CURRENT conversation under this session key (a host-supplied per-project id, or the element id + header title when the host supplies none) — which is not necessarily everything in `timeline`. It is re-derived from the live transcript, so it spans page loads only when the chat is being remembered, and a Clear resets it to zero. `timeline` is append-only regardless: it keeps every page load and the pre-Clear conversation, with the session.cleared event as the boundary. So on a long-lived log, read these totals as the latest conversation's and the earlier sections as history — not as a total of the whole file.\",\n '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',\n 'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',\n \"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, where `inputTokens` is the WHOLE prompt for that request and `cacheReadTokens`/`cacheWriteTokens` BREAK IT DOWN rather than add to it — uncached input is `inputTokens` minus those two, and adding the cache fields to `inputTokens` counts the prompt twice. The cache fields are absent on providers that report no cache split (Gemini reports reads only, since implicit caching bills no write) and on messages persisted before they existed, so read them as 0 when missing. Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a large token count at a small cost means the prompt was mostly cache hits. `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.\",\n \"Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\\\"<parent> › <sub-agent>\\\"` breadcrumb (composing when nested, e.g. `\\\"UI Builder › Planner › Grounding\\\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.\",\n \"kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.\",\n \"kind:'turn'.`model`/`providerName`/`provider` — which model ran that call: the concrete model id, the registry slot it resolved under (a tier name like 'high'/'low', or the default), and the vendor. Recorded per CALL, so an agent whose `provider` selector varies by state — a flow that plans on a high tier and executes on a low one — has every step attributed, including calls that produced no message. `model` is the SERVING model where the provider reports one, so a turn answered by a server-side fallback names the model that answered rather than the one requested; it always matches the `model` on the message that call produced. Absent when the provider exposes no `getStatus` and the transport stamped nothing.\",\n \"kind:'turn'.`usage` — what that ONE call cost, in the same four-bucket + `costUsd` shape as `sessionUsage`. Absent while a call is in flight and on providers that report no usage. This is the SAME money as the message the call produced, not extra money: never add turn usage to message usage, and read a turn plus its message as one charge. Its distinct value is the calls that produced NO message — a blank or refused response is billed and then discarded before the retry, so the turn entry is the only record of that spend, and a turn with `usage` but no message after it is exactly that. `costUsd: 0` alongside a nonzero token count means the provider reported no price, not that the call was free.\",\n \"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.\",\n \"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.\",\n \"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.\",\n 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused; budget_exhausted and provider_refused are both terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',\n 'Sub-agent meta events: a sub-agent\\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `\"<parent> › <sub-agent>\"` breadcrumb that composes when nested (e.g. `\"UI Builder › Planner › Grounding\"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\\'s span.',\n \"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.\",\n 'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',\n 'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',\n]",
5472
+ "default": "[\n 'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',\n \"`sessionUsage` is what this session spent: `costUsd` (USD, provider-reported per request and summed, cache discounts already applied) plus the four TOKEN BUCKETS — `uncachedInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Those four are disjoint and safe to add up for a total-tokens figure; the per-message fields further down are NOT (see kind:'message'). Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a big token count against a small cost means the prompt was mostly cache hits, not a missing charge. It covers sub-agent turns and spend a compaction banked, so for the CONVERSATION it is the authoritative total: prefer it over re-summing the timeline, which can be short by whatever the ring buffers evicted. The same figures appear under `meta.context`. It is as current as the snapshot it came from: the assistant's own download stamps a fresh one, but a log stitched by another tool can carry an older one — compare `meta.timestamp` with the last timeline entry, and if it is well behind, read these totals as historical and fall back to summing the timeline. One scope caveat: it is derived from the transcript, so it excludes billed calls that produced no message (a blank or refused attempt that was retried — see kind:'turn'.`usage`). Summing the turns can therefore come out HIGHER than this, and the difference is exactly that discarded spend, not a double-count.\",\n \"What 'session' means for those figures: the CURRENT conversation under this session key (a host-supplied per-project id, or the element id + header title when the host supplies none) — which is not necessarily everything in `timeline`. It is re-derived from the live transcript, so it spans page loads only when the chat is being remembered, and a Clear resets it to zero. `timeline` is append-only regardless: it keeps every page load and the pre-Clear conversation, with the session.cleared event as the boundary. So on a long-lived log, read these totals as the latest conversation's and the earlier sections as history — not as a total of the whole file.\",\n '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',\n 'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',\n \"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, where `inputTokens` is the WHOLE prompt for that request and `cacheReadTokens`/`cacheWriteTokens` BREAK IT DOWN rather than add to it — uncached input is `inputTokens` minus those two, and adding the cache fields to `inputTokens` counts the prompt twice. The cache fields are absent on providers that report no cache split (Gemini reports reads only, since implicit caching bills no write) and on messages persisted before they existed, so read them as 0 when missing. Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a large token count at a small cost means the prompt was mostly cache hits. `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.\",\n \"Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\\\"<parent> › <sub-agent>\\\"` breadcrumb (composing when nested, e.g. `\\\"UI Builder › Planner › Grounding\\\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.\",\n \"kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.\",\n \"kind:'turn'.`model`/`providerName`/`provider` — which model ran that call: the concrete model id, the registry slot it resolved under (a tier name like 'high'/'low', or the default), and the vendor. Recorded per CALL, so an agent whose `provider` selector varies by state — a flow that plans on a high tier and executes on a low one — has every step attributed, including calls that produced no message. `model` is the SERVING model where the provider reports one, so a turn answered by a server-side fallback names the model that answered rather than the one requested; it always matches the `model` on the message that call produced. Absent when the provider exposes no `getStatus` and the transport stamped nothing.\",\n \"kind:'turn'.`usage` — what that ONE call cost, in the same four-bucket + `costUsd` shape as `sessionUsage`. Absent while a call is in flight and on providers that report no usage. This is the SAME money as the message the call produced, not extra money: never add turn usage to message usage, and read a turn plus its message as one charge. Its distinct value is the calls that produced NO message — a blank or refused response is billed and then discarded before the retry, so the turn entry is the only record of that spend, and a turn with `usage` but no message after it is exactly that. `costUsd: 0` alongside a nonzero token count means the provider reported no price, not that the call was free.\",\n \"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.\",\n \"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.\",\n \"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.\",\n 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused/context-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, for context-exhausted the estimated contextTokens against the guard that stopped the turn plus the iterations reached — the driver ending a runaway tool loop at the edge of the context window rather than issuing a request the provider would reject, so the transcript survives intact and can still be compacted, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused/context_exhausted; budget_exhausted, provider_refused and context_exhausted are all terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',\n 'Sub-agent meta events: a sub-agent\\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `\"<parent> › <sub-agent>\"` breadcrumb that composes when nested (e.g. `\"UI Builder › Planner › Grounding\"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\\'s span.',\n \"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.\",\n 'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',\n 'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',\n]",
5425
5473
  "description": "Human/agent-facing guide emitted as the first key of the exported debug log,\nso whoever opens the JSON (often an AI agent) knows how to read it without\nreverse-engineering the shape. Kept here next to the event catalogue it\ndescribes — but proximity alone demonstrably did NOT keep the two in sync\n(`refusal` was missing from both reason lists for a whole release), so\n`debug-event-log.test.ts` now asserts that every `TurnFailureReason` and every\n`SubAgentFailureReason` is named here, and fails the build when one is added\nwithout a matching sentence. Enumerations below read as exhaustive and WILL be\ntrusted by an agent reading the JSON — keep them so."
5426
5474
  }
5427
5475
  ],
@@ -6144,6 +6192,249 @@
6144
6192
  }
6145
6193
  ]
6146
6194
  },
6195
+ {
6196
+ "kind": "javascript-module",
6197
+ "path": "src/utils/context-tokens.ts",
6198
+ "declarations": [
6199
+ {
6200
+ "kind": "variable",
6201
+ "name": "APPROX_CHARS_PER_TOKEN",
6202
+ "type": {
6203
+ "text": "number"
6204
+ },
6205
+ "default": "4",
6206
+ "description": "Fallback ratio for messages with no measured anchor nearby. Shared with\n`condense-history.ts`, which uses it for the same purpose (reporting bytes\nshed as tokens) — one definition so the two never drift."
6207
+ },
6208
+ {
6209
+ "kind": "variable",
6210
+ "name": "COMPACTION_SUMMARY_TOKEN_ESTIMATE",
6211
+ "type": {
6212
+ "text": "number"
6213
+ },
6214
+ "default": "1_200",
6215
+ "description": "Allowance for the summary a compaction will produce, used when projecting the\npost-compaction size before the summarizer has run. The system prompt asks for\nconcise prose, but the length is the model's choice, so this is a ceiling\nrather than an average — a projection that under-estimates the summary is a\ngate that promises a reclaim it cannot deliver."
6216
+ },
6217
+ {
6218
+ "kind": "function",
6219
+ "name": "reachesProvider",
6220
+ "return": {
6221
+ "type": {
6222
+ "text": "boolean"
6223
+ }
6224
+ },
6225
+ "parameters": [
6226
+ {
6227
+ "name": "m",
6228
+ "type": {
6229
+ "text": "ChatMessage"
6230
+ }
6231
+ }
6232
+ ],
6233
+ "description": "Whether a message contributes to the provider request at all.\n\nExported because the cut logic needs the same answer: a boundary is judged by\nwhat the provider will actually see, so a display-only row sitting between an\n`assistant(toolCalls)` and its results must not be mistaken for a safe split.\n\nMirrors what the request-build chokepoint and the transports actually send:\n`system-event` / `synthetic-user` are display-only rows, and the\nreasoning/narration splits (GENC-1410, plus the legacy `thinking` flag they\nsuperseded) are skipped when building the request. Counting any of them would\ninflate every estimate against a prompt that never carries them."
6234
+ },
6235
+ {
6236
+ "kind": "function",
6237
+ "name": "isContextMeasurable",
6238
+ "return": {
6239
+ "type": {
6240
+ "text": "boolean"
6241
+ }
6242
+ },
6243
+ "parameters": [
6244
+ {
6245
+ "name": "history",
6246
+ "type": {
6247
+ "text": "readonly ChatMessage[]"
6248
+ }
6249
+ }
6250
+ ],
6251
+ "description": "Whether anything about this transcript's size is knowable.\n\nThe gate's \"should I have an opinion at all?\" question. Deliberately broader\nthan \"is there a USABLE anchor\": a compaction invalidates every retained\nmeasurement, which is the normal state immediately after one runs, and gating\nthe recompute on a usable anchor there meant no assignment at all — leaving a\nblocked figure latched on a conversation that had just shrunk. That is the very\ndead end this machinery exists to prevent, so the question has to be whether the\ntranscript was EVER measured, not whether the measurement is still good.\n\nWhen it is true, estimateContextTokens is the figure to use — it drops\nthe invalidated anchors itself and falls back to characters, which is still the\ntruth about the current history and, crucially, is not the stale number.\n\nA `compacted-summary` counts on its own: a conversation only reaches one by\nbeing large, and a compaction can summarise away every measured turn.\n\nWhen it is false nothing has ever reported usage, and the honest answer is to\nleave the gate inert rather than let a character estimate invent a percentage."
6252
+ },
6253
+ {
6254
+ "kind": "function",
6255
+ "name": "estimateMessageTokens",
6256
+ "return": {
6257
+ "type": {
6258
+ "text": "number[]"
6259
+ }
6260
+ },
6261
+ "parameters": [
6262
+ {
6263
+ "name": "history",
6264
+ "type": {
6265
+ "text": "readonly ChatMessage[]"
6266
+ }
6267
+ }
6268
+ ],
6269
+ "description": "Per-message token estimates, calibrated against measured prompt sizes wherever\nthe transcript provides them.\n\n**How the calibration works.** `inputTokens` on the message produced by a call\nis the size of that call's WHOLE prompt (the cache buckets break it down\nrather than add to it), so it covers `history[0 … i-1]`. Two consecutive\nanchors therefore bracket a segment whose exact measured cost is their\ndifference: `inputTokens[b] − inputTokens[a]` is what `history[a … b-1]` cost.\nWithin a segment the measured total is redistributed across its messages in\nproportion to their baseline estimate — segment granularity is all that is\nneeded, since compaction only ever cuts on boundaries.\n\nA non-positive delta means history SHRANK between the two calls (a compaction\nor a `condenseWhen` collapse landed in between); there is nothing to\ndistribute, so those messages keep their baseline.\n\nThe region before the first anchor keeps its baseline too: that anchor's\nmeasurement also contains the system prompt and tool definitions, which are\nnot attributable to any message — see estimateSystemOverhead."
6270
+ },
6271
+ {
6272
+ "kind": "function",
6273
+ "name": "estimateSystemOverhead",
6274
+ "return": {
6275
+ "type": {
6276
+ "text": "number"
6277
+ }
6278
+ },
6279
+ "parameters": [
6280
+ {
6281
+ "name": "history",
6282
+ "type": {
6283
+ "text": "readonly ChatMessage[]"
6284
+ }
6285
+ }
6286
+ ],
6287
+ "description": "Context consumed by everything that is NOT a message — the system prompt and\nthe tool definitions.\n\nDerived rather than guessed: the first measured prompt covers the messages\nbefore it plus that fixed preamble, so subtracting our estimate of those\nmessages leaves the preamble. Floored at zero, since an over-estimate of the\nearly messages would otherwise produce a negative overhead.\n\nIt matters for the gate because it is real context the user never sees in the\ntranscript, it does not shrink when history does, and on an agent with a large\ntool surface it is far from negligible."
6288
+ },
6289
+ {
6290
+ "kind": "function",
6291
+ "name": "estimateContextTokens",
6292
+ "return": {
6293
+ "type": {
6294
+ "text": "number"
6295
+ }
6296
+ },
6297
+ "parameters": [
6298
+ {
6299
+ "name": "history",
6300
+ "type": {
6301
+ "text": "readonly ChatMessage[]"
6302
+ }
6303
+ }
6304
+ ],
6305
+ "description": "Total estimated context for a transcript — the preamble plus every message.\n\nOn a transcript with measured anchors this lands close to the last observed\n`inputTokens` by construction; the value of computing it rather than reading\nthat figure is that it stays defined for a hypothetical history (the tail a\ncompaction would leave behind), which is what projectCompaction needs."
6306
+ },
6307
+ {
6308
+ "kind": "function",
6309
+ "name": "estimateRequestTokens",
6310
+ "return": {
6311
+ "type": {
6312
+ "text": "number"
6313
+ }
6314
+ },
6315
+ "parameters": [
6316
+ {
6317
+ "name": "storedHistory",
6318
+ "type": {
6319
+ "text": "readonly ChatMessage[]"
6320
+ }
6321
+ },
6322
+ {
6323
+ "name": "requestHistory",
6324
+ "type": {
6325
+ "text": "readonly ChatMessage[]"
6326
+ }
6327
+ }
6328
+ ],
6329
+ "description": "Estimated size of the request that will actually be SENT, given the stored\nhistory it was derived from.\n\nThe two differ by design and can differ by a lot. `condenseWhen` replaces spent\ntool payloads with short stubs, and multi-agent masking blanks another agent's\npayloads — both on a copy, leaving stored history full-fat for the UI and the\nlog. A guard that measured stored history would therefore refuse turns whose\nreal request is a fraction of the size.\n\nNaively estimating the transformed copy does NOT work either, and the reason is\nsubtle: `inputTokens` anchors live on the stored messages and measured the\nuntransformed prompt, so calibrating against them re-inflates the estimate back\nto roughly what it was before the transform — exactly the figure we were trying\nto avoid.\n\nSo the calibrated total is scaled by how much smaller the request is in\ncharacters. That keeps the accuracy the measurements buy while still reflecting\nwhat the transform removed. The preamble is carried across unscaled: the system\nprompt and tool definitions are not what condensation touches."
6330
+ },
6331
+ {
6332
+ "kind": "function",
6333
+ "name": "projectCompaction",
6334
+ "return": {
6335
+ "type": {
6336
+ "text": "CompactionProjection"
6337
+ }
6338
+ },
6339
+ "parameters": [
6340
+ {
6341
+ "name": "history",
6342
+ "type": {
6343
+ "text": "readonly ChatMessage[]"
6344
+ }
6345
+ },
6346
+ {
6347
+ "name": "cut",
6348
+ "type": {
6349
+ "text": "number"
6350
+ }
6351
+ },
6352
+ {
6353
+ "name": "summaryTokens",
6354
+ "default": "COMPACTION_SUMMARY_TOKEN_ESTIMATE",
6355
+ "type": {
6356
+ "text": "number"
6357
+ }
6358
+ }
6359
+ ],
6360
+ "description": "Project the effect of compacting at `cut`, WITHOUT running the summarizer.\n\nThis is what lets a gate tell the difference between \"compaction will unblock\nyou\" and \"compaction will spend a call and leave you exactly where you are\" —\na distinction the old boolean `canCompact()` could not express, because it\nanswered only whether a legal cut existed. A legal cut that covers four short\nmessages while a large tool loop sits in the tail is legal and useless.\n\nThe system preamble is carried across unchanged: compaction removes messages,\nnever the system prompt or the tool definitions."
6361
+ }
6362
+ ],
6363
+ "exports": [
6364
+ {
6365
+ "kind": "js",
6366
+ "name": "APPROX_CHARS_PER_TOKEN",
6367
+ "declaration": {
6368
+ "name": "APPROX_CHARS_PER_TOKEN",
6369
+ "module": "src/utils/context-tokens.ts"
6370
+ }
6371
+ },
6372
+ {
6373
+ "kind": "js",
6374
+ "name": "COMPACTION_SUMMARY_TOKEN_ESTIMATE",
6375
+ "declaration": {
6376
+ "name": "COMPACTION_SUMMARY_TOKEN_ESTIMATE",
6377
+ "module": "src/utils/context-tokens.ts"
6378
+ }
6379
+ },
6380
+ {
6381
+ "kind": "js",
6382
+ "name": "reachesProvider",
6383
+ "declaration": {
6384
+ "name": "reachesProvider",
6385
+ "module": "src/utils/context-tokens.ts"
6386
+ }
6387
+ },
6388
+ {
6389
+ "kind": "js",
6390
+ "name": "isContextMeasurable",
6391
+ "declaration": {
6392
+ "name": "isContextMeasurable",
6393
+ "module": "src/utils/context-tokens.ts"
6394
+ }
6395
+ },
6396
+ {
6397
+ "kind": "js",
6398
+ "name": "estimateMessageTokens",
6399
+ "declaration": {
6400
+ "name": "estimateMessageTokens",
6401
+ "module": "src/utils/context-tokens.ts"
6402
+ }
6403
+ },
6404
+ {
6405
+ "kind": "js",
6406
+ "name": "estimateSystemOverhead",
6407
+ "declaration": {
6408
+ "name": "estimateSystemOverhead",
6409
+ "module": "src/utils/context-tokens.ts"
6410
+ }
6411
+ },
6412
+ {
6413
+ "kind": "js",
6414
+ "name": "estimateContextTokens",
6415
+ "declaration": {
6416
+ "name": "estimateContextTokens",
6417
+ "module": "src/utils/context-tokens.ts"
6418
+ }
6419
+ },
6420
+ {
6421
+ "kind": "js",
6422
+ "name": "estimateRequestTokens",
6423
+ "declaration": {
6424
+ "name": "estimateRequestTokens",
6425
+ "module": "src/utils/context-tokens.ts"
6426
+ }
6427
+ },
6428
+ {
6429
+ "kind": "js",
6430
+ "name": "projectCompaction",
6431
+ "declaration": {
6432
+ "name": "projectCompaction",
6433
+ "module": "src/utils/context-tokens.ts"
6434
+ }
6435
+ }
6436
+ ]
6437
+ },
6147
6438
  {
6148
6439
  "kind": "javascript-module",
6149
6440
  "path": "src/utils/cost-session-history.ts",
@@ -6670,21 +6961,45 @@
6670
6961
  },
6671
6962
  {
6672
6963
  "kind": "variable",
6673
- "name": "COMPACT_KEEP_RECENT_MESSAGES",
6964
+ "name": "DEFAULT_TAIL_TOKEN_BUDGET",
6674
6965
  "type": {
6675
6966
  "text": "number"
6676
6967
  },
6677
- "default": "4",
6678
- "description": "Recent messages kept verbatim when compacting; everything earlier is summarized."
6968
+ "default": "30_000",
6969
+ "description": "Default size of the verbatim tail a compaction leaves behind, in tokens —\nMESSAGES only, since the system preamble is not something compaction can\nremove. A caller with a resolved context limit should pass a budget derived\nfrom it rather than relying on this."
6679
6970
  },
6680
6971
  {
6681
6972
  "kind": "variable",
6682
- "name": "COMPACT_MIN_MESSAGES_TO_COMPACT",
6973
+ "name": "DEFAULT_MIN_RECLAIM_TOKENS",
6683
6974
  "type": {
6684
6975
  "text": "number"
6685
6976
  },
6686
- "default": "4",
6687
- "description": "Don't bother compacting unless at least this many messages would be summarized."
6977
+ "default": "5_000",
6978
+ "description": "Default floor on what a compaction must reclaim to be worth running. Below\nthis the summarizer call costs more than the space it buys — and, crucially,\na gate that offered compaction as its escape would be promising a reclaim\nthat does not clear the gate."
6979
+ },
6980
+ {
6981
+ "kind": "function",
6982
+ "name": "isSafeCompactionCut",
6983
+ "return": {
6984
+ "type": {
6985
+ "text": "boolean"
6986
+ }
6987
+ },
6988
+ "parameters": [
6989
+ {
6990
+ "name": "history",
6991
+ "type": {
6992
+ "text": "readonly ChatMessage[]"
6993
+ }
6994
+ },
6995
+ {
6996
+ "name": "i",
6997
+ "type": {
6998
+ "text": "number"
6999
+ }
7000
+ }
7001
+ ],
7002
+ "description": "Whether cutting at `i` — summarizing `[0, i)` and keeping `[i, end)` verbatim\n— leaves a request the provider will accept.\n\nThe one hard constraint is tool pairing: a `tool_result` whose `tool_use` was\nsummarized away is rejected outright by every provider. So the first message\nthe tail actually SENDS must not be a tool result.\n\nThis replaces the old \"cut only on a `user` message\" rule (GENC-1351), which\nwas a proxy for the same invariant — a user message never appears between a\ntool call and its results, so it was guaranteed safe without any pairing\ncheck. The proxy was sound but far too narrow: an agentic tool loop contains\nno user messages at all, so the whole loop was one indivisible block and a\nrunaway turn could not be compacted at any point. Checking the invariant\ndirectly opens up every gap between tool groups (GENC-1567).\n\nDisplay-only rows are skipped when looking for that first sent message: a\n`system-event` (e.g. \"Stopped.\") sitting between an `assistant(toolCalls)` and\nits results reaches no transport, so cutting in front of it would still orphan\nthe results behind it."
6688
7003
  },
6689
7004
  {
6690
7005
  "kind": "function",
@@ -6700,9 +7015,41 @@
6700
7015
  "type": {
6701
7016
  "text": "readonly ChatMessage[]"
6702
7017
  }
7018
+ },
7019
+ {
7020
+ "name": "tailTokenBudget",
7021
+ "default": "DEFAULT_TAIL_TOKEN_BUDGET",
7022
+ "type": {
7023
+ "text": "number"
7024
+ }
7025
+ }
7026
+ ],
7027
+ "description": "Index at which the verbatim tail begins — everything before it gets summarized\n— chosen so the tail fits `tailTokenBudget` (GENC-1567).\n\nWalks back from the end accumulating estimated tokens until the next message\nwould overrun the budget, then snaps FORWARD to the nearest safe boundary.\nForward is the correct direction: it can only shrink the tail, so the budget\nstill holds after the snap, whereas snapping backwards could blow it.\n\nReturns `null` when the whole transcript already fits (nothing worth doing) or\nwhen no safe boundary exists ahead of the walk — e.g. a tail that is one\nenormous, indivisible tool group, which no cut can shrink because a cut cannot\nsplit a message."
7028
+ },
7029
+ {
7030
+ "kind": "function",
7031
+ "name": "planCompaction",
7032
+ "return": {
7033
+ "type": {
7034
+ "text": "CompactionProjection | null"
7035
+ }
7036
+ },
7037
+ "parameters": [
7038
+ {
7039
+ "name": "history",
7040
+ "type": {
7041
+ "text": "readonly ChatMessage[]"
7042
+ }
7043
+ },
7044
+ {
7045
+ "name": "options",
7046
+ "default": "{}",
7047
+ "type": {
7048
+ "text": "CompactionPlanOptions"
7049
+ }
6703
7050
  }
6704
7051
  ],
6705
- "description": "Index at which the verbatim tail begins everything before it gets summarized.\nThe tail starts on a clean turn boundary (a `user` message): a user message\nnever appears between a `tool_use` and its `tool_result`, so cutting there\nguarantees the summarized region is tool-pair-balanced (compaction only runs\nwhen idle, so history is turn-complete). Returns `null` when there is nothing\nworth compacting (too short, or no earlier user boundary) e.g. a freshly\nrestored session whose only user turn is the current one."
7052
+ "description": "The single answer to \"what would compacting do right now, and is it worth it?\"\n\nEvery consumer reads this one function the menu's enabled state, its\ntooltip, the gate's banner copy, and the action itself — so the affordance can\nnever promise something the action does not deliver. That is the same\none-source-of-truth argument that produced `canCompact()` (the GENC-1351\nfollow-up which stopped the gate reading a different history from the action);\nthis extends it from *legality* to *sufficiency*, which is what a blocking\ngate needs. A legal cut that covers four short messages while a large tool\nloop sits in the tail is legal and useless, and the old boolean could not say\nso.\n\nReturns `null` when there is no safe cut, or when the projected reclaim does\nnot clear `minReclaimTokens`."
6706
7053
  },
6707
7054
  {
6708
7055
  "kind": "function",
@@ -6778,17 +7125,25 @@
6778
7125
  },
6779
7126
  {
6780
7127
  "kind": "js",
6781
- "name": "COMPACT_KEEP_RECENT_MESSAGES",
7128
+ "name": "DEFAULT_TAIL_TOKEN_BUDGET",
7129
+ "declaration": {
7130
+ "name": "DEFAULT_TAIL_TOKEN_BUDGET",
7131
+ "module": "src/utils/history-transform.ts"
7132
+ }
7133
+ },
7134
+ {
7135
+ "kind": "js",
7136
+ "name": "DEFAULT_MIN_RECLAIM_TOKENS",
6782
7137
  "declaration": {
6783
- "name": "COMPACT_KEEP_RECENT_MESSAGES",
7138
+ "name": "DEFAULT_MIN_RECLAIM_TOKENS",
6784
7139
  "module": "src/utils/history-transform.ts"
6785
7140
  }
6786
7141
  },
6787
7142
  {
6788
7143
  "kind": "js",
6789
- "name": "COMPACT_MIN_MESSAGES_TO_COMPACT",
7144
+ "name": "isSafeCompactionCut",
6790
7145
  "declaration": {
6791
- "name": "COMPACT_MIN_MESSAGES_TO_COMPACT",
7146
+ "name": "isSafeCompactionCut",
6792
7147
  "module": "src/utils/history-transform.ts"
6793
7148
  }
6794
7149
  },
@@ -6800,6 +7155,14 @@
6800
7155
  "module": "src/utils/history-transform.ts"
6801
7156
  }
6802
7157
  },
7158
+ {
7159
+ "kind": "js",
7160
+ "name": "planCompaction",
7161
+ "declaration": {
7162
+ "name": "planCompaction",
7163
+ "module": "src/utils/history-transform.ts"
7164
+ }
7165
+ },
6803
7166
  {
6804
7167
  "kind": "js",
6805
7168
  "name": "buildCompactionSummaryPrompt",
@@ -6969,6 +7332,99 @@
6969
7332
  }
6970
7333
  ]
6971
7334
  },
7335
+ {
7336
+ "kind": "javascript-module",
7337
+ "path": "src/utils/resolve-context-budget.ts",
7338
+ "declarations": [
7339
+ {
7340
+ "kind": "variable",
7341
+ "name": "DEFAULT_RESERVE_TOKENS",
7342
+ "type": {
7343
+ "text": "number"
7344
+ },
7345
+ "default": "25_000",
7346
+ "description": "Tokens kept free for a single turn by default.\n\nA turn is not atomic in cost: a send allowed just under a threshold can run a\nlong tool loop and add a great deal on its own, so what has to be bounded is\nthe growth of ONE turn — a token count, not a fraction of the window. On a\n200k model this leaves ~12% free; on a 1M model the same absolute room, rather\nthan the 200k a percentage rule would strand.\n\nChosen as a starting position, not a measured figure. Per-turn `inputTokens`\nis recorded for every call, so this should be revisited against real traffic."
7347
+ },
7348
+ {
7349
+ "kind": "variable",
7350
+ "name": "DEFAULT_WARN_MULTIPLIER",
7351
+ "type": {
7352
+ "text": "number"
7353
+ },
7354
+ "default": "2",
7355
+ "description": "Warning appears this many reserves ahead of the block."
7356
+ },
7357
+ {
7358
+ "kind": "variable",
7359
+ "name": "CONTEXT_ABORT_MARGIN_TOKENS",
7360
+ "type": {
7361
+ "text": "number"
7362
+ },
7363
+ "default": "4_096",
7364
+ "description": "How close to the window a running turn is allowed to get before the driver\nstops it (GENC-1567). Roughly one more tool result plus a reply — enough that\nthe next request would very likely be refused."
7365
+ },
7366
+ {
7367
+ "kind": "function",
7368
+ "name": "resolveContextBudget",
7369
+ "return": {
7370
+ "type": {
7371
+ "text": "ResolvedContextBudget"
7372
+ }
7373
+ },
7374
+ "parameters": [
7375
+ {
7376
+ "name": "input",
7377
+ "type": {
7378
+ "text": "ContextBudgetInput"
7379
+ }
7380
+ }
7381
+ ],
7382
+ "description": "Resolve the headroom thresholds for the active model and agent.\n\nEverything downstream — the warning, the block, the compaction planner and the\nbanner copy — reads this one result, so the thresholds a user is judged\nagainst and the budget compaction aims at can never disagree."
7383
+ }
7384
+ ],
7385
+ "exports": [
7386
+ {
7387
+ "kind": "js",
7388
+ "name": "DEFAULT_MIN_RECLAIM_TOKENS",
7389
+ "declaration": {
7390
+ "name": "DEFAULT_MIN_RECLAIM_TOKENS",
7391
+ "module": "src/utils/resolve-context-budget.ts"
7392
+ }
7393
+ },
7394
+ {
7395
+ "kind": "js",
7396
+ "name": "DEFAULT_RESERVE_TOKENS",
7397
+ "declaration": {
7398
+ "name": "DEFAULT_RESERVE_TOKENS",
7399
+ "module": "src/utils/resolve-context-budget.ts"
7400
+ }
7401
+ },
7402
+ {
7403
+ "kind": "js",
7404
+ "name": "DEFAULT_WARN_MULTIPLIER",
7405
+ "declaration": {
7406
+ "name": "DEFAULT_WARN_MULTIPLIER",
7407
+ "module": "src/utils/resolve-context-budget.ts"
7408
+ }
7409
+ },
7410
+ {
7411
+ "kind": "js",
7412
+ "name": "CONTEXT_ABORT_MARGIN_TOKENS",
7413
+ "declaration": {
7414
+ "name": "CONTEXT_ABORT_MARGIN_TOKENS",
7415
+ "module": "src/utils/resolve-context-budget.ts"
7416
+ }
7417
+ },
7418
+ {
7419
+ "kind": "js",
7420
+ "name": "resolveContextBudget",
7421
+ "declaration": {
7422
+ "name": "resolveContextBudget",
7423
+ "module": "src/utils/resolve-context-budget.ts"
7424
+ }
7425
+ }
7426
+ ]
7427
+ },
6972
7428
  {
6973
7429
  "kind": "javascript-module",
6974
7430
  "path": "src/utils/resolve-cost-history-config.ts",
@@ -8153,7 +8609,7 @@
8153
8609
  },
8154
8610
  {
8155
8611
  "kind": "class",
8156
- "description": "Plain TS class that drives a multi-turn chat conversation, including the tool-call loop.\nOwned by `FoundationAiAssistant` — created in `connectedCallback`, torn down in `disconnectedCallback`.\n\nDispatches `'history-updated'` events on itself so the owning element can observe changes.",
8612
+ "description": "",
8157
8613
  "name": "ChatDriver",
8158
8614
  "members": [
8159
8615
  {
@@ -8676,7 +9132,7 @@
8676
9132
  "kind": "field",
8677
9133
  "name": "resolvedStatusCache",
8678
9134
  "privacy": "private",
8679
- "default": "new Map<string, { model?: string; provider?: AIProviderType }>()",
9135
+ "default": "new Map<\n string,\n { model?: string; provider?: AIProviderType; contextLimit?: number }\n >()",
8680
9136
  "description": "Memoised `name → getStatus()` lookups, so per-turn resolution doesn't\nre-await `getStatus()` every turn. Cleared (with `resolvedProviderCache`) on\nan observable-registry change, so a model swapped behind a stable name is\npicked up on the next turn."
8681
9137
  },
8682
9138
  {
@@ -9091,7 +9547,7 @@
9091
9547
  "privacy": "private",
9092
9548
  "return": {
9093
9549
  "type": {
9094
- "text": "Promise<{ model?: string; provider?: AIProviderType }>"
9550
+ "text": "Promise<{ model?: string; provider?: AIProviderType; contextLimit?: number }>"
9095
9551
  }
9096
9552
  },
9097
9553
  "parameters": [
@@ -9284,6 +9740,69 @@
9284
9740
  ],
9285
9741
  "description": "Push one snapshot to the ring buffer. Called inside `runToolLoop` just\nbefore each LLM call — that's the latest point where the prompt, tool\nsurface, and agent state line up with what the model is about to see.\n\nReturns the pushed object so the caller can back-fill what only the response\nknows (`usage`). Mutating it after the fact is safe whether or not the ring\nbuffer has since evicted it — an evicted snapshot is simply no longer exported."
9286
9742
  },
9743
+ {
9744
+ "kind": "field",
9745
+ "name": "contextGuard",
9746
+ "type": {
9747
+ "text": "ContextGuardPolicy | undefined"
9748
+ },
9749
+ "privacy": "private",
9750
+ "description": "Mid-loop context guard policy (GENC-1567). `undefined` disables the guard.\n\nCarries a MARGIN rather than an absolute threshold, and the difference is the\nwhole point: the threshold depends on the window of whichever provider the\nnext call resolves to, which the driver learns during the call and the host\ncannot know until afterwards. The host owns the policy; the driver applies it\nagainst the model actually being addressed."
9751
+ },
9752
+ {
9753
+ "kind": "field",
9754
+ "name": "lastResolvedContextLimit",
9755
+ "type": {
9756
+ "text": "number | undefined"
9757
+ },
9758
+ "privacy": "private",
9759
+ "description": "Window of the provider the current call resolved to, if it reports one."
9760
+ },
9761
+ {
9762
+ "kind": "method",
9763
+ "name": "setContextGuard",
9764
+ "return": {
9765
+ "type": {
9766
+ "text": "void"
9767
+ }
9768
+ },
9769
+ "parameters": [
9770
+ {
9771
+ "name": "policy",
9772
+ "optional": true,
9773
+ "type": {
9774
+ "text": "ContextGuardPolicy"
9775
+ }
9776
+ }
9777
+ ],
9778
+ "description": "Set the mid-loop context guard. Its margin is deliberately far smaller than\nthe reserve that blocks NEW turns: that reserve exists so an accepted turn can\nspend it, and a guard set at the same line would kill every turn that used\nthe headroom it was given."
9779
+ },
9780
+ {
9781
+ "kind": "method",
9782
+ "name": "contextExhausted",
9783
+ "privacy": "private",
9784
+ "return": {
9785
+ "type": {
9786
+ "text": "{ estimated: number; threshold: number } | undefined"
9787
+ }
9788
+ },
9789
+ "parameters": [
9790
+ {
9791
+ "name": "requestHistory",
9792
+ "type": {
9793
+ "text": "readonly ChatMessage[]"
9794
+ }
9795
+ },
9796
+ {
9797
+ "name": "pendingInput",
9798
+ "optional": true,
9799
+ "type": {
9800
+ "text": "{ content: string; attachments?: ChatAttachment[] }"
9801
+ }
9802
+ }
9803
+ ],
9804
+ "description": "Whether issuing `requestHistory` would run the context window out.\n\nInert unless a margin has been set AND the resolved provider reports a\nwindow — with no window there is nothing to measure against, and inventing\none to refuse a request is worse than letting the provider answer.\n\nStopping here is strictly better than letting the request go: the provider\nwould reject an oversized prompt outright, leaving a transcript still too\nlarge to retry and no explanation the user can act on, whereas ending the\nturn keeps history intact so compaction is still available and the work so\nfar is not lost."
9805
+ },
9287
9806
  {
9288
9807
  "kind": "method",
9289
9808
  "name": "setProviderHistoryTransform",
@@ -9300,8 +9819,7 @@
9300
9819
  "text": "(history: ChatMessage[]) => ChatMessage[]"
9301
9820
  }
9302
9821
  }
9303
- ],
9304
- "description": "Optional transform applied to conversation history immediately before each LLM request.\nCleared when `undefined`. Does not alter stored history."
9822
+ ]
9305
9823
  },
9306
9824
  {
9307
9825
  "kind": "method",
@@ -9375,8 +9893,36 @@
9375
9893
  "text": "boolean"
9376
9894
  }
9377
9895
  },
9896
+ "parameters": [
9897
+ {
9898
+ "name": "options",
9899
+ "optional": true,
9900
+ "type": {
9901
+ "text": "CompactionPlanOptions"
9902
+ }
9903
+ }
9904
+ ],
9378
9905
  "description": "Whether `compact()` would do anything right now — a summarizable run of older\nturns exists behind a clean boundary. Uses the same `history` `compact()`\nacts on, so the UI's gate can't disagree with the action (GENC-1351 follow-up)."
9379
9906
  },
9907
+ {
9908
+ "kind": "method",
9909
+ "name": "getCompactionPlan",
9910
+ "return": {
9911
+ "type": {
9912
+ "text": "CompactionProjection | null"
9913
+ }
9914
+ },
9915
+ "parameters": [
9916
+ {
9917
+ "name": "options",
9918
+ "optional": true,
9919
+ "type": {
9920
+ "text": "CompactionPlanOptions"
9921
+ }
9922
+ }
9923
+ ],
9924
+ "description": "{@inheritDoc AiDriver.getCompactionPlan}\n\nRuns against the driver's own `history` — the exact list `compact()` acts on\n— so a projection and the compaction it describes can never be computed from\ndifferent transcripts (the GENC-1351 follow-up that first made `canCompact`\nread `history` rather than a mirrored copy)."
9925
+ },
9380
9926
  {
9381
9927
  "kind": "method",
9382
9928
  "name": "compact",
@@ -9385,6 +9931,15 @@
9385
9931
  "text": "Promise<ChatMessage | null>"
9386
9932
  }
9387
9933
  },
9934
+ "parameters": [
9935
+ {
9936
+ "name": "options",
9937
+ "optional": true,
9938
+ "type": {
9939
+ "text": "CompactionPlanOptions"
9940
+ }
9941
+ }
9942
+ ],
9388
9943
  "description": "Destructively compact older turns into a single `compacted-summary` message\n(GENC-1351 §5.7). Summarizes everything before a clean recent-tail boundary\nvia the registry-default provider's one-shot `prompt()`, then **atomically**\nreplaces the covered turns with one summary message at the head — history is\nmutated only on success. Rolling: any existing head summary is folded into\nthe new one. Returns the created summary message, or `null` when there is\nnothing worth compacting or the default provider cannot summarize."
9389
9944
  },
9390
9945
  {
@@ -9893,8 +10448,7 @@
9893
10448
  "name": "history-updated",
9894
10449
  "type": {
9895
10450
  "text": "CustomEvent"
9896
- },
9897
- "description": "Fired whenever the in-memory chat history changes (append, tool loop, interaction resolution, post-resolve external cost) with the full history snapshot. detail: `ReadonlyArray<ChatMessage>`"
10451
+ }
9898
10452
  },
9899
10453
  {
9900
10454
  "name": "interaction-stop",
@@ -10515,6 +11069,25 @@
10515
11069
  }
10516
11070
  }
10517
11071
  },
11072
+ {
11073
+ "kind": "method",
11074
+ "name": "setContextGuard",
11075
+ "return": {
11076
+ "type": {
11077
+ "text": "void"
11078
+ }
11079
+ },
11080
+ "parameters": [
11081
+ {
11082
+ "name": "policy",
11083
+ "optional": true,
11084
+ "type": {
11085
+ "text": "ContextGuardPolicy"
11086
+ }
11087
+ }
11088
+ ],
11089
+ "description": "{@inheritDoc AiDriver.setContextGuard}"
11090
+ },
10518
11091
  {
10519
11092
  "kind": "method",
10520
11093
  "name": "compact",
@@ -10523,6 +11096,15 @@
10523
11096
  "text": "Promise<ChatMessage | null>"
10524
11097
  }
10525
11098
  },
11099
+ "parameters": [
11100
+ {
11101
+ "name": "options",
11102
+ "optional": true,
11103
+ "type": {
11104
+ "text": "CompactionPlanOptions"
11105
+ }
11106
+ }
11107
+ ],
10526
11108
  "description": "{@inheritDoc AiDriver.compact}"
10527
11109
  },
10528
11110
  {
@@ -10533,8 +11115,36 @@
10533
11115
  "text": "boolean"
10534
11116
  }
10535
11117
  },
11118
+ "parameters": [
11119
+ {
11120
+ "name": "options",
11121
+ "optional": true,
11122
+ "type": {
11123
+ "text": "CompactionPlanOptions"
11124
+ }
11125
+ }
11126
+ ],
10536
11127
  "description": "{@inheritDoc AiDriver.canCompact}"
10537
11128
  },
11129
+ {
11130
+ "kind": "method",
11131
+ "name": "getCompactionPlan",
11132
+ "return": {
11133
+ "type": {
11134
+ "text": "CompactionProjection | null"
11135
+ }
11136
+ },
11137
+ "parameters": [
11138
+ {
11139
+ "name": "options",
11140
+ "optional": true,
11141
+ "type": {
11142
+ "text": "CompactionPlanOptions"
11143
+ }
11144
+ }
11145
+ ],
11146
+ "description": "{@inheritDoc AiDriver.getCompactionPlan}"
11147
+ },
10538
11148
  {
10539
11149
  "kind": "method",
10540
11150
  "name": "getTurnSnapshots",