@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
@@ -592,6 +592,72 @@ const baseStyles = css `
592
592
  opacity: 45%;
593
593
  }
594
594
 
595
+ /* Context-gate states on the session-menu pill (GENC-1567).
596
+
597
+ ONE accent across both levels rather than amber escalating to red. The
598
+ escalation is carried by things that survive without colour vision and read
599
+ on a screen reader: the label changes wording ("Approaching" → "At"), the
600
+ composer gains an outline, and the composer disables with the remedy in its
601
+ placeholder. A colour change would have been the one signal adding nothing
602
+ the others do not already say, while making the composer shout in two hues
603
+ at once.
604
+
605
+ Colour is never the signal on its own here either — the pill swaps ⋮ for a
606
+ warning triangle and grows a label, and its title and aria-label carry the
607
+ full reason. */
608
+
609
+ /* The pill's own label is the loudest part, so it carries the weight the glyph
610
+ alone would not. max-width is widened from the agent pin's 45%: this label
611
+ is a fixed phrase rather than a user-supplied agent name, and ellipsing
612
+ "Approaching context limit" to "Approaching cont…" would lose the noun that
613
+ makes it mean anything. */
614
+ .session-menu-button.context-warn,
615
+ .session-menu-button.context-blocked {
616
+ max-width: 65%;
617
+ }
618
+
619
+ .session-menu-button.context-warn,
620
+ .session-menu-button.context-warn:hover {
621
+ color: var(--warning-color);
622
+ }
623
+
624
+ .session-menu-button.context-blocked,
625
+ .session-menu-button.context-blocked:hover {
626
+ color: var(--error-color);
627
+ }
628
+
629
+ /* The gate disables this button during a turn like any other composer control.
630
+ Left at the shared 45% it reads as "inactive" at exactly the moment the
631
+ conversation is running away, so it keeps more of its presence — still
632
+ clearly not clickable, but not faded out of notice. */
633
+ .session-menu-button.context-warn[disabled],
634
+ .session-menu-button.context-blocked[disabled] {
635
+ opacity: 70%;
636
+ }
637
+
638
+ /* The composer outline (GENC-1567). Drawn from the WARNING onwards, not just at
639
+ the block: the composer is the control the condition is about, and a user who
640
+ only ever sees it appear at the moment sending stops has had no warning where
641
+ they were looking. It escalates amber → red with the state rather than
642
+ appearing from nothing.
643
+
644
+ A border-COLOUR change only: the composer already declares a transparent
645
+ 1px border on all four sides so its box metrics are identical in both the
646
+ flush and floating layouts, which means this cannot reflow anything or shift
647
+ the composer as the state flips. */
648
+ .composer.context-warn,
649
+ .composer.context-blocked {
650
+ border-radius: calc(var(--control-corner-radius) * 1px);
651
+ }
652
+
653
+ .composer.context-warn {
654
+ border-color: var(--warning-color);
655
+ }
656
+
657
+ .composer.context-blocked {
658
+ border-color: var(--error-color);
659
+ }
660
+
595
661
  .composer-pill::part(control) {
596
662
  min-width: 0;
597
663
  padding: 0;
@@ -834,6 +900,26 @@ const baseStyles = css `
834
900
  border-color: var(--neutral-stroke-rest);
835
901
  }
836
902
 
