@genesislcap/ai-assistant 15.19.6 → 15.20.0-alpha.3

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 (52) hide show
  1. package/dist/ai-assistant.api.json +707 -69
  2. package/dist/ai-assistant.d.ts +443 -25
  3. package/dist/chat-driver.cjs +440 -34
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +440 -34
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +709 -47
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +40 -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 +81 -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 +23 -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/state/persistence/diagnostics-cursors.d.ts +7 -2
  21. package/dist/dts/state/persistence/diagnostics-cursors.d.ts.map +1 -1
  22. package/dist/dts/state/persistence/diagnostics.d.ts +30 -0
  23. package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
  24. package/dist/dts/state/persistence/session-persister.d.ts +18 -0
  25. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  26. package/dist/dts/utils/condense-history.d.ts +35 -0
  27. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  28. package/dist/dts/utils/context-tokens.d.ts +156 -0
  29. package/dist/dts/utils/context-tokens.d.ts.map +1 -0
  30. package/dist/dts/utils/history-transform.d.ts +76 -14
  31. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  32. package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
  33. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
  34. package/dist/esm/components/chat-driver/chat-driver.js +200 -35
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +28 -4
  36. package/dist/esm/main/main.js +400 -24
  37. package/dist/esm/main/main.styles.js +128 -0
  38. package/dist/esm/main/main.template.js +64 -29
  39. package/dist/esm/state/debug-event-log.js +1 -1
  40. package/dist/esm/state/persistence/diagnostics-cursors.js +1 -1
  41. package/dist/esm/state/persistence/diagnostics.js +49 -0
  42. package/dist/esm/state/persistence/session-persister.js +38 -18
  43. package/dist/esm/utils/condense-history.js +125 -26
  44. package/dist/esm/utils/context-tokens.js +339 -0
  45. package/dist/esm/utils/history-transform.js +101 -19
  46. package/dist/esm/utils/resolve-context-budget.js +84 -0
  47. package/package.json +16 -16
  48. package/sandbox/README.md +93 -4
  49. package/sandbox/controls.ts +77 -10
  50. package/sandbox/fixtures.ts +163 -6
  51. package/sandbox/sandbox.css +54 -1
  52. 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.',
@@ -4,7 +4,7 @@ const freshCursors = () => ({
4
4
  emittedMsgKeys: new Set(),
5
5
  emittedTurnKeys: new Set(),
6
6
  lastEventIndex: -1,
7
- lastMetaJson: '',
7
+ lastMetaDigest: '',
8
8
  });
9
9
  /** The shared cursors for `key`, created (empty) on first access. @internal */
