@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,31 +1,89 @@
1
- import type { AIProviderType } from '@genesislcap/foundation-ai';
1
+ import type { AggregateUsage, AIProviderType } from '@genesislcap/foundation-ai';
2
+ import { emptyUsage } from './sum-usage';
2
3
 
3
- /** A model used during a finalized cost session. */
4
+ /** A model used during a recorded cost session. */
4
5
  export interface CostSessionModelEntry {
5
6
  model: string;
6
7
  /** Vendor id from `getStatus()` (e.g. `'anthropic'`, `'gemini'`). */
7
8
  provider?: AIProviderType;
8
9
  }
9
10
 
10
- /** Persisted snapshot of a completed assistant cost session. */
11
+ /** Persisted usage record for one project's assistant work. */
11
12
  export interface CostSessionRecord {
12
13
  id: string;
14
+ /**
15
+ * Stable identity of the work this row stands for — the assistant's session key
16
+ * (a project id, for hosts that key sessions per project). The upsert key: a row
17
+ * is one project's lifetime usage, so re-reporting the same key REPLACES the row
18
+ * rather than adding another.
19
+ */
20
+ projectKey: string;
13
21
  title: string;
14
- /** ISO-8601 timestamp when the session was finalized. */
15
- endedAt: string;
16
- costUsd: number;
17
- tokensConsumed: number;
22
+ /**
23
+ * ISO-8601 timestamp of the most recent usage recorded for this row.
24
+ *
25
+ * Named for what it is: the row is upserted as spend accrues, so this moves. It is
26
+ * NOT an end time — a row has no terminal state, because a project can always be
27
+ * worked on again.
28
+ */
29
+ updatedAt: string;
30
+ /**
31
+ * Cost plus the four token buckets — the same shape the live session tile renders, so
32
+ * a history row can show the identical breakdown instead of a lone merged total that
33
+ * cannot explain a large token count at a small cost.
34
+ *
35
+ * This is the project's **lifetime** usage: `banked` plus whatever the live transcript
36
+ * currently proves.
37
+ */
38
+ usage: AggregateUsage;
39
+ /**
40
+ * Spend that predates the current transcript, and so cannot be re-derived from it.
41
+ *
42
+ * Without this a row could only ever report what the open transcript proves. That is
43
+ * fine when the transcript is restored on load (it *is* the project's history), but
44
+ * where it starts empty every refresh the row would be rewritten downwards on the next
45
+ * turn and the project's earlier spend lost. Banking it keeps `usage` a lifetime figure
46
+ * in both cases.
47
+ *
48
+ * Deliberately part of the row rather than element state: two assistant instances (a
49
+ * docked bubble and a popped-out panel) share one session, and both write this row. A
50
+ * value derived from the row keeps every write idempotent, whereas an element-held
51
+ * accumulator would be applied once per instance.
52
+ *
53
+ * Optional: rows written before this existed have none, which reads as "nothing banked".
54
+ */
55
+ banked?: AggregateUsage;
18
56
  models: CostSessionModelEntry[];
19
57
  }
20
58
 
21
59
  const STORAGE_PREFIX = 'genesis-ai-assistant:cost-history:';
22
60
 
61
+ /**
62
+ * Every bucket must be a number before a row is trusted. Checked per field rather than
63
+ * just testing for an object, because a partially-shaped `usage` would sum as `NaN` and
64
+ * silently poison every total on the Usage tab.
65
+ */
66
+ function isAggregateUsage(value: unknown): value is AggregateUsage {
67
+ if (typeof value !== 'object' || value == null) return false;
68
+ const u = value as AggregateUsage;
69
+ return (
70
+ typeof u.costUsd === 'number' &&
71
+ typeof u.uncachedInputTokens === 'number' &&
72
+ typeof u.cacheReadTokens === 'number' &&
73
+ typeof u.cacheWriteTokens === 'number' &&
74
+ typeof u.outputTokens === 'number'
75
+ );
76
+ }
77
+
23
78
  /** localStorage key for a host-scoped cost history list. */
24
79
  export function costHistoryStorageKey(scope: string): string {
25
80
  return `${STORAGE_PREFIX}${scope}`;
26
81
  }
27
82
 
