@genesislcap/ai-assistant 15.10.6 → 15.12.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 (70) hide show
  1. package/dist/ai-assistant.api.json +226 -0
  2. package/dist/ai-assistant.d.ts +100 -3
  3. package/dist/chat-driver.cjs +540 -117
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +524 -116
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +439 -66
  8. package/dist/dts/chat-driver-node.d.ts +5 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +20 -3
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +2 -0
  13. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +1 -0
  14. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +2 -0
  15. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +1 -0
  16. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/config/config.d.ts +39 -2
  20. package/dist/dts/config/config.d.ts.map +1 -1
  21. package/dist/dts/config/define-stateful-agent.d.ts +15 -1
  22. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  23. package/dist/dts/main/main.d.ts +25 -0
  24. package/dist/dts/main/main.d.ts.map +1 -1
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/react.d.ts +9 -8
  27. package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
  28. package/dist/dts/utils/sum-usage.d.ts +37 -4
  29. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  30. package/dist/dts/utils/usage-rows.d.ts +102 -0
  31. package/dist/dts/utils/usage-rows.d.ts.map +1 -0
  32. package/dist/dts/utils/usage-rows.test.d.ts +2 -0
  33. package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
  34. package/dist/esm/chat-driver-node.js +36 -1
  35. package/dist/esm/components/chat-driver/chat-driver.js +73 -10
  36. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
  37. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
  38. package/dist/esm/components/settings-modal/settings-modal.styles.js +22 -0
  39. package/dist/esm/components/settings-modal/settings-modal.template.js +25 -4
  40. package/dist/esm/components/settings-modal/settings-modal.template.test.js +1 -1
  41. package/dist/esm/config/define-stateful-agent.js +11 -0
  42. package/dist/esm/main/blocked-state.test.js +1 -0
  43. package/dist/esm/main/budget-meter.test.js +42 -0
  44. package/dist/esm/main/main.js +49 -0
  45. package/dist/esm/main/main.template.js +20 -1
  46. package/dist/esm/utils/strip-agent-handlers.js +1 -1
  47. package/dist/esm/utils/sum-usage.js +37 -4
  48. package/dist/esm/utils/usage-rows.js +90 -0
  49. package/dist/esm/utils/usage-rows.test.js +189 -0
  50. package/dist/react.cjs +11 -6
  51. package/dist/react.mjs +10 -5
  52. package/dist/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +17 -17
  54. package/src/chat-driver-node.ts +58 -0
  55. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
  56. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
  57. package/src/components/chat-driver/chat-driver.ts +90 -10
  58. package/src/components/settings-modal/settings-modal.styles.ts +22 -0
  59. package/src/components/settings-modal/settings-modal.template.test.ts +1 -1
  60. package/src/components/settings-modal/settings-modal.template.ts +27 -2
  61. package/src/config/config.ts +50 -1
  62. package/src/config/define-stateful-agent.ts +37 -0
  63. package/src/main/blocked-state.test.ts +1 -0
  64. package/src/main/budget-meter.test.ts +50 -0
  65. package/src/main/main.template.ts +19 -1
  66. package/src/main/main.ts +47 -0
  67. package/src/utils/strip-agent-handlers.ts +1 -1
  68. package/src/utils/sum-usage.ts +37 -4
  69. package/src/utils/usage-rows.test.ts +237 -0
  70. package/src/utils/usage-rows.ts +187 -0
@@ -199,6 +199,16 @@ export function defineStatefulAgent(opts) {
199
199
  return opts.cachePolicy(Object.assign(Object.assign({}, ctx), { state }));
200
200
  })
201
201
  : opts.cachePolicy;
202
+ // Unlike the resolvers above, a pre-init call returns `undefined` rather than throwing: that is
203
+ // this option's "model default" state, so degrading to it costs a turn its thinking preference
204
+ // instead of breaking the turn outright.
205
+ const wrappedThinkingPolicy = typeof opts.thinkingPolicy === 'function'
206
+ ? (ctx) => __awaiter(this, void 0, void 0, function* () {
207
+ if (!state)
208
+ return undefined;
209
+ return opts.thinkingPolicy(Object.assign(Object.assign({}, ctx), { state }));
210
+ })
211
+ : opts.thinkingPolicy;
202
212
  const wrappedTailContext = typeof opts.tailContext === 'function'