903
+ /* Announced, not drawn (GENC-1567). The context gate has three visible
904
+ expressions already; this keeps the role=status region mounted and
905
+ mutating so the condition is still spoken, which nothing else can do once
906
+ the composer disables and drops out of the tab order.
907
+
908
+ Clipped rather than display:none or visibility:hidden — both of those
909
+ remove the node from the accessibility tree, which would silence the very
910
+ announcement this exists for. */
911
+ .blocked-banner.is-sr-only {
912
+ position: absolute;
913
+ width: 1px;
914
+ height: 1px;
915
+ margin: -1px;
916
+ padding: 0;
917
+ border: 0;
918
+ overflow: hidden;
919
+ clip-path: inset(50%);
920
+ white-space: nowrap;
921
+ }
922
+
837
923
  .blocked-banner-text {
838
924
  font-size: 0.9em;
839
925
  line-height: 1.4;
@@ -868,6 +954,48 @@ const baseStyles = css `
868
954
  box-shadow: none;
869
955
  }
870
956
 
957
+ /* At the context limit the placeholder stops being a hint and becomes the only
958
+ visible statement of what is wrong and how to clear it — so it has to be
959
+ readable. The design system dims a disabled control's placeholder towards
960
+ the point of being skipped over, which is right for "Message assistant..."
961
+ and wrong for a sentence the user has to act on. Restored to the normal
962
+ foreground, and left un-dimmed by the disabled treatment. */
963
+ .composer.context-blocked .chat-input::part(control)::placeholder {
964
+ color: var(--neutral-foreground-rest);
965
+ opacity: 100%;
966
+ }
967
+
968
+ /* And tall enough to actually show it. The placeholder here is two sentences,
969
+ and the input's resting height clipped them — losing the end, which is the
970
+ half that says what to do about it.
971
+
972
+ min-height rather than height, deliberately: it outranks the inline height
973
+ the drag-to-resize handle writes, so a user who had shrunk the composer
974
+ earlier still sees the whole message.
975
+
976
+ Sized from measurement across the width range rather than from one look at
977
+ one panel: the wrap count changes with the container, and a value picked at
978
+ a comfortable width silently clips at a narrow one. Measured need is 76px
979
+ from 400px up to ~560px, and 56px beyond that.
980
+
981
+ **400px is taken as the narrowest supported composer.** Below it the message
982
+ wraps further and this floor stops being enough — it degrades to a clipped
983
+ sentence rather than anything broken, but it is not a width the copy is sized
984
+ for.
985
+
986
+ Past 600px the message fits the composer's natural resting height, so the
987
+ floor is simply removed rather than set to a value it already exceeds. What
988
+ remains is a floor either way: the input still auto-grows past it on input. */
989
+ .composer.context-blocked .chat-input {
990
+ min-height: calc(var(--design-unit) * 20px);
991
+ }
992
+
993
+ @container (width > 600px) {
994
+ .composer.context-blocked .chat-input {
995
+ min-height: 0;
996
+ }
997
+ }
998
+
871
999
  /* Full-width grab bar above the input row; drag up/down to resize the composer.
872
1000
  Sits above the row so it never overlaps the send/stop controls. */
873
1001
  .composer-resize-handle {
@@ -268,8 +268,13 @@ const restoringTemplate = html `
268
268
  */
269
269
  export const blockedBannerClasses = (x) => [
270
270
  'blocked-banner',
271
+ // Only a budget wall is DRAWN here. The context gate states itself through
272
+ // the pill label, the composer outline and the placeholder, so a strip of
273
+ // prose on top would be a fourth statement of one condition — but it still
274
+ // has to be announced, hence `is-sr-only` rather than dropping the region.
271
275
  x.bannerVisible ? 'is-visible' : '',
272
276
  x.bannerVisible && !x.blocked ? 'is-partial' : '',
277
+ x.composerNoticeSrOnly ? 'is-sr-only' : '',
273
278
  ]
274
279
  .filter(Boolean)
275
280
  .join(' ');
@@ -305,7 +310,7 @@ export const BLOCKED_BANNER_ID = 'blocked-banner';
305
310
  *
306
311
  * @internal
307
312
  */
308
- export const composerDescribedBy = (x) => x.bannerVisible ? BLOCKED_BANNER_ID : null;
313
+ export const composerDescribedBy = (x) => x.composerNoticeVisible ? BLOCKED_BANNER_ID : null;
309
314
  /**
310
315
  * Persistent banner shown above the composer while a backend condition blocks
311
316
  * the assistant — today an exhausted AI budget (GENC-1464).
@@ -345,8 +350,8 @@ const blockedBannerTemplate = html `
345
350
  part="blocked-banner"
346
351
  role="status"
347
352
  >
348
- ${when((x) => x.bannerVisible, html `
349
- <span class="blocked-banner-text">${(x) => x.effectiveBlockedReason}</span>
353
+ ${when((x) => x.composerNoticeVisible, html `
354
+ <span class="blocked-banner-text">${(x) => x.composerNoticeText}</span>
350
355
  `)}
351
356
  </div>
352
357
  `;
@@ -560,23 +565,38 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
560
565
  appearance="stealth"
561
566
  title=${(x) => { var _a; return `Attach file (${(_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles})`; }}
562
567
  aria-label="Attach file"
563
- ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
568
+ ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked || x.contextGate === 'blocked'}
564
569
  aria-describedby=${composerDescribedBy}
565
570
  @click=${(x) => x.triggerFileInput()}
566
571
  ><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
567
572
  `;
573
+ // The same control carries the context-gate signal (GENC-1567): ⋮ becomes a
574
+ // warning triangle as the window fills, amber at the warning and red once
575
+ // sends are blocked. Deliberately this button and not a new one — it is
576
+ // already the way to reach Compact, and it stays mounted (disabled, not
577
+ // removed) during a turn, so the colour change is visible through exactly the
578
+ // runaway tool loop that causes the problem.
579
+ //
580
+ // While a turn runs the button is disabled, so the notice copy above must not
581
+ // point at it as if it were clickable — see `contextGateReason`.
568
582
  const sessionMenuButtonTemplate = html `
569
583
  <${buttonTag}
570
- class="composer-pill session-menu-button"
584
+ class="${(x) => classNames('composer-pill', 'session-menu-button',
585
+ // Borrows the agent pin's labelled shape only while the gate is active,
586
+ // so the composer's resting state is unchanged — three equal circular
587
+ // pills — and the label's arrival is itself part of the signal.
588
+ ['composer-pill-labelled', x.contextGate !== 'ok'], ['context-warn', x.contextGate === 'warn'], ['context-blocked', x.contextGate === 'blocked'])}"
571
589
  part="session-menu-button"
572
590
  appearance="stealth"
573
- title="Session options"
574
- aria-label="Session options"
591
+ title="${(x) => (x.contextGate === 'ok' ? 'Session options' : x.contextGateReason)}"
592
+ aria-label="${(x) => x.contextGate === 'ok' ? 'Session options' : `Session options — ${x.contextGateReason}`}"
575
593
  aria-haspopup="menu"
576
594
  aria-expanded=${(x) => (x.sessionMenuOpen ? 'true' : 'false')}
577
595
  ?disabled=${(x) => x.busy || x.compacting || x.restoring}
578
596
  @click=${(x) => x.toggleSessionMenu()}
579
- ><${iconTag} name="ellipsis"></${iconTag}></${buttonTag}>
597
+ >${when((x) => x.contextGate === 'ok', html `<${iconTag} name="ellipsis"></${iconTag}>`)}${when((x) => x.contextGate !== 'ok', html `<span class="pill-glyph"
598
+ ><${iconTag} name="triangle-exclamation"></${iconTag}></span
599
+ ><span class="pill-label">${(x) => x.contextGateLabel}</span>`)}</${buttonTag}>
580
600
  `;
581
601
  // The agent control is LAST in the cluster because it is the only one whose
582
602
  // width changes: pinning an agent swaps `AUTO` for a name, and with it in
@@ -616,7 +636,12 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
616
636
  <${buttonTag}
617
637
  class="send-button"
618
638
  part="send-button"
619
- ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked || (!x.inputValue.trim() && !x.attachments.length)}
639
+ ?disabled=${(x) => x.busy ||
640
+ x.compacting ||
641
+ x.restoring ||
642
+ x.blocked ||
643
+ x.contextGate === 'blocked' ||
644
+ (!x.inputValue.trim() && !x.attachments.length)}
620
645
  aria-describedby=${composerDescribedBy}
621
646
  @click=${(x) => x.handleSendClick()}
622
647
  >Send</${buttonTag}>
@@ -655,29 +680,32 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
655
680
  role="menuitem"
656
681
  type="button"
657
682
  ?disabled=${(x) => !x.compactable}
658
- title=${(x) => x.compactable
659
- ? 'Summarize older messages to free up context'
660
- : 'Not enough conversation yet to be worth compacting'}
683
+ title=${(x) => x.compactHint}
661
684
  @click=${(x) => (x.confirmingCompact = true)}
662
685
  >
663
686
  <span class="menu-item-icon" aria-hidden="true"><${iconTag} name="compress"></${iconTag}></span>
664
687
  <span class="menu-item-body">
665
688
  <span class="menu-item-name">Compact</span>
666
- <span class="menu-item-hint">Summarize older messages to free up context</span>
667
- </span>
668
- </button>
669
- <button
670
- class="menu-item"
671
- role="menuitem"
672
- type="button"
673
- @click=${(x) => (x.confirmingClear = true)}
674
- >
675
- <span class="menu-item-icon" aria-hidden="true"><${iconTag} name="trash"></${iconTag}></span>
676
- <span class="menu-item-body">
677
- <span class="menu-item-name">Clear</span>
678
- <span class="menu-item-hint">Start a new conversation</span>
689
+ <span class="menu-item-hint">${(x) => x.compactHint}</span>
679
690
  </span>
680
691
  </button>
692
+ ${when(
693
+ // Clear is withheld from a menu the context gate forced open — but only
694
+ // while Compact is a real escape. See `clearWithheldByContext`.
695
+ (x) => !x.clearWithheldByContext, html `
696
+ <button
697
+ class="menu-item"
698
+ role="menuitem"
699
+ type="button"
700
+ @click=${(x) => (x.confirmingClear = true)}
701
+ >
702
+ <span class="menu-item-icon" aria-hidden="true"><${iconTag} name="trash"></${iconTag}></span>
703
+ <span class="menu-item-body">
704
+ <span class="menu-item-name">Clear</span>
705
+ <span class="menu-item-hint">Start a new conversation</span>
706
+ </span>
707
+ </button>
708
+ `)}
681
709
  `;
682
710
  // The hint is not decoration. Every other row in this menu has one, and the
683
711
  // panel is anchored by its BOTTOM edge to the button: a hintless Cancel makes
@@ -891,7 +919,10 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
891
919
  @pointercancel=${(x, c) => x.onComposerResizeEnd(c.event)}
892
920
  ></div>
893
921
  `)}
894
- <div class="composer" part="composer">
922
+ <div
923
+ class="${(x) => classNames('composer', ['context-warn', x.contextGate === 'warn'], ['context-blocked', x.contextGate === 'blocked'])}"
924
+ part="composer"
925
+ >
895
926
  <${textareaTag}
896
927
  ${ref('chatInputEl')}
897
928
  class="chat-input"
@@ -899,9 +930,13 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
899
930
  style=${(x) => x.composerHeightStyle}
900
931
  placeholder=${(x) => x.effectivePlaceholder}
901
932
  :value=${(x) => x.inputValue}
902
- ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
903
- aria-disabled=${(x) => (x.blocked ? 'true' : null)}
904
- aria-label=${(x) => (x.blocked ? x.effectiveBlockedReason : null)}
933
+ ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked || x.contextGate === 'blocked'}
934
+ aria-disabled=${(x) => (x.blocked || x.contextGate === 'blocked' ? 'true' : null)}
935
+ aria-label=${(x) => x.blocked
936
+ ? x.effectiveBlockedReason
937
+ : x.contextGate === 'blocked'
938
+ ? x.contextGateReason
939
+ : null}
905
940
  aria-describedby=${composerDescribedBy}
906
941
  @input=${(x, c) => (x.inputValue = c.event.target.value)}
907
942
  @keydown=${(x, c) => {
@@ -219,7 +219,7 @@ export const DEBUG_LOG_README = [
219
219
  "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.",
220
220
  "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'.",
221
221
  "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.",
222
- '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.',
222
+ '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.',
223
223
  '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.',
224
224
  "`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.",
225
225
  '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.',
@@ -1,4 +1,5 @@
1
1
  import { __rest } from "tslib";
2
+ import { APPROX_CHARS_PER_TOKEN } from './context-tokens';
2
3
  /**
3
4
  * Tool-context condensation: collapse stale tool payloads out of the history
4
5
  * sent to the model, while leaving stored history untouched.
@@ -21,11 +22,6 @@ import { __rest } from "tslib";
21
22
  export const CONDENSE_MIN_CHARS = 1000;
22
23
  /** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
23
24
  export const CONDENSED_ARGS_KEY = 'condensed';
24
- /**
25
- * Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
26
- * available in this stack; ~4 chars/token is the usual English approximation.
27
- */
28
- const APPROX_CHARS_PER_TOKEN = 4;
29
25
  /** Short human-readable reason for the stub text, per fired trigger. */
30
26
  function triggerReason(trigger) {
31
27
  switch (trigger.kind) {