@genesislcap/ai-assistant 15.4.1 → 15.5.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 (91) hide show
  1. package/dist/ai-assistant.api.json +544 -82
  2. package/dist/ai-assistant.d.ts +324 -36
  3. package/dist/chat-driver.cjs +94 -22
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +94 -22
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +303 -36
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  9. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
  11. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  12. package/dist/dts/index.d.ts +1 -0
  13. package/dist/dts/index.d.ts.map +1 -1
  14. package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
  15. package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
  16. package/dist/dts/main/main.d.ts +184 -24
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
  19. package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
  20. package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
  21. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  22. package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  24. package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
  25. package/dist/dts/state/session-store.d.ts +1 -2
  26. package/dist/dts/state/session-store.d.ts.map +1 -1
  27. package/dist/dts/styles/settings-section.d.ts +29 -0
  28. package/dist/dts/styles/settings-section.d.ts.map +1 -0
  29. package/dist/dts/utils/cost-session-history.d.ts +103 -12
  30. package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
  31. package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
  32. package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
  33. package/dist/dts/utils/sum-costs.d.ts.map +1 -1
  34. package/dist/dts/utils/sum-tokens.d.ts +8 -8
  35. package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
  36. package/dist/dts/utils/sum-usage.d.ts +59 -0
  37. package/dist/dts/utils/sum-usage.d.ts.map +1 -0
  38. package/dist/dts/utils/sum-usage.test.d.ts +2 -0
  39. package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
  40. package/dist/esm/components/chat-driver/chat-driver.js +6 -0
  41. package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
  42. package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
  43. package/dist/esm/index.js +1 -0
  44. package/dist/esm/main/cost-session-banking.test.js +308 -0
  45. package/dist/esm/main/main.js +424 -71
  46. package/dist/esm/state/ai-assistant-slice.js +11 -8
  47. package/dist/esm/state/ai-assistant-slice.test.js +12 -5
  48. package/dist/esm/state/debug-event-log.js +2 -2
  49. package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
  50. package/dist/esm/state/persistence/session-persister.js +2 -2
  51. package/dist/esm/state/persistence/session-persister.test.js +10 -1
  52. package/dist/esm/state/persistence/session-snapshot.js +6 -2
  53. package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
  54. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
  55. package/dist/esm/styles/settings-section.js +39 -0
  56. package/dist/esm/utils/cost-session-history.js +92 -15
  57. package/dist/esm/utils/cost-session-history.test.js +155 -13
  58. package/dist/esm/utils/resolve-cost-history-config.js +2 -1
  59. package/dist/esm/utils/sum-costs.js +2 -13
  60. package/dist/esm/utils/sum-tokens.js +10 -27
  61. package/dist/esm/utils/sum-tokens.test.js +1 -5
  62. package/dist/esm/utils/sum-usage.js +123 -0
  63. package/dist/esm/utils/sum-usage.test.js +120 -0
  64. package/dist/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +17 -17
  66. package/src/components/chat-driver/chat-driver.ts +6 -0
  67. package/src/components/settings-modal/settings-modal.styles.ts +237 -18
  68. package/src/components/settings-modal/settings-modal.template.ts +270 -81
  69. package/src/index.ts +1 -0
  70. package/src/main/cost-session-banking.test.ts +407 -0
  71. package/src/main/main.ts +433 -68
  72. package/src/provider/assistant-app-settings.ts +31 -5
  73. package/src/state/ai-assistant-slice.test.ts +12 -5
  74. package/src/state/ai-assistant-slice.ts +21 -13
  75. package/src/state/debug-event-log.ts +2 -2
  76. package/src/state/persistence/session-persistence-provider.ts +24 -0
  77. package/src/state/persistence/session-persistence.integration.test.ts +8 -1
  78. package/src/state/persistence/session-persister.test.ts +10 -1
  79. package/src/state/persistence/session-persister.ts +2 -2
  80. package/src/state/persistence/session-snapshot.test.ts +4 -1
  81. package/src/state/persistence/session-snapshot.ts +5 -1
  82. package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
  83. package/src/styles/settings-section.ts +40 -0
  84. package/src/utils/cost-session-history.test.ts +187 -16
  85. package/src/utils/cost-session-history.ts +142 -23
  86. package/src/utils/resolve-cost-history-config.ts +10 -3
  87. package/src/utils/sum-costs.ts +2 -9
  88. package/src/utils/sum-tokens.test.ts +1 -11
  89. package/src/utils/sum-tokens.ts +10 -26
  90. package/src/utils/sum-usage.test.ts +140 -0
  91. package/src/utils/sum-usage.ts +130 -0
@@ -54,15 +54,14 @@ import { ChatSuggestions } from '../suggestions/chat-suggestions';
54
54
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
55
55
  import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
56
56
  import { collectSessionModels } from '../utils/collect-session-models';
57
- import { appendCostSessionRecord, clearCostSessionHistory, loadCostSessionHistory, removeCostSessionRecord, } from '../utils/cost-session-history';
57
+ import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
58
58
  import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
59
59
  import { logger } from '../utils/logger';
60
60
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
61
61
  import { resolveCostHistoryConfig, } from '../utils/resolve-cost-history-config';