203
213
  ? (ctx) => __awaiter(this, void 0, void 0, function* () {
204
214
  if (!state) {
@@ -249,6 +259,7 @@ export function defineStatefulAgent(opts) {
249
259
  temperature: wrappedTemperature,
250
260
  toolChoice: wrappedToolChoice,
251
261
  cachePolicy: wrappedCachePolicy,
262
+ thinkingPolicy: wrappedThinkingPolicy,
252
263
  tailContext: wrappedTailContext,
253
264
  onUnresolvedTool: wrappedOnUnresolvedTool,
254
265
  resumable: wrappedResumable,
@@ -219,6 +219,7 @@ Suite('a wall with figures snaps the vendor meter at latch time', () => {
219
219
  reachable(el, 'anthropic', 'gemini');
220
220
  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
221
221
  assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
222
+ assert.ok(typeof el.budgetMeterLastFedAt === 'number', 'the wall feed stamps freshness too');
222
223
  // A later 402 for the same (already-walled) vendor carries fresher spend —
223
224
  // the feed is deliberately outside the idempotence guard, like the sweep.
224
225
  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 21.5 });
@@ -314,4 +314,46 @@ Suite('figures alone are enough to surface the AI Model Settings section', () =>
314
314
  el.chatConfig = { ui: { showBudgetUsage: false } };
315
315
  assert.is(el.settingsModelSectionVisible, false, 'and the config flag takes it back out');
316
316
  });
317
+ // ── Freshness stamp + refresh request (GENC-1464 refresh affordance) ─────────
318
+ Suite('every feed stamps budgetMeterLastFedAt — identical figures included', () => {
319
+ const el = element();
320
+ assert.is(el.budgetMeterLastFedAt, null, 'null before any feed');
321
+ assert.is(el.settingsBudgetUpdatedLabel, undefined, 'no label before any feed');
322
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
323
+ const first = el.budgetMeterLastFedAt;
324
+ assert.ok(typeof first === 'number', 'stamped on feed');
325
+ assert.ok(el.settingsBudgetUpdatedLabel.startsWith('Updated '), 'label renders');
326
+ // The store is idempotent by value, but a refresh that CONFIRMS the same
327
+ // dollars is still a refresh — the stamp must move even when the rows don't.
328
+ el.budgetMeterLastFedAt = first - 5000; // age it so a same-ms re-feed still differs
329
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
330
+ assert.ok(el.budgetMeterLastFedAt > first - 5000, 'identical re-feed still stamps');
331
+ // Clearing figures is not a refresh — the label describes shown figures.
332
+ const before = el.budgetMeterLastFedAt;
333
+ el.setVendorBudget('anthropic', null);
334
+ assert.is(el.budgetMeterLastFedAt, before, 'a clear does not stamp');
335
+ });
336
+ Suite('requestBudgetRefresh emits budget-refresh-requested, and settings open asks too', () => {
337
+ const el = element();
338
+ let fired = 0;
339
+ el.addEventListener('budget-refresh-requested', () => {
340
+ fired += 1;
341
+ });
342
+ el.requestBudgetRefresh();
343
+ assert.is(fired, 1, 'the button path emits');
344
+ // The element cannot fetch — the event is the whole contract. Hosts without
345
+ // a listener simply keep the per-turn cadence, so firing is unconditional.
346
+ //
347
+ // The modal stub is load-bearing: openSettingsModal's `show` closure
348
+ // re-queues itself via DOM.queueUpdate until `settingsModal` exists, and on
349
+ // an unconnected element that is never — an infinite queue that keeps the
350
+ // test runner alive (this file's documented hang class). `open: true` makes
351
+ // the first queued run return immediately.
352
+ el.settingsModal = {
353
+ open: true,
354
+ show() { },
355
+ };
356
+ el.openSettingsModal();
357
+ assert.is(fired, 2, 'opening settings requests fresh figures');
358
+ });
317
359
  Suite.run();
@@ -265,6 +265,17 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
265
265
  * repeat the same warning on every send for the life of the page.
266
266
  */
267
267
  this.vendorBudgetWarningIssued = false;
268
+ /**
269
+ * When budget figures last arrived — from the host's {@link FoundationAiAssistant.setVendorBudget}
270
+ * or the wall latch's own feed — as epoch ms; `null` until the first feed.
271
+ *
272
+ * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
273
+ * value (an unchanged re-feed must not re-render the meter rows), but "when
274
+ * was this fetched" must move on every feed, changed figures or not — a
275
+ * refresh that confirms the same dollars is still a refresh. Only the
276
+ * toolbar label observes this, so a stamp re-renders one span, not the rows.
277
+ */
278
+ this.budgetMeterLastFedAt = null;
268
279
  // ---- Transient UI state (stays as @observable on the component) ----
269
280
  this._suggestionsGeneration = 0;
270
281
  this.attachments = [];
@@ -982,8 +993,36 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
982
993
  // must not adopt (and potentially freeze) state the host still owns.
983
994
  figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
984
995
  });
996
+ if (figures != null)
997
+ this.budgetMeterLastFedAt = Date.now();
985
998
  return true;
986
999
  }
