@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
@@ -1,4 +1,5 @@
1
1
  import { AgentPickerMode } from '@genesislcap/foundation-ai';
2
+ import type { AggregateUsage } from '@genesislcap/foundation-ai';
2
3
  import { AIProviderRegistry } from '@genesislcap/foundation-ai';
3
4
  import type { AIProviderRegistryStatusEntry } from '@genesislcap/foundation-ai';
4
5
  import type { AIProviderType } from '@genesislcap/foundation-ai';
@@ -12,6 +13,7 @@ import type { ChatMessage } from '@genesislcap/foundation-ai';
12
13
  import type { ChatToolChoice } from '@genesislcap/foundation-ai';
13
14
  import type { ChatToolDefinition } from '@genesislcap/foundation-ai';
14
15
  import type { ChatToolHandlers } from '@genesislcap/foundation-ai';
16
+ import { ElementStyles } from '@genesislcap/web-core';
15
17
  import { GenesisElement } from '@genesislcap/web-core';
16
18
  import type { InteractionRequestOptions } from '@genesislcap/foundation-ai';
17
19
  import type { InteractionResult } from '@genesislcap/foundation-ai';
@@ -726,16 +728,42 @@ export declare const ANIMATION_DEFS: {
726
728
  export declare function assembleDebugLog(entries: DiagnosticEntry[], readme: readonly string[]): DebugLog;
727
729
 
728
730
  /**
729
- * App-supplied settings for the platform settings modal UI Builder section.
730
- * Register a concrete implementation on the DI token at app bootstrap; the
731
- * assistant renders toggles with the same row layout as AI Chat Bot settings.
731
+ * Optional heading above a provider's toggle group.
732
+ *
733
+ * Entirely host-supplied the assistant has no default title and no default icon, because the
734
+ * section belongs to the host. Omit it and the toggles render bare, which is right when the
735
+ * host's slotted content already titles them.
736
+ *
737
+ * @beta
738
+ */
739
+ export declare interface AssistantAppSettingsHeading {
740
+ readonly title: string;
741
+ /**
742
+ * Icon name for the design system's icon element (a Font Awesome free name, e.g.
743
+ * `'object-group'`). Omitted renders text only; an unknown name renders nothing at all, so
744
+ * check it against the installed Font Awesome set.
745
+ */
746
+ readonly icon?: string;
747
+ }
748
+
749
+ /**
750
+ * App-supplied settings for the host's own section of the settings modal. Register a concrete
751
+ * implementation on the DI token at app bootstrap; the assistant renders the toggles with the
752
+ * same row layout as AI Chat Bot settings.
753
+ *
754
+ * The section itself is untitled by the assistant — no heading text and no icon — because only
755
+ * the host knows what it is. Title it from the `settings-app` slot, which renders above these
756
+ * toggles, and apply `assistantSettingsSectionTitleStyles` to match the built-in headings.
732
757
  *
733
758
  * @beta
734
759
  */
735
760
  export declare interface AssistantAppSettingsProvider {
736
- /** Section heading. Default: `"UI Builder Settings"`. */
737
- readonly sectionTitle?: string;
738
761
  readonly toggles: readonly AssistantAppSettingsToggle[];
762
+ /**
763
+ * Heading for the toggle group, for hosts that put other controls in the `settings-app` slot
764
+ * and need these distinguished from them. No default: absent means no heading.
765
+ */
766
+ readonly togglesHeading?: AssistantAppSettingsHeading;
739
767
  getValue(id: string): boolean;
740
768
  setValue(id: string, value: boolean): void;
741
769
  /** Notify the assistant to re-read toggle values / visibility. */
@@ -770,6 +798,34 @@ export declare interface AssistantAppSettingsToggle {
770
798
  hint?: () => string | undefined;
771
799
  }
772
800
 
801
+ /**
802
+ * Heading style for a settings-modal section, published so a host can title its own section.
803
+ *
804
+ * The assistant renders no heading for the `settings-app` slot — no text and no icon — because
805
+ * only the host knows what that section is. The consequence is that a host supplying one has to
806
+ * reproduce the styling of the built-in headings beside it, and a copied rule drifts the moment
807
+ * either side changes. So the declarations live here, are applied by the assistant's own
808
+ * headings, and are exported for the host to apply to its.
809
+ *
810
+ * Compose it into a component's styles and put `ai-settings-section-title` on the heading:
811
+ *
812
+ * ```ts
813
+ * import { assistantSettingsSectionTitleStyles } from '@genesislcap/ai-assistant';
814
+ *
815
+ * const styles = css`
816
+ * ${assistantSettingsSectionTitleStyles}
817
+ * :host { display: block; }
818
+ * `;
819
+ * ```
820
+ *
821
+ * Carries no margin on purpose: spacing belongs to the surrounding layout, which differs
822
+ * between the assistant's own sections and a slotted one, and is the single declaration hosts
823
+ * had to diverge on when they copied this rule.
824
+ *
825
+ * @beta
826
+ */
827
+ export declare const assistantSettingsSectionTitleStyles: ElementStyles;
828
+
773
829
  declare interface BaseAgentConfig {
774
830
  /**
775
831
  * Stable identity for this agent. Used for classifier routing, manual
@@ -1698,21 +1754,58 @@ declare type ChatInteractionEventsMap = {
1698
1754
 
1699
1755
  export { ChatToolChoice }
1700
1756
 
1701
- /** A model used during a finalized cost session. */
1757
+ /** A model used during a recorded cost session. */
1702
1758
  export declare interface CostSessionModelEntry {
1703
1759
  model: string;
1704
1760
  /** Vendor id from `getStatus()` (e.g. `'anthropic'`, `'gemini'`). */
1705
1761
  provider?: AIProviderType;
1706
1762
  }
1707
1763
 
1708
- /** Persisted snapshot of a completed assistant cost session. */
1764
+ /** Persisted usage record for one project's assistant work. */
1709
1765
  export declare interface CostSessionRecord {
1710
1766
  id: string;
1767
+ /**
1768
+ * Stable identity of the work this row stands for — the assistant's session key
1769
+ * (a project id, for hosts that key sessions per project). The upsert key: a row
1770
+ * is one project's lifetime usage, so re-reporting the same key REPLACES the row
1771
+ * rather than adding another.
1772
+ */
1773
+ projectKey: string;
1711
1774
  title: string;
1712
- /** ISO-8601 timestamp when the session was finalized. */
1713
- endedAt: string;
1714
- costUsd: number;
1715
- tokensConsumed: number;
1775
+ /**
1776
+ * ISO-8601 timestamp of the most recent usage recorded for this row.
1777
+ *
1778
+ * Named for what it is: the row is upserted as spend accrues, so this moves. It is
1779
+ * NOT an end time — a row has no terminal state, because a project can always be
1780
+ * worked on again.
1781
+ */
1782
+ updatedAt: string;
1783
+ /**
1784
+ * Cost plus the four token buckets — the same shape the live session tile renders, so
1785
+ * a history row can show the identical breakdown instead of a lone merged total that
1786
+ * cannot explain a large token count at a small cost.
1787
+ *
1788
+ * This is the project's **lifetime** usage: `banked` plus whatever the live transcript
1789
+ * currently proves.
1790
+ */
1791
+ usage: AggregateUsage;
1792
+ /**
1793
+ * Spend that predates the current transcript, and so cannot be re-derived from it.
1794
+ *
1795
+ * Without this a row could only ever report what the open transcript proves. That is
1796
+ * fine when the transcript is restored on load (it *is* the project's history), but
1797
+ * where it starts empty every refresh the row would be rewritten downwards on the next
1798
+ * turn and the project's earlier spend lost. Banking it keeps `usage` a lifetime figure
1799
+ * in both cases.
1800
+ *
1801
+ * Deliberately part of the row rather than element state: two assistant instances (a
1802
+ * docked bubble and a popped-out panel) share one session, and both write this row. A
1803
+ * value derived from the row keeps every write idempotent, whereas an element-held
1804
+ * accumulator would be applied once per instance.
1805
+ *
1806
+ * Optional: rows written before this existed have none, which reads as "nothing banked".
1807
+ */
1808
+ banked?: AggregateUsage;
1716
1809
  models: CostSessionModelEntry[];
1717
1810
  }
1718
1811
 
@@ -1905,10 +1998,13 @@ export declare interface FallbackAgentConfig extends BaseAgentConfig {
1905
1998
  *
1906
1999
  * Popout/collapse coordination uses `agenticActivityBus` topics `chat-popout` and `chat-popin` — not DOM `CustomEvent`s on this element.
1907
2000
  *
1908
- * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled,
1909
- * finalized sessions persist in `localStorage` (scoped by `costHistory.scope`, the
1910
- * element `id`, or `'default'`). Snapshots run on tab close and when the session
1911
- * title changes; hosts may call {@link FoundationAiAssistant.finalizeCostSession}.
2001
+ * **Cost build history:** opt-in via `chatConfig.costHistory.enabled`. When enabled, each
2002
+ * session's usage is kept as exactly ONE row per session key in `localStorage` (scoped by
2003
+ * `costHistory.scope`, the element `id`, or `'default'`), upserted as spend accrues. So a
2004
+ * row is a project's lifetime usage rather than a visit to it, and the Usage tab's total is
2005
+ * the sum of the rows alone — the live session is never added on top, because the active
2006
+ * project's own row already reflects it. Hosts may call
2007
+ * {@link FoundationAiAssistant.finalizeCostSession} to flush early; it is idempotent.
1912
2008
  *
1913
2009
  * @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`
1914
2010
  * @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`
@@ -2141,25 +2237,111 @@ export declare class FoundationAiAssistant extends GenesisElement {
2141
2237
  /** Context window size for the active model, if known. */
2142
2238
  get contextLimit(): number | undefined;
2143
2239
  set contextLimit(value: number | undefined);
2144
- /** Aggregated USD cost across every chat turn in this session. */
2240
+ /**
2241
+ * Cost plus the four token buckets for this session — derived from the transcript
2242
+ * on every change, so it includes sub-agent turns and spend banked by a compaction.
2243
+ */
2244
+ get sessionUsage(): AggregateUsage;
2245
+ set sessionUsage(value: AggregateUsage);
2246
+ /**
2247
+ * Aggregated USD cost across every chat turn in this session.
2248
+ *
2249
+ * Read-only (unlike previously): it is a projection of `sessionUsage`, and a
2250
+ * host assigning it would have set a figure the next transcript change overwrites.
2251
+ */
2145
2252
  get sessionCostUsd(): number;
2146
- set sessionCostUsd(value: number);
2147
- /** Saved build history plus the in-flight sessionshown on the Cost tab summary. */
2253
+ /**
2254
+ * Distinct models the registry currently resolves tothe live session's model chips.
2255
+ *
2256
+ * `providerStatuses` carries one entry per registry SLOT, so a vendor configured across several
2257
+ * tiers that happen to resolve to the same model reports it once per tier, and the chips
2258
+ * rendered it once per tier too. Which tier a model came from is not what these chips are for.
2259
+ *
2260
+ * Deduped on the label actually displayed, not on `status.model`: a slot whose provider reports
2261
+ * no model falls back to the slot's own name, and those are distinct per slot, so keying on the
2262
+ * label keeps them from collapsing into one another.
2263
+ */
2264
+ get uniqueProviderModels(): CostSessionModelEntry[];
2265
+ /**
2266
+ * Build history for display: most recently worked on first.
2267
+ *
2268
+ * The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
2269
+ * sitting wherever it was first seen — so ordering is applied here, on read, where it also
2270
+ * covers rows a host provider supplied.
2271
+ */
2272
+ get sortedCostSessionHistory(): CostSessionRecord[];
2273
+ /**
2274
+ * Lifetime usage across every project in the history scope — cost and all four token
2275
+ * buckets. What the Usage tab's lifetime section renders.
2276
+ *
2277
+ * Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
2278
+ * usage and the active project's row is kept current on every transcript change, so
2279
+ * adding the live total would count this project's spend twice. That double-count is what
2280
+ * made the figure grow on every refresh: a page-hide banked a row while the restored
2281
+ * session still carried the same spend.
2282
+ */
2283
+ get cumulativeUsage(): AggregateUsage;
2284
+ /** Lifetime USD across every project in the history scope. */
2148
2285
  get cumulativeCostUsd(): number;
2286
+ /**
2287
+ * Whether the live session figures amount to this project's whole history, which is true
2288
+ * exactly when the transcript is restored on load. Drives the Usage tab's live labels
2289
+ * ("Project cost" vs "Session cost") so they state the scope rather than imply one.
2290
+ *
2291
+ * Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
2292
+ * (see `CostSessionRecord.banked`) and are lifetime either way.
2293
+ */
2294
+ get liveUsageIsProjectLifetime(): boolean;
2149
2295
  /** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
2150
2296
  get resolvedCostHistory(): ResolvedCostHistoryConfig;
2151
- /** Cumulative input + output tokens across every chat turn in this session. */
2297
+ /**
2298
+ * Total tokens across every chat turn in this session — all four buckets added up.
2299
+ * Read-only; the per-bucket split is on `sessionUsage`.
2300
+ */
2152
2301
  get sessionTokensConsumed(): number;
2153
- set sessionTokensConsumed(value: number);
2154
- /** Label shown on the Cost tab for the in-flight session (app title or first user phrase). */
2302
+ /** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
2155
2303
  get liveCostSessionTitle(): string | undefined;
2156
2304
  /**
2157
- * Snapshot the running session into persisted build history (`localStorage`).
2158
- * No-op unless `chatConfig.costHistory.enabled` is `true`.
2305
+ * Write this session's usage into persisted build history (`localStorage`) as a single
2306
+ * row keyed by the session key, replacing any existing row for that key.
2307
+ *
2308
+ * **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
2309
+ * The name is historical: it does NOT end the session or reset the running totals.
2310
+ * Resetting them is what used to double-count spend, since the row was banked while the
2311
+ * persisted transcript still carried the same usage, so a reload restored it and the
2312
+ * Usage tab counted both.
2313
+ *
2314
+ * No-op unless `chatConfig.costHistory.enabled` is `true`, when the live session has no
2315
+ * usage yet, or when the session has no key to upsert against. Gating on the *live*
2316
+ * figures (not the banked total) means a reload that restores nothing rewrites nothing.
2159
2317
  */
2160
2318
  finalizeCostSession(options?: {
2161
2319
  title?: string;
2162
2320
  }): void;
2321
+ /**
2322
+ * Spend for `projectKey` that predates the current transcript, resolved once per project
2323
+ * and memoised (`_bankedFor`) so it cannot drift mid-session as the row it came from is
2324
+ * rewritten by our own upserts.
2325
+ *
2326
+ * The two cases turn on whether the transcript is authoritative for this project:
2327
+ *
2328
+ * - **Transcript restored** (`persistenceActive`): it already accounts for everything after
2329
+ * the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
2330
+ * `usage` here would add the restored history to itself on every reload.
2331
+ * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
2332
+ * whole of it becomes banked. Without this the next turn would rewrite the row downwards
2333
+ * to just that turn, losing the project's earlier spend.
2334
+ */
2335
+ private bankedUsageFor;
2336
+ /**
2337
+ * Discard the banked figure — for when the spend it stands for has been deliberately
2338
+ * thrown away (the user clearing history, or deleting this project's row).
2339
+ *
2340
+ * NOT for reloads. A reload re-reads rows that already include our own upserts, so
2341
+ * re-deriving from them would fold the live session into its own baseline; only a genuine
2342
+ * project change should re-derive, which `bankedUsageFor`'s per-project memo handles.
2343
+ */
2344
+ private resetBankedUsage;
2163
2345
  showConfirmationDialog(confirmationMessage: string, modalParent: HTMLElement, onConfirm: () => void, onCancel?: () => void): void;
2164
2346
  /** Remove one entry from persisted build history. */
2165
2347
  removeCostSessionFromHistory(id: string): void;
@@ -2167,7 +2349,45 @@ export declare class FoundationAiAssistant extends GenesisElement {
2167
2349
  clearCostSessionHistory(): void;
2168
2350
  private getCostHistoryScope;
2169
2351
  private reloadCostSessionHistory;
2170
- /** Finalize the previous session when the host-provided title changes. */
2352
+ /**
2353
+ * The persistence provider, but only when it implements BOTH cost-history methods.
2354
+ *
2355
+ * Both or neither: a provider that can save but not load would silently accumulate a
2356
+ * ledger nobody ever reads back, so a partial implementation is treated as absent and
2357
+ * falls back to `localStorage`.
2358
+ */
2359
+ private costHistoryProvider;
2360
+ /**
2361
+ * Adopt `records` as the live list, and schedule a write to whichever backend owns cost
2362
+ * history — the provider when the host implements it, else `localStorage`.
2363
+ *
2364
+ * The list is adopted synchronously because that is what the Usage tab renders; only the
2365
+ * *write* is deferred. This runs on every usage change (about once per LLM call, plus once
2366
+ * per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
2367
+ * per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
2368
+ * because the write is a full replace of idempotent state: the newest call wins and no
2369
+ * intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
2370
+ */
2371
+ private persistCostHistory;
2372
+ /**
2373
+ * Write the pending ledger now. Safe to call when nothing is pending.
2374
+ *
2375
+ * Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
2376
+ * conversation content, so a failed write is logged and never surfaced into the chat. On
2377
+ * `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
2378
+ * the same unload race as every other network save the assistant makes.
2379
+ */
2380
+ private flushCostHistory;
2381
+ /**
2382
+ * Finalize the previous session when the host-provided title changes — a rename should land on
2383
+ * the row under its old name rather than silently relabelling accrued spend.
2384
+ *
2385
+ * Only when the session key still matches the one that title accrued under. Hosts change
2386
+ * `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
2387
+ * upsert the outgoing project's live usage onto the incoming project's row as
2388
+ * `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
2389
+ * row; with the upsert it overwrites a real one.
2390
+ */
2171
2391
  private syncActiveCostSessionTitle;
2172
2392
  /** Active model id resolved from the AIProvider, if exposed. */
2173
2393
  get activeModel(): string | undefined;
@@ -2208,7 +2428,7 @@ export declare class FoundationAiAssistant extends GenesisElement {
2208
2428
  settingsAppSlotted: HTMLElement[];
2209
2429
  settingsCostCurrentSlotted: HTMLElement[];
2210
2430
  settingsCostHistorySlotted: HTMLElement[];
2211
- /** Persisted finalized cost sessions shown on the Cost tab build history list. */
2431
+ /** Persisted finalized cost sessions shown on the Usage tab build history list. */
2212
2432
  costSessionHistory: CostSessionRecord[];
2213
2433
  /** Whether the splash overlay is currently showing (no messages and showSplash is enabled). Reflected as a boolean attribute on the host. */
2214
2434
  showingSplash: boolean;
@@ -2222,8 +2442,29 @@ export declare class FoundationAiAssistant extends GenesisElement {
2222
2442
  private unsubAppSettingsProvider?;
2223
2443
  /** Title tracked for the in-flight cost session (used when `header-title` changes). */
2224
2444
  private _activeCostSessionTitle;
2225
- /** Prevents double-finalize for the same session instance (pagehide + title change). */
2226
- private _costSessionFinalizedForInstance;
2445
+ /**
2446
+ * Session key `_activeCostSessionTitle` was captured under, so a title change can tell a rename
2447
+ * (same key) from a project switch (key already flipped) — see `syncActiveCostSessionTitle`.
2448
+ */
2449
+ private _activeCostSessionKey?;
2450
+ /** Memoised pre-transcript spend for `_bankedFor` — see `bankedUsageFor`. */
2451
+ private _bankedUsage;
2452
+ /** Project key `_bankedUsage` was resolved for; `undefined` means "not yet resolved". */
2453
+ private _bankedFor;
2454
+ /**
2455
+ * Whether the row list reflects storage yet. Gates cost writes so a project's banked
2456
+ * spend is never derived from a ledger that simply hasn't arrived.
2457
+ */
2458
+ private _costHistoryLoaded;
2459
+ /**
2460
+ * Set when a provider read failed. Latches writes off for this page-load: `saveCostHistory`
2461
+ * replaces the whole stored list, so writing anything derived from the empty list we fell back
2462
+ * to would destroy rows we never managed to read.
2463
+ */
2464
+ private _ledgerUnreadable;
2465
+ /** Pending (adopted but unwritten) ledger changes — see `persistCostHistory`. */
2466
+ private _ledgerDirty;
2467
+ private _ledgerFlushTimer?;
2227
2468
  private _handlePageHide;
2228
2469
  private haloStartPublished;
2229
2470
  /** Fingerprint of the agents array used to build the current driver. Used by agentsChanged to skip spurious rebuilds. */
@@ -2600,21 +2841,33 @@ export declare class FoundationAiAssistant extends GenesisElement {
2600
2841
  get settingsContextUsageVisible(): boolean;
2601
2842
  /** Whether the settings modal UI Builder section should render. */
2602
2843
  get settingsAppSectionVisible(): boolean;
2603
- /** Section title for the app settings block. */
2604
- get appSettingsSectionTitle(): string;
2605
2844
  /** App toggles currently visible per each entry's `visible()` predicate. */
2606
2845
  get visibleAppSettingsToggles(): AssistantAppSettingsToggle[];
2846
+ /**
2847
+ * Host-supplied heading for the toggle group, or `undefined` for none — the assistant has no
2848
+ * default. Suppressed when no toggle is currently visible, so a `visible()` predicate hiding
2849
+ * the last one takes its heading with it rather than leaving a label over nothing.
2850
+ */
2851
+ get appSettingsTogglesHeading(): AssistantAppSettingsHeading | undefined;
2607
2852
  getAppSettingValue(id: string): boolean;
2608
2853
  setAppSettingValue(id: string, value: boolean): void;
2609
2854
  private wireAppSettingsProvider;
2610
2855
  private unwireAppSettingsProvider;
2611
2856
  get settingsCostCurrentSectionVisible(): boolean;
2612
2857
  get settingsCostHistorySectionVisible(): boolean;
2613
- /** Whether the built-in Cost tab summary block has anything to show. */
2858
+ /** Whether the built-in Usage tab summary block has anything to show. */
2614
2859
  get settingsCostSummaryVisible(): boolean;
2615
- /** Whether the Cost tab should appear in the settings modal. */
2860
+ /**
2861
+ * Whether the lifetime-usage section has anything to say.
2862
+ *
2863
+ * Requires build history: with it off there are no rows, so `cumulativeUsage` falls back to
2864
+ * the live session and the section would restate the block directly below it under a
2865
+ * "lifetime" heading it hasn't earned.
2866
+ */
2867
+ get settingsCostLifetimeVisible(): boolean;
2868
+ /** Whether the Usage tab should appear in the settings modal. */
2616
2869
  get settingsCostTabVisible(): boolean;
2617
- /** Whether session token usage should render on the Cost tab. */
2870
+ /** Whether session token usage should render on the Usage tab. */
2618
2871
  get settingsSessionTokensVisible(): boolean;
2619
2872
  /** Whether the chat header (and settings cog) should render. */
2620
2873
  get settingsCogVisible(): boolean;
@@ -3262,12 +3515,18 @@ declare interface ResolvedCostHistoryConfig {
3262
3515
  title?: string;
3263
3516
  /** `localStorage` scope key; falls back to element `id` then `'default'`. */
3264
3517
  scope?: string;
3265
- /** Show running session USD on the Cost tab. Default: `true`. */
3518
+ /** Show running session USD on the Usage tab. Default: `true`. */
3266
3519
  showCost: boolean;
3267
- /** Show cumulative session tokens on the Cost tab. Default: `true`. */
3520
+ /** Show cumulative session tokens on the Usage tab. Default: `true`. */
3268
3521
  showTokens: boolean;
3269
- /** Live-session badge label on the Cost tab summary. */
3522
+ /** Live-session badge label on the Usage tab summary. */
3270
3523
  badgeLabel: string;
3524
+ /**
3525
+ * Host-supplied disclosure line for the Usage tab. Stays optional — unlike
3526
+ * `badgeLabel` there is no default, because only the host knows what its `scope`
3527
+ * spans, and the library must not assert it.
3528
+ */
3529
+ summaryHint?: string;
3271
3530
  }
3272
3531
 
3273
3532
  /**
@@ -3385,6 +3644,28 @@ export declare interface SessionPersistenceProvider {
3385
3644
  loadPreferences?(sessionKey: string): Promise<SessionPreferences | undefined>;
3386
3645
  /** Persist the user's UI preferences for `sessionKey` (kept across `clear`). */
3387
3646
  savePreferences?(sessionKey: string, preferences: SessionPreferences): Promise<void>;
3647
+ /**
3648
+ * Scoped cost/build history — the per-project usage rows behind the Usage tab.
3649
+ *
3650
+ * ‼️ **Keyed by SCOPE, not `sessionKey`**, unlike every other method on this interface.
3651
+ * A row holds one project's lifetime usage and the list spans projects, so keying it
3652
+ * per session would defeat the model. The scope comes from
3653
+ * `chatConfig.costHistory.scope`, falling back to the element `id`, then `'default'`.
3654
+ *
3655
+ * Optional: when a provider omits these, the assistant falls back to `localStorage`, so
3656
+ * hosts that don't implement them are unaffected. Implementing them moves the ledger to
3657
+ * the host's own backend, which is what stops it being stranded in one browser.
3658
+ *
3659
+ * Read/written **independently of the `enabled` toggle** (as with preferences and
3660
+ * diagnostics): usage accounting is not conversation content, so a user turning off
3661
+ * chat retention should not lose their cost ledger. Best-effort — a failed write must
3662
+ * never affect the chat.
3663
+ *
3664
+ * `undefined` from `loadCostHistory` means "nothing stored", the same as `[]`.
3665
+ */
3666
+ loadCostHistory?(scope: string): Promise<CostSessionRecord[] | undefined>;
3667
+ /** Replace the stored row list for `scope`. See {@link SessionPersistenceProvider.loadCostHistory}. */
3668
+ saveCostHistory?(scope: string, records: CostSessionRecord[]): Promise<void>;
3388
3669
  /**
3389
3670
  * Server-saved diagnostics (GENC-1351 §5.8) — a **forward-only append** stream
3390
3671
  * that lets the downloadable debug log span the whole session lifetime, not just
@@ -3428,7 +3709,14 @@ export declare interface SessionPreferences {
3428
3709
  animations?: string[];
3429
3710
  }
3430
3711
 
3431
- /** Settings vs Cost tab identifiers for the modal tab bar. */
3712
+ /**
3713
+ * Tab identifiers for the modal tab bar.
3714
+ *
3715
+ * The values are deliberately NOT renamed to match the visible labels ("Configuration" and
3716
+ * "Usage"): they are the public API that hosts read and write via `settingsModalTab`, and are
3717
+ * matched by the `settings-modal-tab-*` shadow parts. Renaming a label is cosmetic; renaming
3718
+ * these would break consumers.
3719
+ */
3432
3720
  declare type SettingsModalTab = 'settings' | 'cost';
3433
3721
 
3434
3722
  /** Design-system tabs element bound to the settings modal tab bar. */
@@ -1565,6 +1565,8 @@ ${att.content}` });
1565
1565
  var _a, _b, _c, _d, _e, _f, _g, _h;
1566
1566
  let inputTokens;
1567
1567
  let outputTokens;
1568
+ let cacheReadTokens;
1569
+ let cacheWriteTokens;
1568
1570
  let cost;
1569
1571
  if (response.usage) {
1570
1572
  const uncachedInput = (_a = response.usage.input_tokens) !== null && _a !== void 0 ? _a : 0;
@@ -1574,6 +1576,8 @@ ${att.content}` });
1574
1576
  const cacheCreation = (_d = response.usage.cache_creation_input_tokens) !== null && _d !== void 0 ? _d : breakdown ? ((_e = breakdown.ephemeral_5m_input_tokens) !== null && _e !== void 0 ? _e : 0) + cacheCreation1h : 0;
1575
1577
  cost = this.logTokenUsage(uncachedInput, (_f = response.usage.output_tokens) !== null && _f !== void 0 ? _f : 0, cacheRead, cacheCreation, cacheCreation1h);
1576
1578
  inputTokens = uncachedInput + cacheRead + cacheCreation;
1579
+ cacheReadTokens = cacheRead;
1580
+ cacheWriteTokens = cacheCreation;
1577
1581
  if (response.usage.output_tokens != null) {
1578
1582
  outputTokens = response.usage.output_tokens;
1579
1583
  }
@@ -1604,6 +1608,10 @@ ${att.content}` });
1604
1608
  base.inputTokens = inputTokens;
1605
1609
  if (outputTokens != null)
1606
1610
  base.outputTokens = outputTokens;
1611
+ if (cacheReadTokens != null)
1612
+ base.cacheReadTokens = cacheReadTokens;
1613
+ if (cacheWriteTokens != null)
1614
+ base.cacheWriteTokens = cacheWriteTokens;
1607
1615
  if (cost != null)
1608
1616
  base.cost = cost;
1609
1617
  if (response.stop_reason === "max_tokens") {
@@ -2130,28 +2138,45 @@ ${att.content}`
2130
2138
  }
2131
2139
  return contents;
2132
2140
  }
2141
+ /**
2142
+ * Map Gemini's `usageMetadata` onto the provider-agnostic usage fields, pricing
2143
+ * the call as a side effect (`logTokenUsage`). Returns an empty object when the
2144
+ * response carries no usage — every field is optional, so an absent count stays
2145
+ * `undefined` rather than being reported as a measured 0.
2146
+ *
2147
+ * Split out of `fromGeminiResponse` because it is self-contained bookkeeping
2148
+ * that reads better alone — and because folding it inline pushed that method
2149
+ * past the complexity ceiling.
2150
+ */
2151
+ usageFromGemini(usageMetadata) {
2152
+ var _a, _b, _c, _d, _e, _f;
2153
+ if (!usageMetadata)
2154
+ return {};
2155
+ const usage = usageMetadata;
2156
+ const cost = this.logTokenUsage((_a = usage.promptTokenCount) !== null && _a !== void 0 ? _a : 0, (_b = usage.candidatesTokenCount) !== null && _b !== void 0 ? _b : 0, (_c = usage.thoughtsTokenCount) !== null && _c !== void 0 ? _c : 0, (_d = usage.cachedContentTokenCount) !== null && _d !== void 0 ? _d : 0);
2157
+ return {
2158
+ inputTokens: usage.promptTokenCount,
2159
+ // Thinking tokens are generated output, billed at the candidate rate, but
2160
+ // reported in a field disjoint from `candidatesTokenCount`. Fold them into the
2161
+ // provider-agnostic `outputTokens` so it reflects the true generated total —
2162
+ // matching Anthropic, whose `output_tokens` already includes them. Left
2163
+ // `undefined` only when the provider reported neither count.
2164
+ outputTokens: usage.candidatesTokenCount != null || usage.thoughtsTokenCount != null ? ((_e = usage.candidatesTokenCount) !== null && _e !== void 0 ? _e : 0) + ((_f = usage.thoughtsTokenCount) !== null && _f !== void 0 ? _f : 0) : void 0,
2165
+ thoughtsTokens: usage.thoughtsTokenCount,
2166
+ // Gemini reports cache HITS only — implicit caching bills no separate write, so
2167
+ // `cacheWriteTokens` is left unset rather than 0 (there is no write bucket to
2168
+ // measure, which is not the same as measuring 0). Already counted inside
2169
+ // `promptTokenCount`, so this breaks that total down rather than adding to it.
2170
+ cacheReadTokens: usage.cachedContentTokenCount,
2171
+ cost
2172
+ };
2173
+ }
2133
2174
  fromGeminiResponse(response, offeredToolNames = /* @__PURE__ */ new Set()) {
2134
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
2135
- let inputTokens;
2136
- let outputTokens;
2137
- let thoughtsTokens;
2138
- let cost;
2139
- if (response.usageMetadata) {
2140
- const usage = response.usageMetadata;
2141
- cost = this.logTokenUsage((_a = usage.promptTokenCount) !== null && _a !== void 0 ? _a : 0, (_b = usage.candidatesTokenCount) !== null && _b !== void 0 ? _b : 0, (_c = usage.thoughtsTokenCount) !== null && _c !== void 0 ? _c : 0, (_d = usage.cachedContentTokenCount) !== null && _d !== void 0 ? _d : 0);
2142
- if (usage.promptTokenCount != null) {
2143
- inputTokens = usage.promptTokenCount;
2144
- }
2145
- if (usage.thoughtsTokenCount != null) {
2146
- thoughtsTokens = usage.thoughtsTokenCount;
2147
- }
2148
- if (usage.candidatesTokenCount != null || usage.thoughtsTokenCount != null) {
2149
- outputTokens = ((_e = usage.candidatesTokenCount) !== null && _e !== void 0 ? _e : 0) + ((_f = usage.thoughtsTokenCount) !== null && _f !== void 0 ? _f : 0);
2150
- }
2151
- }
2175
+ var _a, _b, _c, _d;
2176
+ const { inputTokens, outputTokens, thoughtsTokens, cacheReadTokens, cost } = this.usageFromGemini(response.usageMetadata);
2152
2177
  const candidates = response === null || response === void 0 ? void 0 : response.candidates;
2153
2178
  const firstCandidate = candidates === null || candidates === void 0 ? void 0 : candidates[0];
2154
- const parts = (_h = (_g = firstCandidate === null || firstCandidate === void 0 ? void 0 : firstCandidate.content) === null || _g === void 0 ? void 0 : _g.parts) !== null && _h !== void 0 ? _h : [];
2179
+ const parts = (_b = (_a = firstCandidate === null || firstCandidate === void 0 ? void 0 : firstCandidate.content) === null || _a === void 0 ? void 0 : _a.parts) !== null && _b !== void 0 ? _b : [];
2155
2180
  const toolCalls = [];
2156
2181
  const thoughtParts = [];
2157
2182
  const textParts = [];
@@ -2164,7 +2189,7 @@ ${att.content}`
2164
2189
  toolCalls.push({
2165
2190
  id: crypto.randomUUID(),
2166
2191
  name: part.functionCall.name,
2167
- args: (_j = part.functionCall.args) !== null && _j !== void 0 ? _j : {},
2192
+ args: (_c = part.functionCall.args) !== null && _c !== void 0 ? _c : {},
2168
2193
  providerMetadata: { [GEMINI_PROVIDER_KEY]: state }
2169
2194
  });
2170
2195
  } else if (part.thought && part.text) {
@@ -2198,9 +2223,11 @@ ${att.content}`
2198
2223
  base.inputTokens = inputTokens;
2199
2224
  if (outputTokens != null)
2200
2225
  base.outputTokens = outputTokens;
2226
+ if (cacheReadTokens != null)
2227
+ base.cacheReadTokens = cacheReadTokens;
2201
2228
  if (cost != null)
2202
2229
  base.cost = cost;
2203
- const blockReason = (_k = response.promptFeedback) === null || _k === void 0 ? void 0 : _k.blockReason;
2230
+ const blockReason = (_d = response.promptFeedback) === null || _d === void 0 ? void 0 : _d.blockReason;
2204
2231
  if (finishReason != null || thoughtsTokens != null || blockReason != null) {
2205
2232
  base.responseMeta = {
2206
2233
  finishReason,
@@ -2997,6 +3024,46 @@ The above summarizes earlier parts of this conversation that were compacted. Con
2997
3024
  });
2998
3025
  }
2999
3026
 
3027
+ // src/utils/sum-usage.ts
3028
+ function emptyUsage() {
3029
+ return {
3030
+ costUsd: 0,
3031
+ uncachedInputTokens: 0,
3032
+ cacheReadTokens: 0,
3033
+ cacheWriteTokens: 0,
3034
+ outputTokens: 0
3035
+ };
3036
+ }
3037
+ function sumUsage(messages) {
3038
+ const total = emptyUsage();
3039
+ accumulate(messages, total);
3040
+ return total;
3041
+ }
3042
+ function addInto(into, from) {
3043
+ into.costUsd += from.costUsd;
3044
+ into.uncachedInputTokens += from.uncachedInputTokens;
3045
+ into.cacheReadTokens += from.cacheReadTokens;
3046
+ into.cacheWriteTokens += from.cacheWriteTokens;
3047
+ into.outputTokens += from.outputTokens;
3048
+ }
3049
+ function accumulate(messages, into) {
3050
+ for (const m of messages) {
3051
+ if (m.cost != null) into.costUsd += m.cost;
3052
+ if (m.externalCostUsd != null) into.costUsd += m.externalCostUsd;
3053
+ const cacheRead = m.cacheReadTokens ?? 0;
3054
+ const cacheWrite = m.cacheWriteTokens ?? 0;
3055
+ into.cacheReadTokens += cacheRead;
3056
+ into.cacheWriteTokens += cacheWrite;
3057
+ into.uncachedInputTokens += Math.max(0, (m.inputTokens ?? 0) - cacheRead - cacheWrite);
3058
+ into.outputTokens += m.outputTokens ?? 0;
3059
+ const rolled = m.compaction?.rolledUpUsage;
3060
+ if (rolled) addInto(into, rolled);
3061
+ for (const tc of m.toolCalls ?? []) {
3062
+ if (tc.subAgentTrace) accumulate(tc.subAgentTrace, into);
3063
+ }
3064
+ }
3065
+ }
3066
+
3000
3067
  // src/utils/tool-fold.ts
3001
3068
  var TOOL_FOLD_SYMBOL = Symbol("toolFold");
3002
3069
 
@@ -3744,7 +3811,12 @@ Output format (strict):
3744
3811
  coveredThroughTimestamp: toCompact[toCompact.length - 1]?.timestamp,
3745
3812
  tokensBefore,
3746
3813
  model: status?.model,
3747
- createdAt
3814
+ createdAt,
3815
+ // Bank what the replaced turns spent. `toCompact` is dropped from history
3816
+ // below, taking its per-message cost/token fields with it — so a host total
3817
+ // re-derived from the transcript would otherwise fall by exactly this much
3818
+ // on every compaction. Summarising a turn does not refund what it cost.
3819
+ rolledUpUsage: sumUsage(toCompact)
3748
3820
  }
3749
3821
  };
3750
3822
  this.loadHistory([summaryMessage, ...tail]);