28
- /** Load persisted cost sessions for a scope (newest first). */
83
+ /**
84
+ * Load persisted cost sessions for a scope, in whatever order they were stored — this applies no
85
+ * ordering of its own. Display order is the caller's business; see `sortRecordsByRecency`.
86
+ */
29
87
  export function loadCostSessionHistory(scope: string): CostSessionRecord[] {
30
88
  if (typeof localStorage === 'undefined') return [];
31
89
  try {
@@ -49,21 +107,69 @@ export function saveCostSessionHistory(scope: string, records: CostSessionRecord
49
107
  }
50
108
  }
51
109
 
52
- /** Prepend a record and persist. Returns the updated list. */
53
- export function appendCostSessionRecord(
54
- scope: string,
110
+ /**
111
+ * Pure upsert — `records` with `record` replacing any row sharing its `projectKey`, or
112
+ * prepended when there is none.
113
+ *
114
+ * An upsert, not an append, because a row stands for a **project's lifetime usage** rather
115
+ * than a visit to it. Appending cut a fresh row per page-hide, so one project accumulated
116
+ * a stack of near-identical rows and its spend was counted once per refresh by anything
117
+ * summing the list.
118
+ *
119
+ * The resulting array order is incidental — it is insertion order, so a project returned to
120
+ * stays where it first landed. Do not read it as recency; `sortRecordsByRecency` is what the
121
+ * Usage tab renders through.
122
+ *
123
+ * Storage-agnostic on purpose: the same merge serves the synchronous `localStorage` path
124
+ * and the async provider-backed one, so the two can never drift on what "upsert" means.
125
+ * The caller owns persistence — see the assistant's `persistCostHistory`.
126
+ */
127
+ export function upsertRecord(
128
+ records: readonly CostSessionRecord[],
55
129
  record: CostSessionRecord,
56
130
  ): CostSessionRecord[] {
57
- const records = [record, ...loadCostSessionHistory(scope)];
58
- saveCostSessionHistory(scope, records);
59
- return records;
131
+ const at = records.findIndex((r) => r.projectKey === record.projectKey);
132
+ return at === -1 ? [record, ...records] : records.map((r, i) => (i === at ? record : r));
133
+ }
134
+
135
+ /**
136
+ * Rows most-recently-worked-on first, as a new array.
137
+ *
138
+ * Applied at read time rather than baked into the stored order, so it covers rows that came from
139
+ * a host provider as well as from `localStorage`, and so no write path has to maintain it.
140
+ *
141
+ * `updatedAt` is ISO-8601, which sorts lexicographically — no date parsing, and no dependence on
142
+ * the strings being valid dates. Sorting by it rather than by insertion order is what makes the
143
+ * list track use: the active project moves to the top on its first upsert and stays there, since
144
+ * its row is rewritten on every transcript change.
145
+ */
146
+ export function sortRecordsByRecency(records: readonly CostSessionRecord[]): CostSessionRecord[] {
147
+ return [...records].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
60
148
  }
61
149
 
62
- /** Remove one record by id. Returns the updated list. */
63
- export function removeCostSessionRecord(scope: string, id: string): CostSessionRecord[] {
64
- const records = loadCostSessionHistory(scope).filter((r) => r.id !== id);
65
- saveCostSessionHistory(scope, records);
66
- return records;
150
+ /**
151
+ * The spend a project's next row must carry over from before its current transcript.
152
+ *
153
+ * `transcriptIsAuthoritative` means the open transcript already accounts for this project's
154
+ * history — true exactly when a session snapshot is restored on load. The two branches are
155
+ * not interchangeable, and picking the wrong one is silently destructive:
156
+ *
157
+ * - Authoritative: carry the previous row's own `banked` forward. Taking `usage` instead would
158
+ * re-add the restored history on every reload, the compounding double-count this replaced.
159
+ * - Not authoritative: the whole previous total becomes banked, because nothing in the fresh
160
+ * transcript accounts for it. Taking `banked` instead would rewrite the row down to the
161
+ * current page-load and drop the project's earlier spend.
162
+ *
163
+ * Note both branches are idempotent — the result never includes the current transcript — which
164
+ * is what lets two assistant instances sharing a session write the same row.
165
+ */
166
+ export function resolveBankedUsage(
167
+ existing: CostSessionRecord | undefined,
168
+ transcriptIsAuthoritative: boolean,
169
+ ): AggregateUsage {
170
+ if (!existing) return emptyUsage();
171
+ const carried = transcriptIsAuthoritative ? existing.banked : existing.usage;
172
+ return carried ?? emptyUsage();
67
173
  }
68
174
 
69
175
  /** Clear all persisted records for a scope. */
@@ -85,15 +191,28 @@ export function formatCostSessionDate(iso: string): string {
85
191
  }).format(new Date(iso));
86
192
  }
87
193
 
88
- function isCostSessionRecord(value: unknown): value is CostSessionRecord {
194
+ /**
195
+ * Whether a value is a usable row. Exported because rows arrive by two routes and BOTH need it:
196
+ * `loadCostSessionHistory` filters the `localStorage` path here, and the assistant filters what a
197
+ * host provider returns. A row that skips this check is not merely ignored downstream — a
198
+ * half-shaped `usage` sums to `NaN` through every total, and a missing `updatedAt` throws inside
199
+ * the sort that orders the list, during render.
200
+ */
201
+ export function isCostSessionRecord(value: unknown): value is CostSessionRecord {
89
202
  if (typeof value !== 'object' || value == null) return false;
90
203
  const r = value as CostSessionRecord;
91
204
  return (
92
205
  typeof r.id === 'string' &&
206
+ // Rows written before the upsert model have no `projectKey` and cannot be keyed
207
+ // to a project, so they are discarded on load rather than silently pooled with
208
+ // the new per-project rows (where they would double-count that project).
209
+ typeof r.projectKey === 'string' &&
93
210
  typeof r.title === 'string' &&
94
- typeof r.endedAt === 'string' &&
95
- typeof r.costUsd === 'number' &&
96
- typeof r.tokensConsumed === 'number' &&
211
+ typeof r.updatedAt === 'string' &&
212
+ isAggregateUsage(r.usage) &&
213
+ // Absent is valid (nothing banked / pre-dates the field); present must be whole, since
214
+ // a half-shaped `banked` would poison the lifetime total exactly as a bad `usage` would.
215
+ (r.banked === undefined || isAggregateUsage(r.banked)) &&
97
216
  Array.isArray(r.models)
98
217
  );
99
218
  }
@@ -8,12 +8,18 @@ export interface ResolvedCostHistoryConfig {
8
8
  title?: string;
9
9
  /** `localStorage` scope key; falls back to element `id` then `'default'`. */
10
10
  scope?: string;
11
- /** Show running session USD on the Cost tab. Default: `true`. */
11
+ /** Show running session USD on the Usage tab. Default: `true`. */
12
12
  showCost: boolean;
13
- /** Show cumulative session tokens on the Cost tab. Default: `true`. */
13
+ /** Show cumulative session tokens on the Usage tab. Default: `true`. */
14
14
  showTokens: boolean;
15
- /** Live-session badge label on the Cost tab summary. */
15
+ /** Live-session badge label on the Usage tab summary. */
16
16
  badgeLabel: string;
17
+ /**
18
+ * Host-supplied disclosure line for the Usage tab. Stays optional — unlike
19
+ * `badgeLabel` there is no default, because only the host knows what its `scope`
20
+ * spans, and the library must not assert it.
21
+ */
22
+ summaryHint?: string;
17
23
  }
18
24
 
19
25
  /** Resolve `chatConfig.costHistory` with defaults applied. */
@@ -27,5 +33,6 @@ export function resolveCostHistoryConfig(chatConfig: ChatConfig): ResolvedCostHi
27
33
  showCost: cost?.showCost !== false,
28
34
  showTokens: cost?.showTokens !== false,
29
35
  badgeLabel: cost?.badgeLabel ?? 'Currently building',
36
+ summaryHint: cost?.summaryHint?.trim() || undefined,
30
37
  };
31
38
  }