1000
+ /** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
1001
+ get settingsBudgetUpdatedLabel() {
1002
+ if (this.budgetMeterLastFedAt == null)
1003
+ return undefined;
1004
+ return `Updated ${new Date(this.budgetMeterLastFedAt).toLocaleTimeString()}`;
1005
+ }
1006
+ /**
1007
+ * Ask the host to re-fetch budget figures now (GENC-1464 refresh affordance).
1008
+ *
1009
+ * The element cannot fetch — the meter is host-fed by design — so this emits
1010
+ * `budget-refresh-requested` and the host answers with the same fetch that
1011
+ * already feeds {@link FoundationAiAssistant.setVendorBudget} (Create's handler coalesces in-flight
1012
+ * fetches, so a spammed button costs one request). Fired by the meter's
1013
+ * refresh button and on settings-modal open, so the figures are fresh exactly
1014
+ * when someone is looking at them. Fires unconditionally: a host without a
1015
+ * listener simply keeps the per-turn cadence.
1016
+ */
1017
+ requestBudgetRefresh() {
1018
+ // Plain dispatchEvent, not FAST's $emit: $emit silently no-ops while the
1019
+ // element is disconnected, and this must stay observable in headless/test
1020
+ // harnesses that never connect (and in any odd pop-out lifecycle moment).
1021
+ // bubbles + composed, matching this class's other events (SessionCleared
1022
+ // et al): the element lives inside host shadow roots (Create nests it in
1023
+ // gc-assistant's), and a delegation-based host must still see the request.
1024
+ this.dispatchEvent(new CustomEvent('budget-refresh-requested', { bubbles: true, composed: true }));
1025
+ }
987
1026
  /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
988
1027
  isVendorSwept(vendor) {
989
1028
  var _a, _b;
@@ -1201,6 +1240,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1201
1240
  vendor,
1202
1241
  figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1203
1242
  });
1243
+ this.budgetMeterLastFedAt = Date.now();
1204
1244
  }