62
62
  import { resolvePreferenceBaseline } from '../utils/resolve-preference-baseline';
63
63
  import { stripAgentHandlers } from '../utils/strip-agent-handlers';
64
- import { sumCosts } from '../utils/sum-costs';
65
- import { sumTokens } from '../utils/sum-tokens';
64
+ import { addUsage, emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
66
65
  import { expandToolTree } from '../utils/tool-fold';
67
66
  import { styles } from './main.styles';
68
67
  import { FoundationAiAssistantTemplate } from './main.template';
@@ -99,6 +98,13 @@ function sanitizeIconMarkup(markup) {
99
98
  */
100
99
  const AGENT_PICKER_CLOSE_MS = 200;
101
100
  const SESSION_MENU_CLOSE_MS = 200;
101
+ /**
102
+ * How long cost-ledger writes coalesce. Comfortably longer than the gap between the turns of a
103
+ * tool loop, so a multi-step run writes once rather than per turn, and short enough that a flush
104
+ * has landed well before a user could close the tab. Nothing reads the stored ledger while the
105
+ * assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
106
+ */
107
+ const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
102
108
  /** Drag-to-resize bounds for the chat input (composer), in px. */
103
109
  const COMPOSER_MIN_HEIGHT_PX = 48;
104
110
  const COMPOSER_MAX_HEIGHT_PX = 400;
@@ -119,10 +125,13 @@ avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWave
119
125
  *
120
126
  * Popout/collapse coordination uses `agenticActivityBus` topics `chat-popout` and `chat-popin` — not DOM `CustomEvent`s on this element.
121
127
  *
122
- * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled,
123
- * finalized sessions persist in `localStorage` (scoped by `costHistory.scope`, the
124
- * element `id`, or `'default'`). Snapshots run on tab close and when the session
125
- * title changes; hosts may call {@link FoundationAiAssistant.finalizeCostSession}.
128
+ * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled, each
129
+ * session's usage is kept as exactly ONE row per session key in `localStorage` (scoped by
130
+ * `costHistory.scope`, the element `id`, or `'default'`), upserted as spend accrues. So a
131
+ * row is a project's lifetime usage rather than a visit to it, and the Usage tab's total is
132
+ * the sum of the rows alone — the live session is never added on top, because the active
133
+ * project's own row already reflects it. Hosts may call
134
+ * {@link FoundationAiAssistant.finalizeCostSession} to flush early; it is idempotent.
126
135
  *
127
136
  * @fires chat-header-mousedown - Fired when the user presses the chat header in `popout-mode="expand"` (for drag-to-popout hosts). Bubbles and composed. detail: `ChatHeaderMouseDownDetail`
128
137
  * @fires session-cleared - Fired after the session is wiped (the lifecycle menu's Clear). The payload is captured before teardown so the host can clean up any external state the active agent was working on. Bubbles and composed. detail: `SessionClearedDetail`
@@ -176,19 +185,37 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
176
185
  this.settingsAppSlotted = [];
177
186
  this.settingsCostCurrentSlotted = [];
178
187
  this.settingsCostHistorySlotted = [];
179
- /** Persisted finalized cost sessions shown on the Cost tab build history list. */
188
+ /** Persisted finalized cost sessions shown on the Usage tab build history list. */
180
189
  this.costSessionHistory = [];
181
190
  /** Whether the splash overlay is currently showing (no messages and showSplash is enabled). Reflected as a boolean attribute on the host. */
182
191
  this.showingSplash = false;
183
192
  /** Title tracked for the in-flight cost session (used when `header-title` changes). */
184
193
  this._activeCostSessionTitle = '';
185
- /** Prevents double-finalize for the same session instance (pagehide + title change). */
186
- this._costSessionFinalizedForInstance = false;
194
+ /** Memoised pre-transcript spend for `_bankedFor` see `bankedUsageFor`. */
195
+ this._bankedUsage = emptyUsage();
196
+ /**
197
+ * Whether the row list reflects storage yet. Gates cost writes so a project's banked
198
+ * spend is never derived from a ledger that simply hasn't arrived.
199
+ */
200
+ this._costHistoryLoaded = false;
201
+ /**
202
+ * Set when a provider read failed. Latches writes off for this page-load: `saveCostHistory`
203
+ * replaces the whole stored list, so writing anything derived from the empty list we fell back
204
+ * to would destroy rows we never managed to read.
205
+ */
206
+ this._ledgerUnreadable = false;
207
+ /** Pending (adopted but unwritten) ledger changes — see `persistCostHistory`. */
208
+ this._ledgerDirty = false;
209
+ // No once-guard for cost-session writes: the write is an upsert keyed by the session
210
+ // key, so repeat calls (pagehide, title change, per-turn) replace the same row and are
211
+ // idempotent. The old flag existed only because the write appended and reset.
187
212
  // Single-view model (GENC-1351 Option A): the one assistant element always finalizes
188
213
  // the cost session on tab close — there is no hidden twin to defer to, so this fires
189
214
  // unconditionally (the old bubble-vs-docked gating flag is gone with the two-view model).
190
215
  this._handlePageHide = () => {
191
216
  this.finalizeCostSession();
217
+ // Straight to storage: the debounce timer will never fire once the page is going away.
218
+ this.flushCostHistory();
192
219
  };
193
220
  this.haloStartPublished = false;
194
221
  /**
@@ -669,40 +696,114 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
669
696
  var _a;
670
697
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setContextLimit(value);
671
698
  }
672
- /** Aggregated USD cost across every chat turn in this session. */
673
- get sessionCostUsd() {
699
+ /**
700
+ * Cost plus the four token buckets for this session — derived from the transcript
701
+ * on every change, so it includes sub-agent turns and spend banked by a compaction.
702
+ */
703
+ get sessionUsage() {
674
704
  var _a, _b;
675
- return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sessionCostUsd) !== null && _b !== void 0 ? _b : 0;
705
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sessionUsage) !== null && _b !== void 0 ? _b : emptyUsage();
676
706
  }
677
- set sessionCostUsd(value) {
707
+ set sessionUsage(value) {
678
708
  var _a;
679
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setSessionCostUsd(value);
709
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setSessionUsage(value);
680
710
  }
681
- /** Saved build history plus the in-flight session — shown on the Cost tab summary. */
682
- get cumulativeCostUsd() {
683
- // TODO: Handle project switching/loading so this reflects the active project's total cost.
711
+ /**
712
+ * Aggregated USD cost across every chat turn in this session.
713
+ *
714
+ * Read-only (unlike previously): it is a projection of `sessionUsage`, and a
715
+ * host assigning it would have set a figure the next transcript change overwrites.
716
+ */
717
+ get sessionCostUsd() {
718
+ return this.sessionUsage.costUsd;
719
+ }
720
+ /**
721
+ * Distinct models the registry currently resolves to — the live session's model chips.
722
+ *
723
+ * `providerStatuses` carries one entry per registry SLOT, so a vendor configured across several
724
+ * tiers that happen to resolve to the same model reports it once per tier, and the chips
725
+ * rendered it once per tier too. Which tier a model came from is not what these chips are for.
726
+ *
727
+ * Deduped on the label actually displayed, not on `status.model`: a slot whose provider reports
728
+ * no model falls back to the slot's own name, and those are distinct per slot, so keying on the
729
+ * label keeps them from collapsing into one another.
730
+ */
731
+ get uniqueProviderModels() {
732
+ var _a, _b, _c, _d;
733
+ const seen = new Map();
734
+ for (const entry of this.providerStatuses) {
735
+ const model = (_b = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : entry.name;
736
+ if (!model)
737
+ continue;
738
+ const existing = seen.get(model);
739
+ if (!existing) {
740
+ seen.set(model, { model, provider: (_c = entry.status) === null || _c === void 0 ? void 0 : _c.provider });
741
+ continue;
742
+ }
743
+ // Keep whichever tier knew the vendor, so the chip still gets its colour when one slot
744
+ // reports a provider and another does not.
745
+ if (existing.provider === undefined && ((_d = entry.status) === null || _d === void 0 ? void 0 : _d.provider) !== undefined) {
746
+ existing.provider = entry.status.provider;
747
+ }
748
+ }
749
+ return [...seen.values()];
750
+ }
751
+ /**
752
+ * Build history for display: most recently worked on first.
753
+ *
754
+ * The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
755
+ * sitting wherever it was first seen — so ordering is applied here, on read, where it also
756
+ * covers rows a host provider supplied.
757
+ */
758
+ get sortedCostSessionHistory() {
759
+ return sortRecordsByRecency(this.costSessionHistory);
760
+ }
761
+ /**
762
+ * Lifetime usage across every project in the history scope — cost and all four token
763
+ * buckets. What the Usage tab's lifetime section renders.
764
+ *
765
+ * Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
766
+ * usage and the active project's row is kept current on every transcript change, so
767
+ * adding the live total would count this project's spend twice. That double-count is what
768
+ * made the figure grow on every refresh: a page-hide banked a row while the restored
769
+ * session still carried the same spend.
770
+ */
771
+ get cumulativeUsage() {
684
772
  void this.costSessionHistory;
685
773
  void this.chatConfig;
686
- const historyTotal = this.resolvedCostHistory.enabled
687
- ? this.costSessionHistory.reduce((sum, record) => sum + record.costUsd, 0)
688
- : 0;
689
- return historyTotal + this.sessionCostUsd;
774
+ // With history off there are no rows to sum, so the live session IS all we know.
775
+ if (!this.resolvedCostHistory.enabled)
776
+ return this.sessionUsage;
777
+ return this.costSessionHistory.reduce((total, record) => addUsage(total, record.usage), emptyUsage());
778
+ }
779
+ /** Lifetime USD across every project in the history scope. */
780
+ get cumulativeCostUsd() {
781
+ return this.cumulativeUsage.costUsd;
782
+ }
783
+ /**
784
+ * Whether the live session figures amount to this project's whole history, which is true
785
+ * exactly when the transcript is restored on load. Drives the Usage tab's live labels
786
+ * ("Project cost" vs "Session cost") so they state the scope rather than imply one.
787
+ *
788
+ * Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
789
+ * (see `CostSessionRecord.banked`) and are lifetime either way.
790
+ */
791
+ get liveUsageIsProjectLifetime() {
792
+ return this.persistenceActive;
690
793
  }
691
794
  /** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
692
795
  get resolvedCostHistory() {
693
796
  void this.chatConfig;
694
797
  return resolveCostHistoryConfig(this.chatConfig);
695
798
  }
696
- /** Cumulative input + output tokens across every chat turn in this session. */
799
+ /**
800
+ * Total tokens across every chat turn in this session — all four buckets added up.
801
+ * Read-only; the per-bucket split is on `sessionUsage`.
802
+ */
697
803
  get sessionTokensConsumed() {
698
- var _a, _b;
699
- return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sessionTokensConsumed) !== null && _b !== void 0 ? _b : 0;
700
- }
701
- set sessionTokensConsumed(value) {
702
- var _a;
703
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setSessionTokensConsumed(value);
804
+ return totalTokens(this.sessionUsage);
704
805
  }
705
- /** Label shown on the Cost tab for the in-flight session (app title or first user phrase). */
806
+ /** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
706
807
  get liveCostSessionTitle() {
707
808
  var _a, _b;
708
809
  void this.messages;
@@ -713,17 +814,37 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
713
814
  return deriveCostSessionTitleFromMessages(this.messages);
714
815
  }
715
816
  /**
716
- * Snapshot the running session into persisted build history (`localStorage`).
717
- * No-op unless `chatConfig.costHistory.enabled` is `true`.
817
+ * Write this session's usage into persisted build history (`localStorage`) as a single
818
+ * row keyed by the session key, replacing any existing row for that key.
819
+ *
820
+ * **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
821
+ * The name is historical: it does NOT end the session or reset the running totals.
822
+ * Resetting them is what used to double-count spend, since the row was banked while the
823
+ * persisted transcript still carried the same usage, so a reload restored it and the
824
+ * Usage tab counted both.
825
+ *
826
+ * No-op unless `chatConfig.costHistory.enabled` is `true`, when the live session has no
827
+ * usage yet, or when the session has no key to upsert against. Gating on the *live*
828
+ * figures (not the banked total) means a reload that restores nothing rewrites nothing.
718
829
  */
719
830
  finalizeCostSession(options) {
720
831
  if (!this.resolvedCostHistory.enabled)
721
832
  return;
722
- if (this._costSessionFinalizedForInstance)
833
+ // Wait for the rows before writing: a provider read is async, and banking from a
834
+ // not-yet-loaded ledger would read as "nothing came before" and rewrite this project's
835
+ // row down to the current transcript. Skipping is free — this is called on every
836
+ // transcript change, so the next one writes.
837
+ if (!this._costHistoryLoaded)
838
+ return;
839
+ const live = this.sessionUsage;
840
+ const tokensConsumed = totalTokens(live);
841
+ if (live.costUsd <= 0 && tokensConsumed <= 0)
723
842
  return;
724
- const costUsd = this.sessionCostUsd;
725
- const tokensConsumed = this.sessionTokensConsumed;
726
- if (costUsd <= 0 && tokensConsumed <= 0)
843
+ // The session key identifies the work — a project id, for hosts that key sessions
844
+ // per project. Without one there is nothing stable to upsert against, so skip
845
+ // rather than write a row that can never be matched again.
846
+ const projectKey = this.getStateKey();
847
+ if (!projectKey)
727
848
  return;
728
849
  const title = resolveCostSessionTitle({
729
850
  explicit: options === null || options === void 0 ? void 0 : options.title,
@@ -731,18 +852,62 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
731
852
  messages: this.messages,
732
853
  headerTitle: this.headerTitle,
733
854
  });
855
+ // Lifetime = what came before this transcript + what this transcript proves. Both
856
+ // terms are derived from state, never incremented, so the two element instances that
857
+ // share a session write the same row rather than each adding their own contribution.
858
+ const banked = this.bankedUsageFor(projectKey);
734
859
  const record = {
735
- id: crypto.randomUUID(),
860
+ // Stable per project so repeat upserts replace the row instead of stacking, and
861
+ // so the delete action keeps targeting the same row across reloads.
862
+ id: projectKey,
863
+ projectKey,
736
864
  title,
737
- endedAt: new Date().toISOString(),
738
- costUsd,
739
- tokensConsumed,
865
+ updatedAt: new Date().toISOString(),
866
+ usage: addUsage(banked, live),
867
+ banked,
740
868
  models: collectSessionModels(this.messages, this.providerStatuses),
741
869
  };
742
- this.costSessionHistory = appendCostSessionRecord(this.getCostHistoryScope(), record);
743
- this._costSessionFinalizedForInstance = true;
744
- this.sessionCostUsd = 0;
745
- this.sessionTokensConsumed = 0;
870
+ this.persistCostHistory(upsertRecord(this.costSessionHistory, record));
871
+ // NOTE: the live counters are deliberately NOT reset here. Resetting was what
872
+ // double-counted spend — the row was banked while the persisted transcript still
873
+ // carried the same usage, so a reload restored it and the Usage tab added both.
874
+ // The row is a projection of the session totals, not a transfer out of them.
875
+ }
876
+ /**
877
+ * Spend for `projectKey` that predates the current transcript, resolved once per project
878
+ * and memoised (`_bankedFor`) so it cannot drift mid-session as the row it came from is
879
+ * rewritten by our own upserts.
880
+ *
881
+ * The two cases turn on whether the transcript is authoritative for this project:
882
+ *
883
+ * - **Transcript restored** (`persistenceActive`): it already accounts for everything after
884
+ * the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
885
+ * `usage` here would add the restored history to itself on every reload.
886
+ * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
887
+ * whole of it becomes banked. Without this the next turn would rewrite the row downwards
888
+ * to just that turn, losing the project's earlier spend.
889
+ */
890
+ bankedUsageFor(projectKey) {
891
+ if (this._bankedFor === projectKey)
892
+ return this._bankedUsage;
893
+ const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
894
+ // `persistenceActive` is what decides whether the transcript speaks for the project's
895
+ // history: it is on only when a provider is wired AND the user is remembering the chat.
896
+ this._bankedUsage = resolveBankedUsage(existing, this.persistenceActive);
897
+ this._bankedFor = projectKey;
898
+ return this._bankedUsage;
899
+ }
900
+ /**
901
+ * Discard the banked figure — for when the spend it stands for has been deliberately
902
+ * thrown away (the user clearing history, or deleting this project's row).
903
+ *
904
+ * NOT for reloads. A reload re-reads rows that already include our own upserts, so
905
+ * re-deriving from them would fold the live session into its own baseline; only a genuine
906
+ * project change should re-derive, which `bankedUsageFor`'s per-project memo handles.
907
+ */
908
+ resetBankedUsage() {
909
+ this._bankedFor = undefined;
910
+ this._bankedUsage = emptyUsage();
746
911
  }
747
912
  showConfirmationDialog(confirmationMessage, modalParent, onConfirm, onCancel) {
748
913
  showNotificationDialog({
@@ -756,10 +921,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
756
921
  }
757
922
  /** Remove one entry from persisted build history. */
758
923
  removeCostSessionFromHistory(id) {
759
- this.costSessionHistory = removeCostSessionRecord(this.getCostHistoryScope(), id);
924
+ // Deleting the active project's row discards its banked spend by intent, so drop the
925
+ // memo too — otherwise the next turn would rewrite the row we just removed, restoring
926
+ // the total the user asked to be rid of.
927
+ //
928
+ // Resolve the row first: `id` is a row id while `_bankedFor` is a projectKey. They match
929
+ // only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a row from
930
+ // a host provider can carry any id, and comparing across the two namespaces would silently
931
+ // skip the reset.
932
+ const removed = this.costSessionHistory.find((record) => record.id === id);
933
+ if (removed && this._bankedFor === removed.projectKey)
934
+ this.resetBankedUsage();
935
+ this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
760
936
  }
761
937
  /** Clear all persisted build history for this assistant scope. */
762
938
  clearCostSessionHistory() {
939
+ // Every row is gone, so nothing is banked any more — without this the active project's
940
+ // next turn would resurrect its pre-clear total.
941
+ this.resetBankedUsage();
942
+ // Provider-backed: write an empty ledger through. The `localStorage` path instead
943
+ // removes its key outright rather than leaving an empty array behind.
944
+ if (this.costHistoryProvider()) {
945
+ this.persistCostHistory([]);
946
+ return;
947
+ }
763
948
  clearCostSessionHistory(this.getCostHistoryScope());
764
949
  this.costSessionHistory = [];
765
950
  }
@@ -772,23 +957,134 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
772
957
  return 'default';
773
958
  }
774
959
  reloadCostSessionHistory() {
960
+ // NOTE: deliberately does NOT invalidate the banked figure. This runs again whenever
961
+ // `chatConfig` rebinds — hosts commonly build it in a template expression, so a new
962
+ // object arrives mid-session — and by then the rows include our own upserts, whose
963
+ // `usage` already contains the live transcript. Re-deriving from those would add the
964
+ // current session to itself. `bankedUsageFor` keys its memo on the project instead,
965
+ // which handles a genuine session switch without trusting reload timing.
966
+ this._costHistoryLoaded = false;
967
+ this._ledgerUnreadable = false;
775
968
  if (!this.resolvedCostHistory.enabled) {
776
969
  this.costSessionHistory = [];
970
+ this._costHistoryLoaded = true;
971
+ return;
972
+ }
973
+ const scope = this.getCostHistoryScope();
974
+ const provider = this.costHistoryProvider();
975
+ if (!provider) {
976
+ this.costSessionHistory = loadCostSessionHistory(scope);
977
+ this._costHistoryLoaded = true;
978
+ return;
979
+ }
980
+ // Provider reads are async, so the Usage tab renders an empty list for a tick before
981
+ // this lands. Seeded empty rather than left stale so a scope change can't briefly show
982
+ // the previous scope's rows.
983
+ this.costSessionHistory = [];
984
+ void provider.loadCostHistory(scope)
985
+ .then((records) => {
986
+ // Guard against a scope change while the load was in flight — a late result must
987
+ // not overwrite the rows of whatever scope is current now.
988
+ if (this.getCostHistoryScope() === scope) {
989
+ // Filtered exactly as the localStorage path is. A host's rows are as capable of
990
+ // being malformed as a hand-edited storage entry, and an unchecked one poisons
991
+ // every total with NaN or throws in the sort during render.
992
+ this.costSessionHistory = (records !== null && records !== void 0 ? records : []).filter(isCostSessionRecord);
993
+ this._costHistoryLoaded = true;
994
+ }
995
+ })
996
+ .catch((e) => {
997
+ logger.warn('Cost history unreadable — recording locally only this page-load', e);
998
+ // Writes stay blocked, and `persistCostHistory` will not save even if something else
999
+ // calls it. `saveCostHistory` REPLACES the stored list, so writing an upsert against
1000
+ // the empty list we fell back to would delete every other project's row and reset
1001
+ // this project's banked total — destroying data we simply failed to read. The row
1002
+ // rebuilds from the transcript on the next successful load.
1003
+ this._ledgerUnreadable = true;
1004
+ });
1005
+ }
1006
+ /**
1007
+ * The persistence provider, but only when it implements BOTH cost-history methods.
1008
+ *
1009
+ * Both or neither: a provider that can save but not load would silently accumulate a
1010
+ * ledger nobody ever reads back, so a partial implementation is treated as absent and
1011
+ * falls back to `localStorage`.
1012
+ */
1013
+ costHistoryProvider() {
1014
+ const provider = this.persistence.provider;
1015
+ return (provider === null || provider === void 0 ? void 0 : provider.loadCostHistory) && provider.saveCostHistory ? provider : undefined;
1016
+ }
1017
+ /**
1018
+ * Adopt `records` as the live list, and schedule a write to whichever backend owns cost
1019
+ * history — the provider when the host implements it, else `localStorage`.
1020
+ *
1021
+ * The list is adopted synchronously because that is what the Usage tab renders; only the
1022
+ * *write* is deferred. This runs on every usage change (about once per LLM call, plus once
1023
+ * per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
1024
+ * per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
1025
+ * because the write is a full replace of idempotent state: the newest call wins and no
1026
+ * intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
1027
+ */
1028
+ persistCostHistory(records) {
1029
+ this.costSessionHistory = records;
1030
+ // A ledger we could not read must never be written back — see reloadCostSessionHistory.
1031
+ // Guarded here rather than only at the finalize gate so it also covers the row-removal and
1032
+ // clear paths, which write directly.
1033
+ if (this._ledgerUnreadable)
1034
+ return;
1035
+ this._ledgerDirty = true;
1036
+ if (this._ledgerFlushTimer !== undefined)
1037
+ clearTimeout(this._ledgerFlushTimer);
1038
+ this._ledgerFlushTimer = setTimeout(() => this.flushCostHistory(), LEDGER_FLUSH_DEBOUNCE_MS);
1039
+ }
1040
+ /**
1041
+ * Write the pending ledger now. Safe to call when nothing is pending.
1042
+ *
1043
+ * Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
1044
+ * conversation content, so a failed write is logged and never surfaced into the chat. On
1045
+ * `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
1046
+ * the same unload race as every other network save the assistant makes.
1047
+ */
1048
+ flushCostHistory() {
1049
+ if (this._ledgerFlushTimer !== undefined) {
1050
+ clearTimeout(this._ledgerFlushTimer);
1051
+ this._ledgerFlushTimer = undefined;
1052
+ }
1053
+ if (!this._ledgerDirty || this._ledgerUnreadable)
1054
+ return;
1055
+ this._ledgerDirty = false;
1056
+ const records = this.costSessionHistory;
1057
+ const scope = this.getCostHistoryScope();
1058
+ const provider = this.costHistoryProvider();
1059
+ if (!provider) {
1060
+ saveCostSessionHistory(scope, records);
777
1061
  return;
778
1062
  }
779
- this.costSessionHistory = loadCostSessionHistory(this.getCostHistoryScope());
1063
+ void provider.saveCostHistory(scope, records).catch((e) => {
1064
+ logger.warn('Cost history save failed', e);
1065
+ });
780
1066
  }
781
- /** Finalize the previous session when the host-provided title changes. */
1067
+ /**
1068
+ * Finalize the previous session when the host-provided title changes — a rename should land on
1069
+ * the row under its old name rather than silently relabelling accrued spend.
1070
+ *
1071
+ * Only when the session key still matches the one that title accrued under. Hosts change
1072
+ * `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
1073
+ * upsert the outgoing project's live usage onto the incoming project's row as
1074
+ * `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
1075
+ * row; with the upsert it overwrites a real one.
1076
+ */
782
1077
  syncActiveCostSessionTitle() {
783
1078
  var _a, _b;
784
1079
  const previous = this._activeCostSessionTitle;
785
1080
  const next = (_b = (_a = this.resolvedCostHistory.title) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
786
- if (previous && previous !== next) {
1081
+ const key = this.getStateKey();
1082
+ if (previous && previous !== next && this._activeCostSessionKey === key) {
787
1083
  this.finalizeCostSession({ title: previous });
788
1084
  }
789
1085
  if (previous !== next) {
790
1086
  this._activeCostSessionTitle = next;
791
- this._costSessionFinalizedForInstance = false;
1087
+ this._activeCostSessionKey = key;
792
1088
  }
793
1089
  }
794
1090
  /** Active model id resolved from the AIProvider, if exposed. */
@@ -1502,6 +1798,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1502
1798
  disconnectedCallback() {
1503
1799
  var _a, _b, _c, _d, _e, _f;
1504
1800
  window.removeEventListener('pagehide', this._handlePageHide);
1801
+ // Write out anything the debounce is still holding — the timer would otherwise fire against
1802
+ // a torn-down element, or not at all.
1803
+ this.flushCostHistory();
1505
1804
  super.disconnectedCallback();
1506
1805
  this._sessionActive = false;
1507
1806
  this.stopLoadingTimer();
@@ -1662,18 +1961,24 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1662
1961
  break;
1663
1962
  }
1664
1963
  }
1665
- // Recompute aggregated session cost from per-message `cost` fields. Recomputing
1666
- // (rather than incrementing on append) keeps the total correct under any
1667
- // mutation of the message list — including future clear-chat / re-render flows.
1668
- // `sumCosts` recurses into `toolCall.subAgentTrace` so cost incurred by
1669
- // sub-agents (potentially on different providers) is included.
1670
- const runningCost = sumCosts(this.messages);
1671
- if (runningCost !== this.sessionCostUsd) {
1672
- this.sessionCostUsd = runningCost;
1673
- }
1674
- const runningTokens = sumTokens(this.messages);
1675
- if (runningTokens !== this.sessionTokensConsumed) {
1676
- this.sessionTokensConsumed = runningTokens;
1964
+ // Recompute cost and all four token buckets from the transcript in one walk.
1965
+ // Recomputing (rather than incrementing on append) keeps the totals correct under
1966
+ // any mutation of the message list — clear-chat, re-render, restore. `sumUsage`
1967
+ // recurses into `toolCall.subAgentTrace`, so work a sub-agent did (possibly on
1968
+ // another provider at other rates) counts, and it reads the usage a compaction
1969
+ // banked onto its summary, so shrinking history no longer shrinks the totals.
1970
+ const running = sumUsage(this.messages);
1971
+ const current = this.sessionUsage;
1972
+ if (running.costUsd !== current.costUsd ||
1973
+ running.uncachedInputTokens !== current.uncachedInputTokens ||
1974
+ running.cacheReadTokens !== current.cacheReadTokens ||
1975
+ running.cacheWriteTokens !== current.cacheWriteTokens ||
1976
+ running.outputTokens !== current.outputTokens) {
1977
+ this.sessionUsage = running;
1978
+ // Keep this project's history row current as spend accrues, which is what lets
1979
+ // the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
1980
+ // by the session key), so calling it per change is safe.
1981
+ this.finalizeCostSession();
1677
1982
  }
1678
1983
  // Record a context.updated meta event when the token count changes (≈once
1679
1984
  // per LLM call, as a new usage-bearing message arrives), plus a one-shot
@@ -2115,16 +2420,22 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2115
2420
  get settingsAppSectionVisible() {
2116
2421
  return this.settingsAppSlotted.length > 0 || this.visibleAppSettingsToggles.length > 0;
2117
2422
  }
2118
- /** Section title for the app settings block. */
2119
- get appSettingsSectionTitle() {
2120
- var _a;
2121
- return (_a = this.appSettingsProvider.sectionTitle) !== null && _a !== void 0 ? _a : 'UI Builder Settings';
2122
- }
2123
2423
  /** App toggles currently visible per each entry's `visible()` predicate. */
2124
2424
  get visibleAppSettingsToggles() {
2125
2425
  void this.appSettingsRevision;
2126
2426
  return this.appSettingsProvider.toggles.filter((toggle) => { var _a; return ((_a = toggle.visible) === null || _a === void 0 ? void 0 : _a.call(toggle)) !== false; });
2127
2427
  }
2428
+ /**
2429
+ * Host-supplied heading for the toggle group, or `undefined` for none — the assistant has no
2430
+ * default. Suppressed when no toggle is currently visible, so a `visible()` predicate hiding
2431
+ * the last one takes its heading with it rather than leaving a label over nothing.
2432
+ */
2433
+ get appSettingsTogglesHeading() {
2434
+ void this.appSettingsRevision;
2435
+ if (this.visibleAppSettingsToggles.length === 0)
2436
+ return undefined;
2437
+ return this.appSettingsProvider.togglesHeading;
2438
+ }
2128
2439
  getAppSettingValue(id) {
2129
2440
  void this.appSettingsRevision;
2130
2441
  return this.appSettingsProvider.getValue(id);
@@ -2153,7 +2464,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2153
2464
  return false;
2154
2465
  return this.costSessionHistory.length > 0 || this.settingsCostHistorySlotted.length > 0;
2155
2466
  }
2156
- /** Whether the built-in Cost tab summary block has anything to show. */
2467
+ /** Whether the built-in Usage tab summary block has anything to show. */
2157
2468
  get settingsCostSummaryVisible() {
2158
2469
  var _a;
2159
2470
  void this.chatConfig;
@@ -2167,7 +2478,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2167
2478
  }
2168
2479
  return !!this.liveCostSessionTitle;
2169
2480
  }
2170
- /** Whether the Cost tab should appear in the settings modal. */
2481
+ /**
2482
+ * Whether the lifetime-usage section has anything to say.
2483
+ *
2484
+ * Requires build history: with it off there are no rows, so `cumulativeUsage` falls back to
2485
+ * the live session and the section would restate the block directly below it under a
2486
+ * "lifetime" heading it hasn't earned.
2487
+ */
2488
+ get settingsCostLifetimeVisible() {
2489
+ void this.chatConfig;
2490
+ void this._sessionRef;
2491
+ const cost = this.resolvedCostHistory;
2492
+ if (!cost.enabled || !(cost.showCost || cost.showTokens))
2493
+ return false;
2494
+ // Also requires a state key, because `finalizeCostSession` skips the write without one and the
2495
+ // lifetime figures sum rows only — so the section would show a permanent $0.00 directly above
2496
+ // a live block reporting real spend.
2497
+ //
2498
+ // Narrow in practice: `getStateKey` falls back to `id::headerTitle`, and `headerTitle` has a
2499
+ // non-empty default, so a key is almost always derivable even with no `session-key`. This
2500
+ // catches the remaining case — a host that supplies no session key, no element id, AND blanks
2501
+ // the header title. Cheap, and the alternative is a figure that is simply wrong.
2502
+ return !!this.getStateKey();
2503
+ }
2504
+ /** Whether the Usage tab should appear in the settings modal. */
2171
2505
  get settingsCostTabVisible() {
2172
2506
  void this.chatConfig;
2173
2507
  const cost = this.resolvedCostHistory;
@@ -2176,7 +2510,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2176
2510
  this.settingsCostCurrentSectionVisible ||
2177
2511
  this.settingsCostHistorySectionVisible);
2178
2512
  }
2179
- /** Whether session token usage should render on the Cost tab. */
2513
+ /** Whether session token usage should render on the Usage tab. */
2180
2514
  get settingsSessionTokensVisible() {
2181
2515
  void this.chatConfig;
2182
2516
  return this.resolvedCostHistory.showTokens;
@@ -2468,6 +2802,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2468
2802
  contextUsagePercent,
2469
2803
  sessionCostUsd: this.sessionCostUsd,
2470
2804
  sessionTokensConsumed: this.sessionTokensConsumed,
2805
+ // The per-bucket split alongside the total. Without it a log shows a large
2806
+ // token count next to a small cost with no way to tell why — the answer is
2807
+ // almost always that most of the prompt was a cache hit.
2808
+ sessionUsage: this.sessionUsage,
2471
2809
  },
2472
2810
  // Snapshot captured fresh at log-export time — reflects state NOW, which
2473
2811
  // may have transitioned since the last LLM call.
@@ -3078,9 +3416,21 @@ __decorate([
3078
3416
  __decorate([
3079
3417
  volatile
3080
3418
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
3419
+ __decorate([
3420
+ volatile
3421
+ ], FoundationAiAssistant.prototype, "uniqueProviderModels", null);
3422
+ __decorate([
3423
+ volatile
3424
+ ], FoundationAiAssistant.prototype, "sortedCostSessionHistory", null);
3425
+ __decorate([
3426
+ volatile
3427
+ ], FoundationAiAssistant.prototype, "cumulativeUsage", null);
3081
3428
  __decorate([
3082
3429
  volatile
3083
3430
  ], FoundationAiAssistant.prototype, "cumulativeCostUsd", null);
3431
+ __decorate([
3432
+ volatile
3433
+ ], FoundationAiAssistant.prototype, "liveUsageIsProjectLifetime", null);
3084
3434
  __decorate([
3085
3435
  volatile
3086
3436
  ], FoundationAiAssistant.prototype, "resolvedCostHistory", null);
@@ -3170,10 +3520,10 @@ __decorate([
3170
3520
  ], FoundationAiAssistant.prototype, "settingsAppSectionVisible", null);
3171
3521
  __decorate([
3172
3522
  volatile
3173
- ], FoundationAiAssistant.prototype, "appSettingsSectionTitle", null);
3523
+ ], FoundationAiAssistant.prototype, "visibleAppSettingsToggles", null);
3174
3524
  __decorate([
3175
3525
  volatile
3176
- ], FoundationAiAssistant.prototype, "visibleAppSettingsToggles", null);
3526
+ ], FoundationAiAssistant.prototype, "appSettingsTogglesHeading", null);
3177
3527
  __decorate([
3178
3528
  volatile
3179
3529
  ], FoundationAiAssistant.prototype, "settingsCostCurrentSectionVisible", null);
@@ -3183,6 +3533,9 @@ __decorate([
3183
3533
  __decorate([
3184
3534
  volatile
3185
3535
  ], FoundationAiAssistant.prototype, "settingsCostSummaryVisible", null);
3536
+ __decorate([
3537
+ volatile
3538
+ ], FoundationAiAssistant.prototype, "settingsCostLifetimeVisible", null);
3186
3539
  __decorate([
3187
3540
  volatile
3188
3541
  ], FoundationAiAssistant.prototype, "settingsCostTabVisible", null);