@@ -1,4 +1,5 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
+ import { sumUsage } from './sum-usage';
2
3
 
3
4
  /**
4
5
  * Sum cost across a message list: each message's provider-reported LLM `cost`
@@ -13,13 +14,5 @@ import type { ChatMessage } from '@genesislcap/foundation-ai';
13
14
  * @internal
14
15
  */
15
16
  export function sumCosts(messages: readonly ChatMessage[]): number {
16
- let total = 0;
17
- for (const m of messages) {
18
- if (m.cost != null) total += m.cost;
19
- if (m.externalCostUsd != null) total += m.externalCostUsd;
20
- for (const tc of m.toolCalls ?? []) {
21
- if (tc.subAgentTrace) total += sumCosts(tc.subAgentTrace);
22
- }
23
- }
24
- return total;
17
+ return sumUsage(messages).costUsd;
25
18
  }
@@ -1,11 +1,6 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
- import {
4
- formatCompactTokenCount,
5
- formatCompactTokenCountPlain,
6
- formatCompactTokenCountSafe,
7
- sumTokens,
8
- } from './sum-tokens';
3
+ import { formatCompactTokenCount, formatCompactTokenCountSafe, sumTokens } from './sum-tokens';
9
4
 
10
5
  const assistantMsg = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
11
6
  role: 'assistant',