10
10
  export function getDiagnosticsCursors(key) {
@@ -7,6 +7,55 @@
7
7
  *
8
8
  * @packageDocumentation
9
9
  */
10
+ /** FNV-1a's 32-bit offset basis — the first lane's seed, and the second lane's multiplier. */
11
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
12
+ /** FNV-1a's 32-bit prime — the first lane's multiplier, and the second lane's seed. */
13
+ const FNV_PRIME = 0x01000193;
14
+ /** Base-36 renders each lane as a short alphanumeric run, so the whole token stays ~20 chars. */
15
+ const DIGEST_RADIX = 36;
16
+ /**
17
+ * Digest a meta-snapshot's config projection down to a comparison token (GENC-1566).
18
+ *
19
+ * The projection itself is unchanged and still correct — it is the near-static config with the
20
+ * volatile fields excluded, which is what stops a throttled flush re-appending the block every
21
+ * time. What changed is that the projection is no longer STORED verbatim.
22
+ *
23
+ * **Why it matters.** `dedupSignature` is only ever compared for equality
24
+ * (`collectDiagnosticsDelta`: `sig === cursors.lastMetaDigest`), so a 155 KB string bought nothing a
25
+ * 20-byte one does not. Measured on one small Create session (GENC-1566): six `meta-snapshot`
26
+ * entries, each carrying a 155,491-byte `meta` **and** a 155,131-char stringified copy of most of
27
+ * it as its own key — 832,012 bytes of signature, **21% of a 3.92 MB diagnostics stream**, to
28
+ * decide six comparisons. Every byte is also re-uploaded per flush, and that session flushed 86
29
+ * times.
30
+ *
31
+ * **FNV-1a, twice, plus the length.** Sync (the caller builds the snapshot synchronously, so
32
+ * `crypto.subtle.digest`'s promise is not available to it), dependency-free, and deterministic
33
+ * across page loads. Two lanes with different offset bases plus the input length make an
34
+ * accidental collision between two real configs vanishingly unlikely — and the consequence of one
35
+ * would be a single *skipped* re-append of a block whose reader keeps only the newest copy anyway
36
+ * (`withFreshMetaSnapshot` discards every stored snapshot on the download path), so this is not a
37
+ * correctness-critical hash and must not be treated as one.
38
+ *
39
+ * Old streams need no migration: the delta's cursor is module state seeded empty on every page
40
+ * load, so a stored full-JSON signature is never compared against a digest. The first flush after
41
+ * an upgrade appends one snapshot, as every first flush does.
42
+ *
43
+ * @internal
44
+ */
45
+ export function configDigest(json) {
46
+ // FNV-1a 32-bit, two lanes. `>>> 0` keeps each step an unsigned 32-bit value, and
47
+ // `Math.imul` is the only multiply that stays exact at 32 bits in JS. The second lane
48
+ // swaps the seed and the multiplier so the two do not move in lockstep — a pair of
49
+ // 32-bit lanes plus the length is what makes an accidental collision negligible.
50
+ let a = FNV_OFFSET_BASIS;
51
+ let b = FNV_PRIME;
52
+ for (let i = 0; i < json.length; i += 1) {
53
+ const c = json.charCodeAt(i);
54
+ a = Math.imul(a ^ c, FNV_PRIME) >>> 0;
55
+ b = Math.imul(b ^ c, FNV_OFFSET_BASIS) >>> 0;
56
+ }
57
+ return [json.length, a, b].map((n) => n.toString(DIGEST_RADIX)).join('-');
58
+ }
10
59
  /**
11
60
  * Stable identity for a message diagnostic entry — `timestamp::role::subAgentOf::category`.
12
61
  * Used by the forward-capture delta (skip already-emitted messages) and by the
@@ -241,6 +241,28 @@ export class SessionPersister {
241
241
  }
242
242
  });
243
243
  }
244
+ /**
245
+ * The transcript to STORE, for both save paths (the resumable autosave and the non-resumable
246
+ * freeze, which slices this same array).
247
+ *
248
+ * `getHistoryForPersistence` in preference to `getRawHistory` (GENC-1566): the same transcript
249
+ * with the tool payloads the model has already stopped seeing replaced by the stubs it was sent.
250
+ * Storing those in full writes bytes on every autosave for content the driver has retired —
251
+ * 7.4% of a measured 790 KB session — and the full payloads survive in the diagnostics stream
252
+ * either way. A driver that does not implement tool-context condensation falls back to raw
253
+ * history, and a session with no driver yet falls back to the store's copy.
254
+ *
255
+ * The projection preserves message COUNT and order, which it must: `_persistedMessages`, the
256
+ * not-saving modal's unsaved count and the freeze boundary are all derived from lengths.
257
+ *
258
+ * Then strip the display-only restore notices (restored / couldn't-restore) so they never
259
+ * persist or accumulate across reload→save→reload cycles.
260
+ */
261
+ messagesToPersist(driver, storeMessages) {
262
+ var _a, _b, _c, _d;
263
+ const raw = (_d = (_b = (_a = driver === null || driver === void 0 ? void 0 : driver.getHistoryForPersistence) === null || _a === void 0 ? void 0 : _a.call(driver)) !== null && _b !== void 0 ? _b : (_c = driver === null || driver === void 0 ? void 0 : driver.getRawHistory) === null || _c === void 0 ? void 0 : _c.call(driver)) !== null && _d !== void 0 ? _d : storeMessages;
264
+ return raw.filter((m) => !isSessionNoticeMarker(m));
265
+ }
244
266
  /**
245
267
  * Capture the current session to durable storage (GENC-1351 §5.1). Reads the
246
268
  * full transcript from the driver and the restorable scalars from the store, and
@@ -248,7 +270,7 @@ export class SessionPersister {
248
270
  */
249
271
  persistSession() {
250
272
  return __awaiter(this, void 0, void 0, function* () {
251
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
273
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
252
274
  const { provider, enabled } = this.deps.getPersistence();
253
275
  if (!enabled || !provider)
254
276
  return;
@@ -258,9 +280,7 @@ export class SessionPersister {
258
280
  if (!ref)
259
281
  return;
260
282
  const s = ref.store.aiAssistant;
261
- // Strip the display-only restore notices (restored / couldn't-restore) so they
262
- // never persist or accumulate across reload→save→reload cycles.
263
- const messages = ((_b = (_a = driver === null || driver === void 0 ? void 0 : driver.getRawHistory) === null || _a === void 0 ? void 0 : _a.call(driver)) !== null && _b !== void 0 ? _b : s.messages).filter((m) => !isSessionNoticeMarker(m));
283
+ const messages = this.messagesToPersist(driver, s.messages);
264
284
  if (!messages.length)
265
285
  return; // nothing worth persisting (e.g. a just-cleared session)
266
286
  // Serialize the live active agent from the DRIVER (the instance whose
@@ -268,7 +288,7 @@ export class SessionPersister {
268
288
  // mints fresh configs (the showcase) would otherwise hand us a never-activated
269
289
  // closure whose `serialize()` returns undefined.
270
290
  const liveSnapshots = {};
271
- const liveActive = (_c = driver === null || driver === void 0 ? void 0 : driver.getActiveAgent) === null || _c === void 0 ? void 0 : _c.call(driver);
291
+ const liveActive = (_a = driver === null || driver === void 0 ? void 0 : driver.getActiveAgent) === null || _a === void 0 ? void 0 : _a.call(driver);
272
292
  if (liveActive === null || liveActive === void 0 ? void 0 : liveActive.serialize) {
273
293
  try {
274
294
  const snap = liveActive.serialize();
@@ -324,7 +344,7 @@ export class SessionPersister {
324
344
  sessionKey: key,
325
345
  host: window.location.host,
326
346
  messages: messages.slice(0, Math.min(boundary, messages.length)),
327
- activeAgentName: (_d = s.activeAgent) === null || _d === void 0 ? void 0 : _d.name,
347
+ activeAgentName: (_b = s.activeAgent) === null || _b === void 0 ? void 0 : _b.name,
328
348
  pinnedAgentName: s.pinnedAgentName,
329
349
  flowOwnerAgentName: null,
330
350
  sessionCostUsd: s.sessionUsage.costUsd,
@@ -345,7 +365,7 @@ export class SessionPersister {
345
365
  return;
346
366
  }
347
367
  this.logMeta('persistence.suppressed', {
348
- agent: (_e = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _e !== void 0 ? _e : null,
368
+ agent: (_c = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _c !== void 0 ? _c : null,
349
369
  isFlowOwner: s.flowOwnerAgentName === (liveActive === null || liveActive === void 0 ? void 0 : liveActive.name),
350
370
  frozeAtMessages: frozen.messages.length,
351
371
  custom: !!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.notResumableMessage),
@@ -362,7 +382,7 @@ export class SessionPersister {
362
382
  // event was added to the resumable branch — so a session that broke while an agent was
363
383
  // mid-flow still reported nothing at all, which is precisely the blind spot the
364
384
  // user-facing warning exists to cover.
365
- this.recordSaveOutcome(false, (_f = e === null || e === void 0 ? void 0 : e.message) !== null && _f !== void 0 ? _f : String(e));
385
+ this.recordSaveOutcome(false, (_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : String(e));
366
386
  // Deliberately NOT reporting from here. This is the non-resumable branch and a reload is
367
387
  // the modal's only action, so advising one now would destroy the in-flight work the
368
388
  // snapshot could not carry. `recordSaveOutcome` has already set `_brokenSince`, so the
@@ -375,11 +395,11 @@ export class SessionPersister {
375
395
  this.logMeta('persistence.save-failed', {
376
396
  messages: frozen.messages.length,
377
397
  agents: [],
378
- active: (_g = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _g !== void 0 ? _g : null,
398
+ active: (_e = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _e !== void 0 ? _e : null,
379
399
  hasSerialize: !!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.serialize),
380
400
  frozen: true,
381
- error: (_h = e === null || e === void 0 ? void 0 : e.name) !== null && _h !== void 0 ? _h : typeof e,
382
- message: (_j = e === null || e === void 0 ? void 0 : e.message) !== null && _j !== void 0 ? _j : String(e),
401
+ error: (_f = e === null || e === void 0 ? void 0 : e.name) !== null && _f !== void 0 ? _f : typeof e,
402
+ message: (_g = e === null || e === void 0 ? void 0 : e.message) !== null && _g !== void 0 ? _g : String(e),
383
403
  });
384
404
  logger.error('Session save (interrupted marker) failed:', e);
385
405
  }
@@ -391,7 +411,7 @@ export class SessionPersister {
391
411
  sessionKey: key,
392
412
  host: window.location.host,
393
413
  messages: [...messages],
394
- activeAgentName: (_k = s.activeAgent) === null || _k === void 0 ? void 0 : _k.name,
414
+ activeAgentName: (_h = s.activeAgent) === null || _h === void 0 ? void 0 : _h.name,
395
415
  pinnedAgentName: s.pinnedAgentName,
396
416
  flowOwnerAgentName: s.flowOwnerAgentName,
397
417
  sessionCostUsd: s.sessionUsage.costUsd,
@@ -415,7 +435,7 @@ export class SessionPersister {
415
435
  const captured = {
416
436
  messages: messages.length,
417
437
  agents: Object.keys(agentSnapshots),
418
- active: (_l = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _l !== void 0 ? _l : null,
438
+ active: (_j = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _j !== void 0 ? _j : null,
419
439
  hasSerialize: !!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.serialize),
420
440
  agentSnapshots,
421
441
  };
@@ -434,7 +454,7 @@ export class SessionPersister {
434
454
  // means the body never reached the wire at all, because a provider builds its
435
455
  // request body inside the `fetch` init, so a throw while serialising creates no
436
456
  // request and leaves no server-side trace.
437
- this.recordSaveOutcome(false, (_m = e === null || e === void 0 ? void 0 : e.message) !== null && _m !== void 0 ? _m : String(e));
457
+ this.recordSaveOutcome(false, (_k = e === null || e === void 0 ? void 0 : e.message) !== null && _k !== void 0 ? _k : String(e));
438
458
  // Report straight away rather than waiting for the next attempt: we are on the resumable
439
459
  // path, so a reload is safe NOW, and a one-cycle delay is a second of extra exposure for
440
460
  // no benefit.
@@ -451,8 +471,8 @@ export class SessionPersister {
451
471
  agents: captured.agents,
452
472
  active: captured.active,
453
473
  hasSerialize: captured.hasSerialize,
454
- error: (_o = e === null || e === void 0 ? void 0 : e.name) !== null && _o !== void 0 ? _o : typeof e,
455
- message: (_p = e === null || e === void 0 ? void 0 : e.message) !== null && _p !== void 0 ? _p : String(e),
474
+ error: (_l = e === null || e === void 0 ? void 0 : e.name) !== null && _l !== void 0 ? _l : typeof e,
475
+ message: (_m = e === null || e === void 0 ? void 0 : e.message) !== null && _m !== void 0 ? _m : String(e),
456
476
  });
457
477
  logger.error('Session save failed:', e);
458
478
  }
@@ -825,9 +845,9 @@ export class SessionPersister {
825
845
  const sig = typeof entry.dedupSignature === 'string'
826
846
  ? entry.dedupSignature
827
847
  : JSON.stringify(entry.meta);
828
- if (sig === cursors.lastMetaJson)
848
+ if (sig === cursors.lastMetaDigest)
829
849
  continue;
830
- cursors.lastMetaJson = sig;
850
+ cursors.lastMetaDigest = sig;
831
851
  delta.push(entry);
832
852
  }
833
853
  }