@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
package/src/main/main.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  // =============================================================================
23
23
 
24
24
  import type {
25
+ AggregateUsage,
25
26
  AIProviderRegistryStatusEntry,
26
27
  ChatAttachment,
27
28
  ChatConfig,
@@ -59,6 +60,7 @@ import { AiWavesIndicator } from '../components/waves-indicator';
59
60
  import type { AgentConfig } from '../config/config';
60
61
  import {
61
62
  AssistantAppSettingsProvider,
63
+ type AssistantAppSettingsHeading,
62
64
  type AssistantAppSettingsToggle,
63
65
  } from '../provider/assistant-app-settings';
64
66
  import {
@@ -113,10 +115,14 @@ import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
113
115
  import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
114
116
  import { collectSessionModels } from '../utils/collect-session-models';
115
117
  import {
116
- appendCostSessionRecord,
117
118
  clearCostSessionHistory,
119
+ isCostSessionRecord,
118
120
  loadCostSessionHistory,
119
- removeCostSessionRecord,
121
+ resolveBankedUsage,
122
+ saveCostSessionHistory,
123
+ sortRecordsByRecency,
124
+ upsertRecord,
125
+ type CostSessionModelEntry,
120
126
  type CostSessionRecord,
121
127
  } from '../utils/cost-session-history';
122
128
  import {
@@ -131,8 +137,7 @@ import {
131
137
  } from '../utils/resolve-cost-history-config';
132
138
  import { resolvePreferenceBaseline } from '../utils/resolve-preference-baseline';
133
139
  import { stripAgentHandlers } from '../utils/strip-agent-handlers';
134
- import { sumCosts } from '../utils/sum-costs';
135
- import { sumTokens } from '../utils/sum-tokens';
140
+ import { addUsage, emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
136
141
  import { expandToolTree } from '../utils/tool-fold';
137
142
  import { styles } from './main.styles';
138
143
  import { FoundationAiAssistantTemplate } from './main.template';
@@ -189,6 +194,13 @@ function sanitizeIconMarkup(markup: string): string {
189
194
  */
190
195
  const AGENT_PICKER_CLOSE_MS = 200;
191
196
  const SESSION_MENU_CLOSE_MS = 200;
197
+ /**
198
+ * How long cost-ledger writes coalesce. Comfortably longer than the gap between the turns of a
199
+ * tool loop, so a multi-step run writes once rather than per turn, and short enough that a flush
200
+ * has landed well before a user could close the tab. Nothing reads the stored ledger while the
201
+ * assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
202
+ */
203
+ const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
192
204
 
193
205
  /** Drag-to-resize bounds for the chat input (composer), in px. */
194
206
  const COMPOSER_MIN_HEIGHT_PX = 48;
@@ -223,10 +235,13 @@ avoidTreeShaking(
223
235
  *
224
236
  * Popout/collapse coordination uses `agenticActivityBus` topics `chat-popout` and `chat-popin` — not DOM `CustomEvent`s on this element.
225
237
  *
226
- * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled,
227
- * finalized sessions persist in `localStorage` (scoped by `costHistory.scope`, the
228
- * element `id`, or `'default'`). Snapshots run on tab close and when the session
229
- * title changes; hosts may call {@link FoundationAiAssistant.finalizeCostSession}.
238
+ * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled, each
239
+ * session's usage is kept as exactly ONE row per session key in `localStorage` (scoped by
240
+ * `costHistory.scope`, the element `id`, or `'default'`), upserted as spend accrues. So a
241
+ * row is a project's lifetime usage rather than a visit to it, and the Usage tab's total is
242
+ * the sum of the rows alone — the live session is never added on top, because the active
243
+ * project's own row already reflects it. Hosts may call
244
+ * {@link FoundationAiAssistant.finalizeCostSession} to flush early; it is idempotent.
230
245
  *
231
246
  * @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`
232
247
  * @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`
@@ -688,24 +703,109 @@ export class FoundationAiAssistant extends GenesisElement {
688
703
  this._sessionRef?.actions.aiAssistant.setContextLimit(value);
689
704
  }
690
705
 
691
- /** Aggregated USD cost across every chat turn in this session. */
706
+ /**
707
+ * Cost plus the four token buckets for this session — derived from the transcript
708
+ * on every change, so it includes sub-agent turns and spend banked by a compaction.
709
+ */
710
+ get sessionUsage(): AggregateUsage {
711
+ return this._sessionRef?.store.aiAssistant.sessionUsage ?? emptyUsage();
712
+ }
713
+ set sessionUsage(value: AggregateUsage) {
714
+ this._sessionRef?.actions.aiAssistant.setSessionUsage(value);
715
+ }
716
+
717
+ /**
718
+ * Aggregated USD cost across every chat turn in this session.
719
+ *
720
+ * Read-only (unlike previously): it is a projection of `sessionUsage`, and a
721
+ * host assigning it would have set a figure the next transcript change overwrites.
722
+ */
692
723
  get sessionCostUsd(): number {
693
- return this._sessionRef?.store.aiAssistant.sessionCostUsd ?? 0;
724
+ return this.sessionUsage.costUsd;
694
725
  }
695
- set sessionCostUsd(value: number) {
696
- this._sessionRef?.actions.aiAssistant.setSessionCostUsd(value);
726
+
727
+ /**
728
+ * Distinct models the registry currently resolves to — the live session's model chips.
729
+ *
730
+ * `providerStatuses` carries one entry per registry SLOT, so a vendor configured across several
731
+ * tiers that happen to resolve to the same model reports it once per tier, and the chips
732
+ * rendered it once per tier too. Which tier a model came from is not what these chips are for.
733
+ *
734
+ * Deduped on the label actually displayed, not on `status.model`: a slot whose provider reports
735
+ * no model falls back to the slot's own name, and those are distinct per slot, so keying on the
736
+ * label keeps them from collapsing into one another.
737
+ */
738
+ @volatile
739
+ get uniqueProviderModels(): CostSessionModelEntry[] {
740
+ const seen = new Map<string, CostSessionModelEntry>();
741
+ for (const entry of this.providerStatuses) {
742
+ const model = entry.status?.model ?? entry.name;
743
+ if (!model) continue;
744
+ const existing = seen.get(model);
745
+ if (!existing) {
746
+ seen.set(model, { model, provider: entry.status?.provider });
747
+ continue;
748
+ }
749
+ // Keep whichever tier knew the vendor, so the chip still gets its colour when one slot
750
+ // reports a provider and another does not.
751
+ if (existing.provider === undefined && entry.status?.provider !== undefined) {
752
+ existing.provider = entry.status.provider;
753
+ }
754
+ }
755
+ return [...seen.values()];
697
756
  }
698
757
 
699
- /** Saved build history plus the in-flight session — shown on the Cost tab summary. */
758
+ /**
759
+ * Build history for display: most recently worked on first.
760
+ *
761
+ * The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
762
+ * sitting wherever it was first seen — so ordering is applied here, on read, where it also
763
+ * covers rows a host provider supplied.
764
+ */
700
765
  @volatile
701
- get cumulativeCostUsd(): number {
702
- // TODO: Handle project switching/loading so this reflects the active project's total cost.
766
+ get sortedCostSessionHistory(): CostSessionRecord[] {
767
+ return sortRecordsByRecency(this.costSessionHistory);
768
+ }
769
+
770
+ /**
771
+ * Lifetime usage across every project in the history scope — cost and all four token
772
+ * buckets. What the Usage tab's lifetime section renders.
773
+ *
774
+ * Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
775
+ * usage and the active project's row is kept current on every transcript change, so
776
+ * adding the live total would count this project's spend twice. That double-count is what
777
+ * made the figure grow on every refresh: a page-hide banked a row while the restored
778
+ * session still carried the same spend.
779
+ */
780
+ @volatile
781
+ get cumulativeUsage(): AggregateUsage {
703
782
  void this.costSessionHistory;
704
783
  void this.chatConfig;
705
- const historyTotal = this.resolvedCostHistory.enabled
706
- ? this.costSessionHistory.reduce((sum, record) => sum + record.costUsd, 0)
707
- : 0;
708
- return historyTotal + this.sessionCostUsd;
784
+ // With history off there are no rows to sum, so the live session IS all we know.
785
+ if (!this.resolvedCostHistory.enabled) return this.sessionUsage;
786
+ return this.costSessionHistory.reduce(
787
+ (total, record) => addUsage(total, record.usage),
788
+ emptyUsage(),
789
+ );
790
+ }
791
+
792
+ /** Lifetime USD across every project in the history scope. */
793
+ @volatile
794
+ get cumulativeCostUsd(): number {
795
+ return this.cumulativeUsage.costUsd;
796
+ }
797
+
798
+ /**
799
+ * Whether the live session figures amount to this project's whole history, which is true
800
+ * exactly when the transcript is restored on load. Drives the Usage tab's live labels
801
+ * ("Project cost" vs "Session cost") so they state the scope rather than imply one.
802
+ *
803
+ * Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
804
+ * (see `CostSessionRecord.banked`) and are lifetime either way.
805
+ */
806
+ @volatile
807
+ get liveUsageIsProjectLifetime(): boolean {
808
+ return this.persistenceActive;
709
809
  }
710
810
 
711
811
  /** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
@@ -715,15 +815,15 @@ export class FoundationAiAssistant extends GenesisElement {
715
815
  return resolveCostHistoryConfig(this.chatConfig);
716
816
  }
717
817
 
718
- /** Cumulative input + output tokens across every chat turn in this session. */
818
+ /**
819
+ * Total tokens across every chat turn in this session — all four buckets added up.
820
+ * Read-only; the per-bucket split is on `sessionUsage`.
821
+ */
719
822
  get sessionTokensConsumed(): number {
720
- return this._sessionRef?.store.aiAssistant.sessionTokensConsumed ?? 0;
721
- }
722
- set sessionTokensConsumed(value: number) {
723
- this._sessionRef?.actions.aiAssistant.setSessionTokensConsumed(value);
823
+ return totalTokens(this.sessionUsage);
724
824
  }
725
825
 
726
- /** Label shown on the Cost tab for the in-flight session (app title or first user phrase). */
826
+ /** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
727
827
  @volatile
728
828
  get liveCostSessionTitle(): string | undefined {
729
829
  void this.messages;
@@ -734,15 +834,34 @@ export class FoundationAiAssistant extends GenesisElement {
734
834
  }
735
835
 
736
836
  /**
737
- * Snapshot the running session into persisted build history (`localStorage`).
738
- * No-op unless `chatConfig.costHistory.enabled` is `true`.
837
+ * Write this session's usage into persisted build history (`localStorage`) as a single
838
+ * row keyed by the session key, replacing any existing row for that key.
839
+ *
840
+ * **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
841
+ * The name is historical: it does NOT end the session or reset the running totals.
842
+ * Resetting them is what used to double-count spend, since the row was banked while the
843
+ * persisted transcript still carried the same usage, so a reload restored it and the
844
+ * Usage tab counted both.
845
+ *
846
+ * No-op unless `chatConfig.costHistory.enabled` is `true`, when the live session has no
847
+ * usage yet, or when the session has no key to upsert against. Gating on the *live*
848
+ * figures (not the banked total) means a reload that restores nothing rewrites nothing.
739
849
  */
740
850
  finalizeCostSession(options?: { title?: string }): void {
741
851
  if (!this.resolvedCostHistory.enabled) return;
742
- if (this._costSessionFinalizedForInstance) return;
743
- const costUsd = this.sessionCostUsd;
744
- const tokensConsumed = this.sessionTokensConsumed;
745
- if (costUsd <= 0 && tokensConsumed <= 0) return;
852
+ // Wait for the rows before writing: a provider read is async, and banking from a
853
+ // not-yet-loaded ledger would read as "nothing came before" and rewrite this project's
854
+ // row down to the current transcript. Skipping is free — this is called on every
855
+ // transcript change, so the next one writes.
856
+ if (!this._costHistoryLoaded) return;
857
+ const live = this.sessionUsage;
858
+ const tokensConsumed = totalTokens(live);
859
+ if (live.costUsd <= 0 && tokensConsumed <= 0) return;
860
+ // The session key identifies the work — a project id, for hosts that key sessions
861
+ // per project. Without one there is nothing stable to upsert against, so skip
862
+ // rather than write a row that can never be matched again.
863
+ const projectKey = this.getStateKey();
864
+ if (!projectKey) return;
746
865
 
747
866
  const title = resolveCostSessionTitle({
748
867
  explicit: options?.title,
@@ -750,18 +869,63 @@ export class FoundationAiAssistant extends GenesisElement {
750
869
  messages: this.messages,
751
870
  headerTitle: this.headerTitle,
752
871
  });
872
+ // Lifetime = what came before this transcript + what this transcript proves. Both
873
+ // terms are derived from state, never incremented, so the two element instances that
874
+ // share a session write the same row rather than each adding their own contribution.
875
+ const banked = this.bankedUsageFor(projectKey);
753
876
  const record: CostSessionRecord = {
754
- id: crypto.randomUUID(),
877
+ // Stable per project so repeat upserts replace the row instead of stacking, and
878
+ // so the delete action keeps targeting the same row across reloads.
879
+ id: projectKey,
880
+ projectKey,
755
881
  title,
756
- endedAt: new Date().toISOString(),
757
- costUsd,
758
- tokensConsumed,
882
+ updatedAt: new Date().toISOString(),
883
+ usage: addUsage(banked, live),
884
+ banked,
759
885
  models: collectSessionModels(this.messages, this.providerStatuses),
760
886
  };
761
- this.costSessionHistory = appendCostSessionRecord(this.getCostHistoryScope(), record);
762
- this._costSessionFinalizedForInstance = true;
763
- this.sessionCostUsd = 0;
764
- this.sessionTokensConsumed = 0;
887
+ this.persistCostHistory(upsertRecord(this.costSessionHistory, record));
888
+ // NOTE: the live counters are deliberately NOT reset here. Resetting was what
889
+ // double-counted spend — the row was banked while the persisted transcript still
890
+ // carried the same usage, so a reload restored it and the Usage tab added both.
891
+ // The row is a projection of the session totals, not a transfer out of them.
892
+ }
893
+
894
+ /**
895
+ * Spend for `projectKey` that predates the current transcript, resolved once per project
896
+ * and memoised (`_bankedFor`) so it cannot drift mid-session as the row it came from is
897
+ * rewritten by our own upserts.
898
+ *
899
+ * The two cases turn on whether the transcript is authoritative for this project:
900
+ *
901
+ * - **Transcript restored** (`persistenceActive`): it already accounts for everything after
902
+ * the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
903
+ * `usage` here would add the restored history to itself on every reload.
904
+ * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
905
+ * whole of it becomes banked. Without this the next turn would rewrite the row downwards
906
+ * to just that turn, losing the project's earlier spend.
907
+ */
908
+ private bankedUsageFor(projectKey: string): AggregateUsage {
909
+ if (this._bankedFor === projectKey) return this._bankedUsage;
910
+ const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
911
+ // `persistenceActive` is what decides whether the transcript speaks for the project's
912
+ // history: it is on only when a provider is wired AND the user is remembering the chat.
913
+ this._bankedUsage = resolveBankedUsage(existing, this.persistenceActive);
914
+ this._bankedFor = projectKey;
915
+ return this._bankedUsage;
916
+ }
917
+
918
+ /**
919
+ * Discard the banked figure — for when the spend it stands for has been deliberately
920
+ * thrown away (the user clearing history, or deleting this project's row).
921
+ *
922
+ * NOT for reloads. A reload re-reads rows that already include our own upserts, so
923
+ * re-deriving from them would fold the live session into its own baseline; only a genuine
924
+ * project change should re-derive, which `bankedUsageFor`'s per-project memo handles.
925
+ */
926
+ private resetBankedUsage(): void {
927
+ this._bankedFor = undefined;
928
+ this._bankedUsage = emptyUsage();
765
929
  }
766
930
 
767
931
  showConfirmationDialog(
@@ -786,11 +950,30 @@ export class FoundationAiAssistant extends GenesisElement {
786
950
 
787
951
  /** Remove one entry from persisted build history. */
788
952
  removeCostSessionFromHistory(id: string): void {
789
- this.costSessionHistory = removeCostSessionRecord(this.getCostHistoryScope(), id);
953
+ // Deleting the active project's row discards its banked spend by intent, so drop the
954
+ // memo too — otherwise the next turn would rewrite the row we just removed, restoring
955
+ // the total the user asked to be rid of.
956
+ //
957
+ // Resolve the row first: `id` is a row id while `_bankedFor` is a projectKey. They match
958
+ // only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a row from
959
+ // a host provider can carry any id, and comparing across the two namespaces would silently
960
+ // skip the reset.
961
+ const removed = this.costSessionHistory.find((record) => record.id === id);
962
+ if (removed && this._bankedFor === removed.projectKey) this.resetBankedUsage();
963
+ this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
790
964
  }
791
965
 
792
966
  /** Clear all persisted build history for this assistant scope. */
793
967
  clearCostSessionHistory(): void {
968
+ // Every row is gone, so nothing is banked any more — without this the active project's
969
+ // next turn would resurrect its pre-clear total.
970
+ this.resetBankedUsage();
971
+ // Provider-backed: write an empty ledger through. The `localStorage` path instead
972
+ // removes its key outright rather than leaving an empty array behind.
973
+ if (this.costHistoryProvider()) {
974
+ this.persistCostHistory([]);
975
+ return;
976
+ }
794
977
  clearCostSessionHistory(this.getCostHistoryScope());
795
978
  this.costSessionHistory = [];
796
979
  }
@@ -803,23 +986,134 @@ export class FoundationAiAssistant extends GenesisElement {
803
986
  }
804
987
 
805
988
  private reloadCostSessionHistory(): void {
989
+ // NOTE: deliberately does NOT invalidate the banked figure. This runs again whenever
990
+ // `chatConfig` rebinds — hosts commonly build it in a template expression, so a new
991
+ // object arrives mid-session — and by then the rows include our own upserts, whose
992
+ // `usage` already contains the live transcript. Re-deriving from those would add the
993
+ // current session to itself. `bankedUsageFor` keys its memo on the project instead,
994
+ // which handles a genuine session switch without trusting reload timing.
995
+ this._costHistoryLoaded = false;
996
+ this._ledgerUnreadable = false;
806
997
  if (!this.resolvedCostHistory.enabled) {
807
998
  this.costSessionHistory = [];
999
+ this._costHistoryLoaded = true;
1000
+ return;
1001
+ }
1002
+ const scope = this.getCostHistoryScope();
1003
+ const provider = this.costHistoryProvider();
1004
+ if (!provider) {
1005
+ this.costSessionHistory = loadCostSessionHistory(scope);
1006
+ this._costHistoryLoaded = true;
808
1007
  return;
809
1008
  }
810
- this.costSessionHistory = loadCostSessionHistory(this.getCostHistoryScope());
1009
+ // Provider reads are async, so the Usage tab renders an empty list for a tick before
1010
+ // this lands. Seeded empty rather than left stale so a scope change can't briefly show
1011
+ // the previous scope's rows.
1012
+ this.costSessionHistory = [];
1013
+ void provider.loadCostHistory!(scope)
1014
+ .then((records) => {
1015
+ // Guard against a scope change while the load was in flight — a late result must
1016
+ // not overwrite the rows of whatever scope is current now.
1017
+ if (this.getCostHistoryScope() === scope) {
1018
+ // Filtered exactly as the localStorage path is. A host's rows are as capable of
1019
+ // being malformed as a hand-edited storage entry, and an unchecked one poisons
1020
+ // every total with NaN or throws in the sort during render.
1021
+ this.costSessionHistory = (records ?? []).filter(isCostSessionRecord);
1022
+ this._costHistoryLoaded = true;
1023
+ }
1024
+ })
1025
+ .catch((e) => {
1026
+ logger.warn('Cost history unreadable — recording locally only this page-load', e);
1027
+ // Writes stay blocked, and `persistCostHistory` will not save even if something else
1028
+ // calls it. `saveCostHistory` REPLACES the stored list, so writing an upsert against
1029
+ // the empty list we fell back to would delete every other project's row and reset
1030
+ // this project's banked total — destroying data we simply failed to read. The row
1031
+ // rebuilds from the transcript on the next successful load.
1032
+ this._ledgerUnreadable = true;
1033
+ });
1034
+ }
1035
+
1036
+ /**
1037
+ * The persistence provider, but only when it implements BOTH cost-history methods.
1038
+ *
1039
+ * Both or neither: a provider that can save but not load would silently accumulate a
1040
+ * ledger nobody ever reads back, so a partial implementation is treated as absent and
1041
+ * falls back to `localStorage`.
1042
+ */
1043
+ private costHistoryProvider() {
1044
+ const provider = this.persistence.provider;
1045
+ return provider?.loadCostHistory && provider.saveCostHistory ? provider : undefined;
811
1046
  }
812
1047
 
813
- /** Finalize the previous session when the host-provided title changes. */
1048
+ /**
1049
+ * Adopt `records` as the live list, and schedule a write to whichever backend owns cost
1050
+ * history — the provider when the host implements it, else `localStorage`.
1051
+ *
1052
+ * The list is adopted synchronously because that is what the Usage tab renders; only the
1053
+ * *write* is deferred. This runs on every usage change (about once per LLM call, plus once
1054
+ * per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
1055
+ * per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
1056
+ * because the write is a full replace of idempotent state: the newest call wins and no
1057
+ * intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
1058
+ */
1059
+ private persistCostHistory(records: CostSessionRecord[]): void {
1060
+ this.costSessionHistory = records;
1061
+ // A ledger we could not read must never be written back — see reloadCostSessionHistory.
1062
+ // Guarded here rather than only at the finalize gate so it also covers the row-removal and
1063
+ // clear paths, which write directly.
1064
+ if (this._ledgerUnreadable) return;
1065
+ this._ledgerDirty = true;
1066
+ if (this._ledgerFlushTimer !== undefined) clearTimeout(this._ledgerFlushTimer);
1067
+ this._ledgerFlushTimer = setTimeout(() => this.flushCostHistory(), LEDGER_FLUSH_DEBOUNCE_MS);
1068
+ }
1069
+
1070
+ /**
1071
+ * Write the pending ledger now. Safe to call when nothing is pending.
1072
+ *
1073
+ * Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
1074
+ * conversation content, so a failed write is logged and never surfaced into the chat. On
1075
+ * `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
1076
+ * the same unload race as every other network save the assistant makes.
1077
+ */
1078
+ private flushCostHistory(): void {
1079
+ if (this._ledgerFlushTimer !== undefined) {
1080
+ clearTimeout(this._ledgerFlushTimer);
1081
+ this._ledgerFlushTimer = undefined;
1082
+ }
1083
+ if (!this._ledgerDirty || this._ledgerUnreadable) return;
1084
+ this._ledgerDirty = false;
1085
+ const records = this.costSessionHistory;
1086
+ const scope = this.getCostHistoryScope();
1087
+ const provider = this.costHistoryProvider();
1088
+ if (!provider) {
1089
+ saveCostSessionHistory(scope, records);
1090
+ return;
1091
+ }
1092
+ void provider.saveCostHistory!(scope, records).catch((e) => {
1093
+ logger.warn('Cost history save failed', e);
1094
+ });
1095
+ }
1096
+
1097
+ /**
1098
+ * Finalize the previous session when the host-provided title changes — a rename should land on
1099
+ * the row under its old name rather than silently relabelling accrued spend.
1100
+ *
1101
+ * Only when the session key still matches the one that title accrued under. Hosts change
1102
+ * `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
1103
+ * upsert the outgoing project's live usage onto the incoming project's row as
1104
+ * `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
1105
+ * row; with the upsert it overwrites a real one.
1106
+ */
814
1107
  private syncActiveCostSessionTitle(): void {
815
1108
  const previous = this._activeCostSessionTitle;
816
1109
  const next = this.resolvedCostHistory.title?.trim() ?? '';
817
- if (previous && previous !== next) {
1110
+ const key = this.getStateKey();
1111
+ if (previous && previous !== next && this._activeCostSessionKey === key) {
818
1112
  this.finalizeCostSession({ title: previous });
819
1113
  }
820
1114
  if (previous !== next) {
821
1115
  this._activeCostSessionTitle = next;
822
- this._costSessionFinalizedForInstance = false;
1116
+ this._activeCostSessionKey = key;
823
1117
  }
824
1118
  }
825
1119
 
@@ -884,7 +1178,7 @@ export class FoundationAiAssistant extends GenesisElement {
884
1178
  @observable settingsAppSlotted: HTMLElement[] = [];
885
1179
  @observable settingsCostCurrentSlotted: HTMLElement[] = [];
886
1180
  @observable settingsCostHistorySlotted: HTMLElement[] = [];
887
- /** Persisted finalized cost sessions shown on the Cost tab build history list. */
1181
+ /** Persisted finalized cost sessions shown on the Usage tab build history list. */
888
1182
  @observable costSessionHistory: CostSessionRecord[] = [];
889
1183
  /** Whether the splash overlay is currently showing (no messages and showSplash is enabled). Reflected as a boolean attribute on the host. */
890
1184
  @observable showingSplash = false;
@@ -899,13 +1193,39 @@ export class FoundationAiAssistant extends GenesisElement {
899
1193
  private unsubAppSettingsProvider?: () => void;
900
1194
  /** Title tracked for the in-flight cost session (used when `header-title` changes). */
901
1195
  private _activeCostSessionTitle = '';
902
- /** Prevents double-finalize for the same session instance (pagehide + title change). */
903
- private _costSessionFinalizedForInstance = false;
1196
+ /**
1197
+ * Session key `_activeCostSessionTitle` was captured under, so a title change can tell a rename
1198
+ * (same key) from a project switch (key already flipped) — see `syncActiveCostSessionTitle`.
1199
+ */
1200
+ private _activeCostSessionKey?: string;
1201
+ /** Memoised pre-transcript spend for `_bankedFor` — see `bankedUsageFor`. */
1202
+ private _bankedUsage: AggregateUsage = emptyUsage();
1203
+ /** Project key `_bankedUsage` was resolved for; `undefined` means "not yet resolved". */
1204
+ private _bankedFor: string | undefined;
1205
+ /**
1206
+ * Whether the row list reflects storage yet. Gates cost writes so a project's banked
1207
+ * spend is never derived from a ledger that simply hasn't arrived.
1208
+ */
1209
+ private _costHistoryLoaded = false;
1210
+ /**
1211
+ * Set when a provider read failed. Latches writes off for this page-load: `saveCostHistory`
1212
+ * replaces the whole stored list, so writing anything derived from the empty list we fell back
1213
+ * to would destroy rows we never managed to read.
1214
+ */
1215
+ private _ledgerUnreadable = false;
1216
+ /** Pending (adopted but unwritten) ledger changes — see `persistCostHistory`. */
1217
+ private _ledgerDirty = false;
1218
+ private _ledgerFlushTimer?: ReturnType<typeof setTimeout>;
1219
+ // No once-guard for cost-session writes: the write is an upsert keyed by the session
1220
+ // key, so repeat calls (pagehide, title change, per-turn) replace the same row and are
1221
+ // idempotent. The old flag existed only because the write appended and reset.
904
1222
  // Single-view model (GENC-1351 Option A): the one assistant element always finalizes
905
1223
  // the cost session on tab close — there is no hidden twin to defer to, so this fires
906
1224
  // unconditionally (the old bubble-vs-docked gating flag is gone with the two-view model).
907
1225
  private _handlePageHide = (): void => {
908
1226
  this.finalizeCostSession();
1227
+ // Straight to storage: the debounce timer will never fire once the page is going away.
1228
+ this.flushCostHistory();
909
1229
  };
910
1230
  private haloStartPublished = false;
911
1231
  /** Fingerprint of the agents array used to build the current driver. Used by agentsChanged to skip spurious rebuilds. */
@@ -1712,6 +2032,9 @@ export class FoundationAiAssistant extends GenesisElement {
1712
2032
 
1713
2033
  disconnectedCallback() {
1714
2034
  window.removeEventListener('pagehide', this._handlePageHide);
2035
+ // Write out anything the debounce is still holding — the timer would otherwise fire against
2036
+ // a torn-down element, or not at all.
2037
+ this.flushCostHistory();
1715
2038
  super.disconnectedCallback();
1716
2039
  this._sessionActive = false;
1717
2040
  this.stopLoadingTimer();
@@ -1867,18 +2190,26 @@ export class FoundationAiAssistant extends GenesisElement {
1867
2190
  break;
1868
2191
  }
1869
2192
  }
1870
- // Recompute aggregated session cost from per-message `cost` fields. Recomputing
1871
- // (rather than incrementing on append) keeps the total correct under any
1872
- // mutation of the message list — including future clear-chat / re-render flows.
1873
- // `sumCosts` recurses into `toolCall.subAgentTrace` so cost incurred by
1874
- // sub-agents (potentially on different providers) is included.
1875
- const runningCost = sumCosts(this.messages);
1876
- if (runningCost !== this.sessionCostUsd) {
1877
- this.sessionCostUsd = runningCost;
1878
- }
1879
- const runningTokens = sumTokens(this.messages);
1880
- if (runningTokens !== this.sessionTokensConsumed) {
1881
- this.sessionTokensConsumed = runningTokens;
2193
+ // Recompute cost and all four token buckets from the transcript in one walk.
2194
+ // Recomputing (rather than incrementing on append) keeps the totals correct under
2195
+ // any mutation of the message list — clear-chat, re-render, restore. `sumUsage`
2196
+ // recurses into `toolCall.subAgentTrace`, so work a sub-agent did (possibly on
2197
+ // another provider at other rates) counts, and it reads the usage a compaction
2198
+ // banked onto its summary, so shrinking history no longer shrinks the totals.
2199
+ const running = sumUsage(this.messages);
2200
+ const current = this.sessionUsage;
2201
+ if (
2202
+ running.costUsd !== current.costUsd ||
2203
+ running.uncachedInputTokens !== current.uncachedInputTokens ||
2204
+ running.cacheReadTokens !== current.cacheReadTokens ||
2205
+ running.cacheWriteTokens !== current.cacheWriteTokens ||
2206
+ running.outputTokens !== current.outputTokens
2207
+ ) {
2208
+ this.sessionUsage = running;
2209
+ // Keep this project's history row current as spend accrues, which is what lets
2210
+ // the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
2211
+ // by the session key), so calling it per change is safe.
2212
+ this.finalizeCostSession();
1882
2213
  }
1883
2214
  // Record a context.updated meta event when the token count changes (≈once
1884
2215
  // per LLM call, as a new usage-bearing message arrives), plus a one-shot
@@ -2357,12 +2688,6 @@ export class FoundationAiAssistant extends GenesisElement {
2357
2688
  return this.settingsAppSlotted.length > 0 || this.visibleAppSettingsToggles.length > 0;
2358
2689
  }
2359
2690
 
2360
- /** Section title for the app settings block. */
2361
- @volatile
2362
- get appSettingsSectionTitle(): string {
2363
- return this.appSettingsProvider.sectionTitle ?? 'UI Builder Settings';
2364
- }
2365
-
2366
2691
  /** App toggles currently visible per each entry's `visible()` predicate. */
2367
2692
  @volatile
2368
2693
  get visibleAppSettingsToggles(): AssistantAppSettingsToggle[] {
@@ -2370,6 +2695,18 @@ export class FoundationAiAssistant extends GenesisElement {
2370
2695
  return this.appSettingsProvider.toggles.filter((toggle) => toggle.visible?.() !== false);
2371
2696
  }
2372
2697
 
2698
+ /**
2699
+ * Host-supplied heading for the toggle group, or `undefined` for none — the assistant has no
2700
+ * default. Suppressed when no toggle is currently visible, so a `visible()` predicate hiding
2701
+ * the last one takes its heading with it rather than leaving a label over nothing.
2702
+ */
2703
+ @volatile
2704
+ get appSettingsTogglesHeading(): AssistantAppSettingsHeading | undefined {
2705
+ void this.appSettingsRevision;
2706
+ if (this.visibleAppSettingsToggles.length === 0) return undefined;
2707
+ return this.appSettingsProvider.togglesHeading;
2708
+ }
2709
+
2373
2710
  getAppSettingValue(id: string): boolean {
2374
2711
  void this.appSettingsRevision;
2375
2712
  return this.appSettingsProvider.getValue(id);
@@ -2403,7 +2740,7 @@ export class FoundationAiAssistant extends GenesisElement {
2403
2740
  return this.costSessionHistory.length > 0 || this.settingsCostHistorySlotted.length > 0;
2404
2741
  }
2405
2742
 
2406
- /** Whether the built-in Cost tab summary block has anything to show. */
2743
+ /** Whether the built-in Usage tab summary block has anything to show. */
2407
2744
  @volatile
2408
2745
  get settingsCostSummaryVisible(): boolean {
2409
2746
  void this.chatConfig;
@@ -2416,7 +2753,31 @@ export class FoundationAiAssistant extends GenesisElement {
2416
2753
  return !!this.liveCostSessionTitle;
2417
2754
  }
2418
2755
 
2419
- /** Whether the Cost tab should appear in the settings modal. */
2756
+ /**
2757
+ * Whether the lifetime-usage section has anything to say.
2758
+ *
2759
+ * Requires build history: with it off there are no rows, so `cumulativeUsage` falls back to
2760
+ * the live session and the section would restate the block directly below it under a
2761
+ * "lifetime" heading it hasn't earned.
2762
+ */
2763
+ @volatile
2764
+ get settingsCostLifetimeVisible(): boolean {
2765
+ void this.chatConfig;
2766
+ void this._sessionRef;
2767
+ const cost = this.resolvedCostHistory;
2768
+ if (!cost.enabled || !(cost.showCost || cost.showTokens)) return false;
2769
+ // Also requires a state key, because `finalizeCostSession` skips the write without one and the
2770
+ // lifetime figures sum rows only — so the section would show a permanent $0.00 directly above
2771
+ // a live block reporting real spend.
2772
+ //
2773
+ // Narrow in practice: `getStateKey` falls back to `id::headerTitle`, and `headerTitle` has a
2774
+ // non-empty default, so a key is almost always derivable even with no `session-key`. This
2775
+ // catches the remaining case — a host that supplies no session key, no element id, AND blanks
2776
+ // the header title. Cheap, and the alternative is a figure that is simply wrong.
2777
+ return !!this.getStateKey();
2778
+ }
2779
+
2780
+ /** Whether the Usage tab should appear in the settings modal. */
2420
2781
  @volatile
2421
2782
  get settingsCostTabVisible(): boolean {
2422
2783
  void this.chatConfig;
@@ -2429,7 +2790,7 @@ export class FoundationAiAssistant extends GenesisElement {
2429
2790
  );
2430
2791
  }
2431
2792
 
2432
- /** Whether session token usage should render on the Cost tab. */
2793
+ /** Whether session token usage should render on the Usage tab. */
2433
2794
  @volatile
2434
2795
  get settingsSessionTokensVisible(): boolean {
2435
2796
  void this.chatConfig;
@@ -2752,6 +3113,10 @@ export class FoundationAiAssistant extends GenesisElement {
2752
3113
  contextUsagePercent,
2753
3114
  sessionCostUsd: this.sessionCostUsd,
2754
3115
  sessionTokensConsumed: this.sessionTokensConsumed,
3116
+ // The per-bucket split alongside the total. Without it a log shows a large
3117
+ // token count next to a small cost with no way to tell why — the answer is
3118
+ // almost always that most of the prompt was a cache hit.
3119
+ sessionUsage: this.sessionUsage,
2755
3120
  },
2756
3121
  // Snapshot captured fresh at log-export time — reflects state NOW, which
2757
3122
  // may have transitioned since the last LLM call.