@@ -94,10 +89,5 @@ formatSuite('safe formatter preserves compact markup', () => {
94
89
  );
95
90
  });
96
91
 
97
- formatSuite('plain formatter returns compact text', () => {
98
- assert.is(formatCompactTokenCountPlain(1_400_000), '1.4M');
99
- assert.is(formatCompactTokenCountPlain(842), '842');
100
- });
101
-
102
92
  suite.run();
103
93
  formatSuite.run();
@@ -1,26 +1,23 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import DOMPurify from 'dompurify';
3
-
4
- /** Per-message token usage when the provider reports it. */
5
- const messageTokenUsage = (m: ChatMessage): number => (m.inputTokens ?? 0) + (m.outputTokens ?? 0);
3
+ import { sumUsage, totalTokens } from './sum-usage';
6
4
 
7
5
  /**
8
- * Sum input + output tokens across a message list, recursing into each tool
9
- * call's `subAgentTrace` so sub-agent LLM usage contributes to the total.
6
+ * Total tokens across a message list — the headline figure, with the per-bucket
7
+ * split available from {@link sumUsage} for anything that needs to show *why* a
8
+ * large token count cost little.
9
+ *
10
+ * Identical arithmetic to the old per-message `inputTokens + outputTokens` sum:
11
+ * the three input buckets partition `inputTokens`, so they re-add to it exactly.
12
+ * It does now additionally count usage banked by a compaction, which is the point
13
+ * — that spend used to vanish when the summarised turns left the transcript.
10
14
  *
11
15
  * Returns 0 when no message carries token metadata.
12
16
  *
13
17
  * @internal
14
18
  */
15
19
  export function sumTokens(messages: readonly ChatMessage[]): number {
16
- let total = 0;
17
- for (const m of messages) {
18
- total += messageTokenUsage(m);
19
- for (const tc of m.toolCalls ?? []) {
20
- if (tc.subAgentTrace) total += sumTokens(tc.subAgentTrace);
21
- }
22
- }
23
- return total;
20
+ return totalTokens(sumUsage(messages));
24
21
  }
25
22
 
26
23
  const TOKEN_SCALE_DECIMAL_FACTOR = 10;
@@ -53,19 +50,6 @@ const scaledNumericValue = (tokens: number): number | undefined => {
53
50
  return roundScaled(tokens / TOKENS_PER_THOUSAND);
54
51
  };
55
52
 