1205
1245
  if (!vendor || vendor === 'none') {
1206
1246
  if (!this.blocked) {
@@ -3459,6 +3499,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3459
3499
  }
3460
3500
  openSettingsModal() {
3461
3501
  this.logMeta('panel.toggled', { panel: 'settings', open: true });
3502
+ // The meter refreshes per turn, which a user staring at the modal cannot
3503
+ // see — ask the host for fresh figures at the moment they start looking.
3504
+ this.requestBudgetRefresh();
3462
3505
  this.settingsModalTab = 'settings';
3463
3506
  this.settingsOpen = true;
3464
3507
  const show = () => {
@@ -4375,6 +4418,12 @@ __decorate([
4375
4418
  __decorate([
4376
4419
  volatile
4377
4420
  ], FoundationAiAssistant.prototype, "reachableVendors", null);
4421
+ __decorate([
4422
+ observable
4423
+ ], FoundationAiAssistant.prototype, "budgetMeterLastFedAt", void 0);
4424
+ __decorate([
4425
+ volatile
4426
+ ], FoundationAiAssistant.prototype, "settingsBudgetUpdatedLabel", null);
4378
4427
  __decorate([
4379
4428
  volatile
4380
4429
  ], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
@@ -144,11 +144,30 @@ const subAgentToolResultTemplate = html `
144
144
  const subAgentMessageRowTemplate = html `
145
145
  ${when((m) => { var _a; return m.role === 'assistant' && !((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) && !!m.content; }, subAgentAssistantTemplate)}${when((m) => { var _a; return !!((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length); }, subAgentToolCallTemplate)}${when((m) => { var _a; return m.role === 'tool' && !!((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.content); }, subAgentToolResultTemplate)}
146
146
  `;
147
+ /**
148
+ * Label for a trace block: the distinct agents that appear in it.
149
+ *
150
+ * One tool call can invoke several sub-agents — a scheduler dispatching a dependency
151
+ * graph, or a retry — and their conversations arrive concatenated in one trace. Naming
152
+ * only `[0]` would then label a block "gen_A trace" while `gen_B`'s turns sit inside
153
+ * it. Two agents read as "gen_A, gen_B"; beyond that it degrades to a count rather
154
+ * than growing an unbounded summary line.
155
+ */
156
+ const subAgentTraceLabel = (trace) => {
157
+ const names = [...new Set(trace.map((m) => m.agentName).filter(Boolean))];
158
+ if (names.length === 0)
159
+ return 'Sub-agent trace';
160
+ if (names.length <= 2)
161
+ return `${names.join(', ')} trace`;
162
+ // Owns the whole string rather than returning a fragment the template suffixes, so the noun
163
+ // agrees with the count — a shared ` trace` suffix rendered "3 sub-agents trace".
164
+ return `${names.length} sub-agent traces`;
165
+ };
147
166
  /** Collapsed <details> trace shown inside a tool-call card once the sub-agent finishes. */
148
167
  const subAgentTraceTemplate = html `
149
168
  <details class="sub-agent-trace">
150
169
  <summary class="sub-agent-trace-summary">
151
- ${(tc) => { var _a, _b; return (_b = (_a = tc.subAgentTrace[0]) === null || _a === void 0 ? void 0 : _a.agentName) !== null && _b !== void 0 ? _b : 'Sub-agent'; }} trace
170
+ ${(tc) => subAgentTraceLabel(tc.subAgentTrace)}
152
171
  </summary>
153
172
  ${repeat((tc) => tc.subAgentTrace.filter((m) => m.role !== 'user'), subAgentMessageRowTemplate)}
154
173
  </details>
@@ -6,7 +6,7 @@
6
6
  * `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
7
7
  * form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
8
8
  * `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
9
- * `toolHandlers`).
9
+ * `thinkingPolicy`, `toolHandlers`).
10
10
  * 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
11
11
  * its object form is `{ name: handler }`, so `typeof` is `'object'`, not
12
12
  * `'function'`. A by-value check on the field alone misses it, leaking a live
@@ -2,7 +2,7 @@
2
2
  * Zeroed {@link AggregateUsage}. A factory, not a shared const, because callers
3
3
  * accumulate into the returned object.
4
4
  *
5
- * @internal
5
+ * @beta
6
6
  */
7
7
  export function emptyUsage() {
8
8
  return {
@@ -23,7 +23,7 @@ export function emptyUsage() {
23
23
  * NOT the count of distinct tokens in the conversation, so it grows
24
24
  * super-linearly with turn count and is expected to dwarf the context size.
25
25
  *
26
- * @internal
26
+ * @beta
27
27
  */
28
28
  export function totalTokens(usage) {
29
29
  return (usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + usage.outputTokens);
@@ -35,7 +35,7 @@ export function totalTokens(usage) {
35
35
  * proves. Pure rather than in-place because both operands are live state a caller
36
36
  * must not mutate.
37
37
  *
38
- * @internal
38
+ * @beta
39
39
  */
40
40
  export function addUsage(a, b) {
41
41
  return {
@@ -70,7 +70,40 @@ export function addUsage(a, b) {
70
70
  * Returns a zeroed total when no message carries usage — providers that report
71
71
  * none (e.g. Chrome built-in) are skipped silently rather than guessed at.
72
72
  *
73
- * @internal
73
+ * @remarks
74
+ * This sums the WHOLE array, which is the right answer only when the array is the
75
+ * whole of the work being billed. A driver whose conversation **continues across
76
+ * rounds** opens its history with turns an earlier round already paid for, so
77
+ * summing it again bills them twice — and the session total then grows
78
+ * quadratically in the number of rounds while every individual figure in it stays
79
+ * correct. Nothing about the result looks wrong, which is why it is called out
80
+ * here rather than left to the caller to notice.
81
+ *
82
+ * Do NOT solve this with a "sum from this message onwards" marker. Compaction
83
+ * *replaces* the messages it summarises — and compaction is one of this function's
84
+ * own call sites — so the marker message can be absent from history by the time
85
+ * the sum runs, leaving the caller to either fail or silently sum everything,
86
+ * which is the double-count the marker was meant to prevent.
87
+ *
88
+ * Instead hold a **banked baseline**: spend that predates the current transcript,
89
+ * carried as a value, with `usage = banked + live` composed via {@link addUsage}.
90
+ * `resolveBankedUsage` in `utils/cost-session-history.ts` is the worked form, and
91
+ * the shape of its signature is the load-bearing part. It separates three cases:
92
+ *
93
+ * 1. no prior record — a genuine first round, so nothing is banked;
94
+ * 2. prior record, and the live transcript already accounts for it (a restored
95
+ * session) — carry the prior record's own banked figure forward;
96
+ * 3. prior record, and the transcript starts empty — bank the prior record's
97
+ * whole total, because nothing in the transcript can re-derive it.
98
+ *
99
+ * Case 1 and case 3 take the same arithmetic and mean opposite things, so the
100
+ * prior record must be a **required** input with no zero-defaulting path: a
101
+ * caller who carries a transcript and forgets its baseline reports the entire
102
+ * prior conversation as this round's spend. Distinguishing 2 from 3 is a separate
103
+ * decision, and getting it wrong is destructive in the other direction — it
104
+ * rewrites a total down to the current page-load and drops earlier spend.
105
+ *
106
+ * @beta
74
107
  */
75
108
  export function sumUsage(messages) {
76
109
  const total = emptyUsage();
@@ -0,0 +1,90 @@
1
+ import { vendorOfModel } from '@genesislcap/foundation-ai';
2
+ /** Whether a message carries anything a ledger would record. */
3
+ function hasUsage(m) {
4
+ return (m.cost != null ||
5
+ m.externalCostUsd != null ||
6
+ m.inputTokens != null ||
7
+ m.outputTokens != null ||
8
+ m.cacheReadTokens != null ||
9
+ m.cacheWriteTokens != null);
10
+ }
11
+ /**
12
+ * Project a transcript into one row per billable unit of spend.
13
+ *
14
+ * @remarks
15
+ * Reads the messages and returns a table; it stores nothing, mutates nothing, and
16
+ * adds nothing to history. Use it where an **aggregate is not enough** — writing
17
+ * usage rows for per-project attribution, a cost dashboard, or auditing which turns
18
+ * came back unpriced. For a single total, `sumUsage` is the answer and this is the
19
+ * wrong tool.
20
+ *
21
+ * Walks the same shape `sumUsage` does, and is guaranteed to agree with it: summing
22
+ * `costUsd` and `externalCostUsd` across these rows equals `sumUsage(...).costUsd`
23
+ * over the same input, and the four token buckets reconcile likewise. That property
24
+ * is the point of the function — a per-call view that quietly disagrees with the
25
+ * total is worse than no per-call view, because both look right in isolation.
26
+ *
27
+ * Covers the two places spend hides:
28
+ *
29
+ * - **Sub-agent conversations.** A delegating tool call carries the child's entire
30
+ * conversation on `toolCall.subAgentTrace`, so a walk of top-level messages alone
31
+ * misses everything a delegating agent spent. A consumer that reimplemented this
32
+ * and omitted the branch reported $0.106 on a run that cost $0.234.
33
+ * - **Compactions.** A compaction deletes the messages it summarises, banking their
34
+ * spend on the summary. Those turns produce a single `source: 'compaction'` row.
35
+ *
36
+ * Messages carrying no usage at all (a user turn, a narration) produce no row.
37
+ *
38
+ * **Do not pass the result to `sumUsage`** — the types prevent it, and the reason is
39
+ * that both recurse, so a pre-flattened list would be counted twice. Both functions
40
+ * take raw history.
41
+ *
42
+ * @beta
43
+ */
44
+ export function usageRows(messages) {
45
+ return collectRows(messages, 0, undefined);
46
+ }
47
+ /** Map an already-bucketed {@link AggregateUsage} onto a row's token fields. */
48
+ function bucketsOf(usage) {
49
+ return {
50
+ uncachedInputTokens: usage.uncachedInputTokens,
51
+ cacheReadTokens: usage.cacheReadTokens,
52
+ cacheWriteTokens: usage.cacheWriteTokens,
53
+ outputTokens: usage.outputTokens,
54
+ };
55
+ }
56
+ function collectRows(messages, depth, subAgentOf) {
57
+ var _a, _b, _c, _d, _e, _f, _g;
58
+ const rows = [];
59
+ for (const m of messages) {
60
+ if (hasUsage(m)) {
61
+ const cacheReadTokens = (_a = m.cacheReadTokens) !== null && _a !== void 0 ? _a : 0;
62
+ const cacheWriteTokens = (_b = m.cacheWriteTokens) !== null && _b !== void 0 ? _b : 0;
63
+ rows.push(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ source: 'request', model: m.model,
64
+ // The driver stamps `ChatMessage.provider` from the resolved provider's own
65
+ // status, so it is AUTHORITATIVE and covers vendors the model allowlists do
66
+ // not — a server-proxied `gpt-5` turn is `provider: 'openai'` on the message
67
+ // and unknown to `vendorOfModel`. Prefer it; fall back to resolving the model
68
+ // id only for messages persisted before the field existed. Still left
69
+ // undefined when neither knows, rather than guessed at.
70
+ provider: (_c = m.provider) !== null && _c !== void 0 ? _c : (m.model ? vendorOfModel(m.model) : undefined) }, (m.cost != null && { costUsd: m.cost })), (m.externalCostUsd != null && { externalCostUsd: m.externalCostUsd })), {
71
+ // Uncached input is the REMAINDER, matching `sumUsage`: `inputTokens` is the
72
+ // whole prompt and the cache fields break it down. Clamped for the same
73
+ // reason — hand-edited history must not drive a total negative.
74
+ uncachedInputTokens: Math.max(0, ((_d = m.inputTokens) !== null && _d !== void 0 ? _d : 0) - cacheReadTokens - cacheWriteTokens), cacheReadTokens,
75
+ cacheWriteTokens, outputTokens: (_e = m.outputTokens) !== null && _e !== void 0 ? _e : 0 }), (m.agentName != null && { agentName: m.agentName })), { subAgentDepth: depth }), (subAgentOf != null && { subAgentOf })));
76
+ }
77
+ // Spend the compaction banked on this summary's behalf. Its buckets are already
78
+ // disjoint (it is an `AggregateUsage`), so they map across without the
79
+ // subtraction a per-message record needs.
80
+ const rolled = (_f = m.compaction) === null || _f === void 0 ? void 0 : _f.rolledUpUsage;
81
+ if (rolled) {
82
+ rows.push(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ source: 'compaction' }, (rolled.costUsd != null && { costUsd: rolled.costUsd })), bucketsOf(rolled)), (m.agentName != null && { agentName: m.agentName })), { subAgentDepth: depth }), (subAgentOf != null && { subAgentOf })));
83
+ }
84
+ for (const tc of (_g = m.toolCalls) !== null && _g !== void 0 ? _g : []) {
85
+ if (tc.subAgentTrace)
86
+ rows.push(...collectRows(tc.subAgentTrace, depth + 1, tc.id));
87
+ }
88
+ }
89
+ return rows;
90
+ }
@@ -0,0 +1,189 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { sumUsage } from './sum-usage';
3
+ import { usageRows } from './usage-rows';
4
+ const msg = (over) => (Object.assign({ role: 'assistant', content: '' }, over));
5
+ const rolled = (over) => (Object.assign({ costUsd: 0, uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }, over));
6
+ /** Every place spend can hide, in one transcript: a delegation and a compaction. */
7
+ const richHistory = () => [
8
+ msg({
9
+ role: 'compacted-summary',
10
+ content: 'summary',
11
+ compaction: {
12
+ compactedCount: 4,
13
+ rolledUpUsage: rolled({ costUsd: 0.05, uncachedInputTokens: 400, outputTokens: 60 }),
14
+ // The summariser's model — NOT what the banked spend ran on.
15
+ model: 'claude-haiku-4-5-20251001',
16
+ },
17
+ }),
18
+ msg({ role: 'user', content: 'go' }),
19
+ msg({
20
+ model: 'claude-sonnet-5',
21
+ agentName: 'boss',
22
+ cost: 0.01,
23
+ inputTokens: 1000,
24
+ cacheReadTokens: 900,
25
+ outputTokens: 50,
26
+ toolCalls: [
27
+ {
28
+ id: 'tc1',
29
+ name: 'delegate',
30
+ args: {},
31
+ subAgentTrace: [
32
+ msg({
33
+ model: 'gemini-2.5-flash',
34
+ agentName: 'worker',
35
+ cost: 0.12,
36
+ inputTokens: 500,
37
+ outputTokens: 200,
38
+ }),
39
+ ],
40
+ },
41
+ ],
42
+ }),
43
+ ];
44
+ const suite = createLogicSuite('usageRows');
45
+ suite('reconciles exactly with sumUsage over the same input', () => {
46
+ // THE guarantee. A per-call view that disagrees with the total is worse than none,
47
+ // because both look right in isolation — which is how the consumer's own copy
48
+ // under-reported for a month.
49
+ const history = richHistory();
50
+ const rows = usageRows(history);
51
+ const total = sumUsage(history);
52
+ const rowCost = rows.reduce((n, r) => { var _a, _b; return n + ((_a = r.costUsd) !== null && _a !== void 0 ? _a : 0) + ((_b = r.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
53
+ assert.ok(Math.abs(rowCost - total.costUsd) < 1e-12, `${rowCost} vs ${total.costUsd}`);
54
+ const bucket = (k) => rows.reduce((n, r) => n + r[k], 0);
55
+ assert.is(bucket('uncachedInputTokens'), total.uncachedInputTokens, 'uncached input');
56
+ assert.is(bucket('cacheReadTokens'), total.cacheReadTokens, 'cache read');
57
+ assert.is(bucket('cacheWriteTokens'), total.cacheWriteTokens, 'cache write');
58
+ assert.is(bucket('outputTokens'), total.outputTokens, 'output');
59
+ });
60
+ suite('emits a compaction row so banked spend is not lost from a per-call view', () => {
61
+ // The shortfall this function exists to close: the summary message carries no
62
+ // `cost` and no token fields of its own, so a naive per-message walk drops the
63
+ // spend of every turn the compaction deleted.
64
+ const rows = usageRows(richHistory());
65
+ const compactionRows = rows.filter((r) => r.source === 'compaction');
66
+ assert.is(compactionRows.length, 1);
67
+ assert.is(compactionRows[0].costUsd, 0.05);
68
+ assert.is(compactionRows[0].uncachedInputTokens, 400);
69
+ assert.is(compactionRows[0].model, undefined, 'no model — the banked spend may span several, and compaction.model is the summariser');
70
+ });
71
+ suite('recurses into sub-agent traces, tagging depth and the spawning call', () => {
72
+ const rows = usageRows(richHistory());
73
+ const child = rows.find((r) => r.model === 'gemini-2.5-flash');
74
+ assert.ok(child, 'the sub-agent turn produced a row');
75
+ assert.is(child.subAgentDepth, 1);
76
+ assert.is(child.subAgentOf, 'tc1');
77
+ assert.is(child.agentName, 'worker');
78
+ const parent = rows.find((r) => r.model === 'claude-sonnet-5');
79
+ assert.is(parent.subAgentDepth, 0);
80
+ assert.is(parent.subAgentOf, undefined);
81
+ });
82
+ suite('prefers the provider the driver stamped over re-deriving it from the model', () => {
83
+ // `ChatMessage.provider` comes from the resolved provider's own status, so it is
84
+ // authoritative and knows vendors the model allowlists do not. Re-deriving from the
85
+ // model id alone drops attribution for a server-proxied turn — exactly the rows a
86
+ // usage ledger cares most about getting right. PR review.
87
+ const rows = usageRows([
88
+ msg({ model: 'gpt-5', provider: 'openai', cost: 1, inputTokens: 1 }),
89
+ // Stamped provider disagrees with what the model id would derive: the stamp still wins.
90
+ msg({
91
+ model: 'claude-sonnet-5',
92
+ provider: 'chrome',
93
+ cost: 1,
94
+ inputTokens: 1,
95
+ }),
96
+ ]);
97
+ assert.equal(rows.map((r) => r.provider), ['openai', 'chrome']);
98
+ });
99
+ suite('resolves the provider from the model, and leaves it unset when unknown', () => {
100
+ const rows = usageRows([
101
+ msg({ model: 'claude-sonnet-5', cost: 1, inputTokens: 1 }),
102
+ msg({ model: 'gemini-2.5-flash', cost: 1, inputTokens: 1 }),
103
+ msg({ model: 'gpt-5', cost: 1, inputTokens: 1 }),
104
+ msg({ cost: 1, inputTokens: 1 }),
105
+ ]);
106
+ assert.equal(rows.map((r) => r.provider), ['anthropic', 'gemini', undefined, undefined], 'an unrecognised or absent model leaves provider unset rather than guessing');
107
+ });
108
+ suite('splits the prompt into buckets the same way sumUsage does', () => {
109
+ const [row] = usageRows([
110
+ msg({
111
+ model: 'claude-sonnet-5',
112
+ inputTokens: 1000,
113
+ cacheReadTokens: 600,
114
+ cacheWriteTokens: 300,
115
+ }),
116
+ ]);
117
+ assert.is(row.uncachedInputTokens, 100, 'the remainder, not an addition');
118
+ assert.is(row.cacheReadTokens, 600);
119
+ assert.is(row.cacheWriteTokens, 300);
120
+ });
121
+ suite('clamps a prompt whose cache buckets exceed the total', () => {
122
+ const [row] = usageRows([msg({ inputTokens: 100, cacheReadTokens: 900 })]);
123
+ assert.is(row.uncachedInputTokens, 0, 'never negative');
124
+ });
125
+ suite('leaves costUsd undefined when nothing was reported, rather than zero', () => {
126
+ // What the "carried usage but no cost" audit keys off. A zero here would read as a
127
+ // free call and the alarm would never fire.
128
+ const [row] = usageRows([msg({ model: 'claude-sonnet-5', inputTokens: 500, outputTokens: 10 })]);
129
+ assert.is(row.costUsd, undefined);
130
+ assert.is(row.source, 'request');
131
+ });
132
+ suite('records external (non-LLM) cost and counts it in the reconciliation', () => {
133
+ const history = [msg({ externalCostUsd: 0.4 }), msg({ model: 'claude-sonnet-5', cost: 0.1 })];
134
+ const rows = usageRows(history);
135
+ assert.is(rows.find((r) => r.externalCostUsd != null).externalCostUsd, 0.4);
136
+ const rowCost = rows.reduce((n, r) => { var _a, _b; return n + ((_a = r.costUsd) !== null && _a !== void 0 ? _a : 0) + ((_b = r.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
137
+ assert.ok(Math.abs(rowCost - sumUsage(history).costUsd) < 1e-12);
138
+ });
139
+ suite('produces no row for a message carrying no usage', () => {
140
+ assert.equal(usageRows([
141
+ msg({ role: 'user', content: 'hello' }),
142
+ msg({ content: 'thinking out loud', category: 'reasoning' }),
143
+ ]), []);
144
+ });
145
+ suite('is empty for an empty transcript', () => {
146
+ assert.equal(usageRows([]), []);
147
+ });
148
+ suite('reconciles on a nested delegation two levels deep', () => {
149
+ const history = [
150
+ msg({
151
+ model: 'claude-sonnet-5',
152
+ cost: 0.01,
153
+ inputTokens: 100,
154
+ toolCalls: [
155
+ {
156
+ id: 'a',
157
+ name: 'delegate',
158
+ args: {},
159
+ subAgentTrace: [
160
+ msg({
161
+ model: 'claude-sonnet-5',
162
+ cost: 0.02,
163
+ inputTokens: 200,
164
+ toolCalls: [
165
+ {
166
+ id: 'b',
167
+ name: 'delegate',
168
+ args: {},
169
+ subAgentTrace: [msg({ model: 'gemini-2.5-flash', cost: 0.04, inputTokens: 400 })],
170
+ },
171
+ ],
172
+ }),
173
+ ],
174
+ },
175
+ ],
176
+ }),
177
+ ];
178
+ const rows = usageRows(history);
179
+ assert.equal(rows.map((r) => r.subAgentDepth), [0, 1, 2]);
180
+ const rowCost = rows.reduce((n, r) => { var _a; return n + ((_a = r.costUsd) !== null && _a !== void 0 ? _a : 0); }, 0);
181
+ assert.ok(Math.abs(rowCost - sumUsage(history).costUsd) < 1e-12, 'still reconciles');
182
+ });
183
+ suite('does not mutate the transcript', () => {
184
+ const history = richHistory();
185
+ const before = JSON.stringify(history);
186
+ usageRows(history);
187
+ assert.is(JSON.stringify(history), before, 'a projection, not a transform');
188
+ });
189
+ suite.run();
package/dist/react.cjs CHANGED
@@ -49,8 +49,10 @@ const AiWavesIndicator = React.forwardRef(function AiWavesIndicator(props, ref)
49
49
  });
50
50
 
51
51
  const FoundationAiAssistant = React.forwardRef(function FoundationAiAssistant(props, ref) {
52
- const { onChatHeaderMousedown, onSessionCleared, children, ...rest } = props;
52
+ const { onBudgetRefreshRequested, onChatHeaderMousedown, onSessionCleared, children, ...rest } = props;
53
53
  const _innerRef = React.useRef(null);
54
+ const _onBudgetRefreshRequestedRef = React.useRef(onBudgetRefreshRequested);
55
+ _onBudgetRefreshRequestedRef.current = onBudgetRefreshRequested;
54
56
  const _onChatHeaderMousedownRef = React.useRef(onChatHeaderMousedown);
55
57
  _onChatHeaderMousedownRef.current = onChatHeaderMousedown;
56
58
  const _onSessionClearedRef = React.useRef(onSessionCleared);
@@ -58,11 +60,14 @@ const FoundationAiAssistant = React.forwardRef(function FoundationAiAssistant(pr
58
60
  React.useLayoutEffect(() => {
59
61
  const el = _innerRef.current;
60
62
  if (!el) return;
63
+ const _onBudgetRefreshRequestedFn = (e) => _onBudgetRefreshRequestedRef.current?.(e);
64
+ el.addEventListener('budget-refresh-requested', _onBudgetRefreshRequestedFn);
61
65
  const _onChatHeaderMousedownFn = (e) => _onChatHeaderMousedownRef.current?.(e);
62
66
  el.addEventListener('chat-header-mousedown', _onChatHeaderMousedownFn);
63
67
  const _onSessionClearedFn = (e) => _onSessionClearedRef.current?.(e);
64
68
  el.addEventListener('session-cleared', _onSessionClearedFn);
65
69
  return () => {
70
+ el.removeEventListener('budget-refresh-requested', _onBudgetRefreshRequestedFn);
66
71
  el.removeEventListener('chat-header-mousedown', _onChatHeaderMousedownFn);
67
72
  el.removeEventListener('session-cleared', _onSessionClearedFn);
68
73
  };
@@ -124,14 +129,14 @@ const AiChatInteractionWrapper = React.forwardRef(function AiChatInteractionWrap
124
129
  return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
125
130
  });
126
131
 
127
- const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
132
+ const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
128
133
  const { children, ...rest } = props;
129
- return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
134
+ return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
130
135
  });
131
136
 
132
- const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
137
+ const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
133
138
  const { children, ...rest } = props;
134
- return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
139
+ return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
135
140
  });
136
141
 
137
142
  module.exports = {
@@ -145,6 +150,6 @@ module.exports = {
145
150
  AgentPicker,
146
151
  AiChatBubble,
147
152
  AiChatInteractionWrapper,
148
- FoundationAiPopoutManager,
149
153
  AiChatMarkdown,
154
+ FoundationAiPopoutManager,
150
155
  };
package/dist/react.mjs CHANGED
@@ -47,8 +47,10 @@ export const AiWavesIndicator = React.forwardRef(function AiWavesIndicator(props
47
47
  });
48
48
 
49
49
  export const FoundationAiAssistant = React.forwardRef(function FoundationAiAssistant(props, ref) {
50
- const { onChatHeaderMousedown, onSessionCleared, children, ...rest } = props;
50
+ const { onBudgetRefreshRequested, onChatHeaderMousedown, onSessionCleared, children, ...rest } = props;
51
51
  const _innerRef = React.useRef(null);
52
+ const _onBudgetRefreshRequestedRef = React.useRef(onBudgetRefreshRequested);
53
+ _onBudgetRefreshRequestedRef.current = onBudgetRefreshRequested;
52
54
  const _onChatHeaderMousedownRef = React.useRef(onChatHeaderMousedown);
53
55
  _onChatHeaderMousedownRef.current = onChatHeaderMousedown;
54
56
  const _onSessionClearedRef = React.useRef(onSessionCleared);
@@ -56,11 +58,14 @@ export const FoundationAiAssistant = React.forwardRef(function FoundationAiAssis
56
58
  React.useLayoutEffect(() => {
57
59
  const el = _innerRef.current;
58
60
  if (!el) return;
61
+ const _onBudgetRefreshRequestedFn = (e) => _onBudgetRefreshRequestedRef.current?.(e);
62
+ el.addEventListener('budget-refresh-requested', _onBudgetRefreshRequestedFn);
59
63
  const _onChatHeaderMousedownFn = (e) => _onChatHeaderMousedownRef.current?.(e);
60
64
  el.addEventListener('chat-header-mousedown', _onChatHeaderMousedownFn);
61
65
  const _onSessionClearedFn = (e) => _onSessionClearedRef.current?.(e);
62
66
  el.addEventListener('session-cleared', _onSessionClearedFn);
63
67
  return () => {
68
+ el.removeEventListener('budget-refresh-requested', _onBudgetRefreshRequestedFn);
64
69
  el.removeEventListener('chat-header-mousedown', _onChatHeaderMousedownFn);
65
70
  el.removeEventListener('session-cleared', _onSessionClearedFn);
66
71
  };
@@ -122,12 +127,12 @@ export const AiChatInteractionWrapper = React.forwardRef(function AiChatInteract
122
127
  return React.createElement(customElements.getName(AiChatInteractionWrapperWC) ?? 'ai-chat-interaction-wrapper', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);
123
128
  });
124
129
 
125
- export const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
130
+ export const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
126
131
  const { children, ...rest } = props;
127
- return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
132
+ return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
128
133
  });
129
134
 
130
- export const AiChatMarkdown = React.forwardRef(function AiChatMarkdown(props, ref) {
135
+ export const FoundationAiPopoutManager = React.forwardRef(function FoundationAiPopoutManager(props, ref) {
131
136
  const { children, ...rest } = props;
132
- return React.createElement(customElements.getName(AiChatMarkdownWC) ?? 'ai-chat-markdown', { ...rest, ref }, children);
137
+ return React.createElement(customElements.getName(FoundationAiPopoutManagerWC) ?? 'foundation-ai-popout-manager', { ...rest, ref }, children);
133
138
  });