56
- /**
57
- * Compact plain-text token count (e.g. 1_400_000 → "1.4M").
58
- *
59
- * @internal
60
- */
61
- export function formatCompactTokenCountPlain(tokens: number): string {
62
- const suffix = scaledSuffixLabel(tokens);
63
- if (!suffix) return tokens.toLocaleString();
64
- const rounded = scaledNumericValue(tokens)!;
65
- const value = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
66
- return `${value}${suffix}`;
67
- }
68
-
69
53
  /**
70
54
  * Compact display for large token counts (e.g. 1_400_000 → value "1.4" + suffix "M").
71
55
  * Returns HTML markup so the suffix can be styled separately in the settings modal.
@@ -0,0 +1,140 @@
1
+ import type { ChatMessage } from '@genesislcap/foundation-ai';
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { addUsage, emptyUsage, sumUsage, totalTokens } from './sum-usage';
4
+
5
+ const suite = createLogicSuite('sumUsage');
6
+
7
+ const msg = (over: Partial<ChatMessage> = {}): ChatMessage =>
8
+ ({ role: 'assistant', content: '', ...over }) as ChatMessage;
9
+
10
+ suite('returns zeroes when no message carries usage', () => {
11
+ const usage = sumUsage([msg({ role: 'user', content: 'hi' })]);
12
+ assert.equal(usage, emptyUsage());
13
+ assert.is(totalTokens(usage), 0);
14
+ });
15
+
16
+ suite('splits the prompt into three disjoint input buckets', () => {
17
+ // `inputTokens` is the WHOLE prompt; the cache fields break it down. So uncached is
18
+ // the remainder, and the three must re-add to the prompt exactly — that identity is
19
+ // what stops the buckets double-counting the prompt when summed.
20
+ const usage = sumUsage([
21
+ msg({ inputTokens: 1000, cacheReadTokens: 700, cacheWriteTokens: 200, outputTokens: 50 }),
22
+ ]);
23
+ assert.is(usage.uncachedInputTokens, 100);
24
+ assert.is(usage.cacheReadTokens, 700);
25
+ assert.is(usage.cacheWriteTokens, 200);
26
+ assert.is(usage.outputTokens, 50);
27
+ assert.is(
28
+ usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens,
29
+ 1000,
30
+ 'input buckets re-add to the prompt total',
31
+ );
32
+ assert.is(totalTokens(usage), 1050);
33
+ });
34
+
35
+ suite('treats the whole prompt as uncached when no cache split is reported', () => {
36
+ // Providers that report no cache breakdown (and messages persisted before the fields
37
+ // existed) must still contribute their full prompt, not drop it.
38
+ const usage = sumUsage([msg({ inputTokens: 400, outputTokens: 60 })]);
39
+ assert.is(usage.uncachedInputTokens, 400);
40
+ assert.is(usage.cacheReadTokens, 0);
41
+ assert.is(usage.cacheWriteTokens, 0);
42
+ assert.is(totalTokens(usage), 460);
43
+ });
44
+
45
+ suite('clamps uncached input at zero when buckets exceed the prompt total', () => {
46
+ // Malformed or hand-edited history must not be able to drive a cost display negative.
47
+ const usage = sumUsage([
48
+ msg({ inputTokens: 100, cacheReadTokens: 90, cacheWriteTokens: 90, outputTokens: 0 }),
49
+ ]);
50
+ assert.is(usage.uncachedInputTokens, 0);
51
+ });
52
+
53
+ suite('sums LLM cost and widget-reported external cost', () => {
54
+ const usage = sumUsage([msg({ cost: 0.25 }), msg({ externalCostUsd: 0.1, cost: 0.05 })]);
55
+ assert.is(Math.round(usage.costUsd * 100) / 100, 0.4);
56
+ });
57
+
58
+ suite('recurses into sub-agent traces', () => {
59
+ // A sub-agent's whole conversation is embedded in the parent's tool call, so this is
60
+ // the only place its spend is visible to a host total.
61
+ const usage = sumUsage([
62
+ msg({
63
+ toolCalls: [
64
+ {
65
+ id: 'tc-1',
66
+ name: 'delegate',
67
+ args: {},
68
+ subAgentTrace: [
69
+ msg({ cost: 0.2, inputTokens: 300, cacheReadTokens: 100, outputTokens: 40 }),
70
+ ],
71
+ },
72
+ ],
73
+ }),
74
+ ]);
75
+ assert.is(usage.costUsd, 0.2);
76
+ assert.is(usage.uncachedInputTokens, 200);
77
+ assert.is(usage.cacheReadTokens, 100);
78
+ assert.is(usage.outputTokens, 40);
79
+ });
80
+
81
+ suite('counts usage banked onto a compaction summary', () => {
82
+ // Compaction destroys the messages it summarises. Without the banked rollup, a total
83
+ // re-derived from the transcript would fall by whatever those turns had spent.
84
+ const before = sumUsage([
85
+ msg({ cost: 0.5, inputTokens: 1000, cacheReadTokens: 400, outputTokens: 100 }),
86
+ msg({ cost: 0.3, inputTokens: 500, outputTokens: 50 }),
87
+ ]);
88
+ const afterCompaction = sumUsage([
89
+ msg({
90
+ role: 'compacted-summary',
91
+ content: 'summary of the above',
92
+ compaction: {
93
+ compactedCount: 2,
94
+ createdAt: '2026-06-14T12:00:00.000Z',
95
+ rolledUpUsage: before,
96
+ },
97
+ }),
98
+ ]);
99
+ assert.equal(afterCompaction, before, 'compaction preserves the running total');
100
+ });
101
+
102
+ suite('ignores tokensBefore, which is a context reading rather than spend', () => {
103
+ // `tokensBefore` is one turn's context-window size. Counting it as cumulative spend
104
+ // would silently inflate the session total on every compaction.
105
+ const usage = sumUsage([
106
+ msg({
107
+ role: 'compacted-summary',
108
+ content: 'summary',
109
+ compaction: {
110
+ compactedCount: 3,
111
+ createdAt: '2026-06-14T12:00:00.000Z',
112
+ tokensBefore: 90_000,
113
+ },
114
+ }),
115
+ ]);
116
+ assert.is(totalTokens(usage), 0);
117
+ });
118
+
119
+ suite('addUsage adds every bucket and mutates neither operand', () => {
120
+ const a = { ...emptyUsage(), costUsd: 1.5, uncachedInputTokens: 100, outputTokens: 10 };
121
+ const b = { ...emptyUsage(), costUsd: 0.25, cacheReadTokens: 900, outputTokens: 5 };
122
+ const sum = addUsage(a, b);
123
+
124
+ assert.is(sum.costUsd, 1.75);
125
+ assert.is(sum.uncachedInputTokens, 100);
126
+ assert.is(sum.cacheReadTokens, 900);
127
+ assert.is(sum.outputTokens, 15);
128
+ // Both operands are live state (a row's banked figure and the session total), so a
129
+ // caller must be able to add without either being altered underneath it.
130
+ assert.is(a.costUsd, 1.5);
131
+ assert.is(b.cacheReadTokens, 900);
132
+ });
133
+
134
+ suite('addUsage with an empty operand is the identity', () => {
135
+ const a = { ...emptyUsage(), costUsd: 3, cacheWriteTokens: 42 };
136
+ assert.equal(addUsage(a, emptyUsage()), a);
137
+ assert.equal(addUsage(emptyUsage(), a), a);
138
+ });
139
+
140
+ suite.run();
@@ -0,0 +1,130 @@
1
+ import type { AggregateUsage, ChatMessage } from '@genesislcap/foundation-ai';
2
+
3
+ /**
4
+ * Zeroed {@link AggregateUsage}. A factory, not a shared const, because callers
5
+ * accumulate into the returned object.
6
+ *
7
+ * @internal
8
+ */
9
+ export function emptyUsage(): AggregateUsage {
10
+ return {
11
+ costUsd: 0,
12
+ uncachedInputTokens: 0,
13
+ cacheReadTokens: 0,
14
+ cacheWriteTokens: 0,
15
+ outputTokens: 0,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Total tokens processed — the four buckets added up. Safe to sum because the
21
+ * buckets are disjoint (see {@link AggregateUsage}); the equivalent sum over
22
+ * per-message fields would double-count the prompt.
23
+ *
24
+ * Bear in mind what this number is: because every turn resends the conversation,
25
+ * a turn's prompt is counted again in the next turn's. It is billed throughput,
26
+ * NOT the count of distinct tokens in the conversation, so it grows
27
+ * super-linearly with turn count and is expected to dwarf the context size.
28
+ *
29
+ * @internal
30
+ */
31
+ export function totalTokens(usage: AggregateUsage): number {
32
+ return (
33
+ usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + usage.outputTokens
34
+ );
35
+ }
36
+
37
+ /**
38
+ * `a + b`, per bucket, as a new object.
39
+ *
40
+ * Used to lay a row's banked (pre-transcript) spend over what the live transcript
41
+ * proves. Pure rather than in-place because both operands are live state a caller
42
+ * must not mutate.
43
+ *
44
+ * @internal
45
+ */
46
+ export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
47
+ return {
48
+ costUsd: a.costUsd + b.costUsd,
49
+ uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens,
50
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
51
+ cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
52
+ outputTokens: a.outputTokens + b.outputTokens,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Sum cost and per-bucket token usage across a message list.
58
+ *
59
+ * One walk produces everything a usage display needs, replacing separate cost and
60
+ * token passes over the same (potentially deep) transcript. Recurses into each
61
+ * tool call's `subAgentTrace`, so LLM work a sub-agent did — possibly on another
62
+ * provider at other rates — lands in the total; the driver embeds a whole
63
+ * sub-agent conversation in the parent's tool result, so this is the only place
64
+ * that spend is visible.
65
+ *
66
+ * Splitting the prompt into buckets is the point: `cost` already has the cache
67
+ * discount applied, but a single token total cannot show that a session was cheap
68
+ * because most of its prompt was a cache hit. The three input buckets are derived
69
+ * from the per-message fields, where `inputTokens` is the whole prompt and the
70
+ * cache fields break it down — so uncached input is the remainder, never an
71
+ * addition.
72
+ *
73
+ * Also counts `compaction.rolledUpUsage`: compaction destroys the messages it
74
+ * summarises, and without their banked usage every total re-derived from the
75
+ * transcript would drop by whatever those turns had already spent.
76
+ *
77
+ * Returns a zeroed total when no message carries usage — providers that report
78
+ * none (e.g. Chrome built-in) are skipped silently rather than guessed at.
79
+ *
80
+ * @internal
81
+ */
82
+ export function sumUsage(messages: readonly ChatMessage[]): AggregateUsage {
83
+ const total = emptyUsage();
84
+ accumulate(messages, total);
85
+ return total;
86
+ }
87
+
88
+ /** Add `from` into `into`, in place. */
89
+ function addInto(into: AggregateUsage, from: AggregateUsage): void {
90
+ into.costUsd += from.costUsd;
91
+ into.uncachedInputTokens += from.uncachedInputTokens;
92
+ into.cacheReadTokens += from.cacheReadTokens;
93
+ into.cacheWriteTokens += from.cacheWriteTokens;
94
+ into.outputTokens += from.outputTokens;
95
+ }
96
+
97
+ /**
98
+ * Walk `messages`, accumulating into `into`. Mutates a single accumulator rather
99
+ * than merging returned objects — this runs on every transcript change, so the
100
+ * per-message allocation is worth avoiding.
101
+ */
102
+ function accumulate(messages: readonly ChatMessage[], into: AggregateUsage): void {
103
+ for (const m of messages) {
104
+ // Provider/LLM cost plus any non-LLM cost a widget reported for its own
105
+ // external service calls. Both are real spend on this session.
106
+ if (m.cost != null) into.costUsd += m.cost;
107
+ if (m.externalCostUsd != null) into.costUsd += m.externalCostUsd;
108
+
109
+ const cacheRead = m.cacheReadTokens ?? 0;
110
+ const cacheWrite = m.cacheWriteTokens ?? 0;
111
+ into.cacheReadTokens += cacheRead;
112
+ into.cacheWriteTokens += cacheWrite;
113
+ // The uncached remainder of the prompt. Clamped at zero so malformed or
114
+ // hand-edited history whose buckets exceed the prompt total can't drive the
115
+ // session total negative — a wrong-but-plausible number is worse than a
116
+ // conservative one here, since this feeds a cost display.
117
+ into.uncachedInputTokens += Math.max(0, (m.inputTokens ?? 0) - cacheRead - cacheWrite);
118
+ into.outputTokens += m.outputTokens ?? 0;
119
+
120
+ // Spend banked by a compaction on this summary's behalf. NOTE: deliberately
121
+ // not `compaction.tokensBefore` — that is a one-turn context-window reading,
122
+ // not cumulative spend, and counting it here would inflate the total.
123
+ const rolled = m.compaction?.rolledUpUsage;
124
+ if (rolled) addInto(into, rolled);
125
+
126
+ for (const tc of m.toolCalls ?? []) {
127
+ if (tc.subAgentTrace) accumulate(tc.subAgentTrace, into);
128
+ }
129
+ }
130
+ }