@genesislcap/ai-assistant 15.12.0 → 15.13.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 (50) hide show
  1. package/dist/ai-assistant.api.json +245 -3
  2. package/dist/ai-assistant.d.ts +127 -7
  3. package/dist/chat-driver.cjs +79 -13
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +76 -12
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +452 -359
  8. package/dist/dts/chat-driver-node.d.ts +2 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +45 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +2 -0
  13. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +1 -0
  14. package/dist/dts/main/main.d.ts +20 -1
  15. package/dist/dts/main/main.d.ts.map +1 -1
  16. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  17. package/dist/dts/state/persistence/diagnostics.d.ts +65 -8
  18. package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
  19. package/dist/dts/state/persistence/index.d.ts +1 -1
  20. package/dist/dts/state/persistence/index.d.ts.map +1 -1
  21. package/dist/dts/state/persistence/session-persister.d.ts +4 -3
  22. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  23. package/dist/dts/utils/sum-usage.d.ts +20 -0
  24. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  25. package/dist/esm/chat-driver-node.js +12 -2
  26. package/dist/esm/components/chat-driver/chat-driver.js +60 -5
  27. package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +268 -0
  28. package/dist/esm/main/main.js +53 -28
  29. package/dist/esm/state/debug-event-log.js +7 -2
  30. package/dist/esm/state/persistence/diagnostics.js +79 -16
  31. package/dist/esm/state/persistence/diagnostics.test.js +174 -1
  32. package/dist/esm/state/persistence/index.js +1 -1
  33. package/dist/esm/state/persistence/session-persister.js +13 -5
  34. package/dist/esm/state/persistence/session-persister.test.js +31 -0
  35. package/dist/esm/utils/sum-usage.js +43 -0
  36. package/dist/esm/utils/sum-usage.test.js +45 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +17 -17
  39. package/src/chat-driver-node.ts +12 -2
  40. package/src/components/chat-driver/chat-driver.ts +107 -6
  41. package/src/components/chat-driver/chat-driver.turn-usage.test.ts +362 -0
  42. package/src/main/main.ts +52 -23
  43. package/src/state/debug-event-log.ts +7 -2
  44. package/src/state/persistence/diagnostics.test.ts +208 -1
  45. package/src/state/persistence/diagnostics.ts +117 -15
  46. package/src/state/persistence/index.ts +1 -1
  47. package/src/state/persistence/session-persister.test.ts +37 -0
  48. package/src/state/persistence/session-persister.ts +13 -5
  49. package/src/utils/sum-usage.test.ts +52 -1
  50. package/src/utils/sum-usage.ts +45 -0
@@ -465,6 +465,43 @@ Suite(
465
465
  },
466
466
  );
467
467
 
468
+ Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
469
+ // A turn entry is created BEFORE its model call and priced when the response returns,
470
+ // so a flush landing in between persists it unpriced. Emitting it exactly once more —
471
+ // when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
472
+ // `assembleDebugLog` collapses the pair back onto the priced copy.
473
+ const inFlight: DiagnosticEntry = {
474
+ kind: 'turn',
475
+ turnIndex: '0',
476
+ timestamp: '2026-01-01T00:00:01.000Z',
477
+ toolNames: [],
478
+ };
479
+ const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
480
+ await p.flushDiagnostics();
481
+ assert.is(f.appended.length, 1, 'the unpriced turn is captured');
482
+ assert.is(f.appended[0][0].usage, undefined);
483
+
484
+ const priced: DiagnosticEntry = {
485
+ ...inFlight,
486
+ usage: {
487
+ costUsd: 0.25,
488
+ uncachedInputTokens: 100,
489
+ cacheReadTokens: 900,
490
+ cacheWriteTokens: 0,
491
+ outputTokens: 40,
492
+ },
493
+ };
494
+ f.diagnosticEntries = [priced];
495
+ await p.flushDiagnostics();
496
+ assert.is(f.appended.length, 2, 'the priced copy is appended');
497
+ assert.equal(f.appended[1][0].usage, priced.usage);
498
+
499
+ // ...and only once. A throttled flush runs on every transcript change, so a turn that
500
+ // re-emitted per flush would multiply the biggest entries in the stream.
501
+ await p.flushDiagnostics();
502
+ assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
503
+ });
504
+
468
505
  Suite(
469
506
  'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
470
507
  async () => {
@@ -635,9 +635,10 @@ export class SessionPersister {
635
635
 
636
636
  /**
637
637
  * The diagnostic entries not yet appended this load — the forward-capture delta
638
- * (§5.8). Skips messages present at restore or already appended; emits each turn
639
- * (by turn-index) and meta-event (by index) once; appends a `meta-snapshot` only
640
- * when the block changed. Advances the shared per-session cursors as it goes.
638
+ * (§5.8). Skips messages present at restore or already appended; emits each meta-event
639
+ * (by index) once and each turn (by turn-index) once unpriced plus once more when its
640
+ * per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
641
+ * Advances the shared per-session cursors as it goes.
641
642
  *
642
643
  * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
643
644
  * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
@@ -663,9 +664,16 @@ export class SessionPersister {
663
664
  }
664
665
  delta.push(entry);
665
666
  } else if (entry.kind === 'turn') {
667
+ // A turn entry is created BEFORE its model call and priced when the response
668
+ // lands, so a flush that falls in between persists it unpriced. Emit it a second
669
+ // time once `usage` is there — keyed separately so it happens at most once per
670
+ // turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
671
+ // Without this the stitched lifetime log silently loses per-call cost for any
672
+ // turn that outlived a flush, which is most of the slow ones.
666
673
  const key = String(entry.turnIndex ?? '');
667
- if (cursors.emittedTurnKeys.has(key)) continue;
668
- cursors.emittedTurnKeys.add(key);
674
+ const emitKey = entry.usage ? `${key}::usage` : key;
675
+ if (cursors.emittedTurnKeys.has(emitKey)) continue;
676
+ cursors.emittedTurnKeys.add(emitKey);
669
677
  delta.push(entry);
670
678
  } else if (entry.kind === 'event') {
671
679
  const index = typeof entry.index === 'number' ? entry.index : -1;
@@ -1,6 +1,6 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
- import { addUsage, emptyUsage, sumUsage, totalTokens } from './sum-usage';
3
+ import { addUsage, emptyUsage, messageUsage, sumUsage, totalTokens } from './sum-usage';
4
4
 
5
5
  const suite = createLogicSuite('sumUsage');
6
6
 
@@ -137,4 +137,55 @@ suite('addUsage with an empty operand is the identity', () => {
137
137
  assert.equal(addUsage(emptyUsage(), a), a);
138
138
  });
139
139
 
140
+ suite('messageUsage is undefined for a message reporting nothing', () => {
141
+ // A user turn, or a display-only reasoning/narration split. Distinct from a zeroed
142
+ // usage: "this call reported nothing" is not "this call cost nothing".
143
+ assert.is(messageUsage(msg({ role: 'user', content: 'hi' })), undefined);
144
+ assert.is(messageUsage(msg({ content: 'thinking…', category: 'reasoning' })), undefined);
145
+ });
146
+
147
+ suite('messageUsage agrees with sumUsage over the same single message', () => {
148
+ // The two derivations are deliberately separate — `accumulate` mutates one
149
+ // accumulator rather than allocating per message — so pin them together. A drift
150
+ // here would put one figure on a turn entry and a different one in the session
151
+ // total, both looking plausible.
152
+ const cases: Partial<ChatMessage>[] = [
153
+ { inputTokens: 1000, cacheReadTokens: 700, cacheWriteTokens: 200, outputTokens: 50, cost: 0.4 },
154
+ { inputTokens: 400, outputTokens: 60 },
155
+ { inputTokens: 100, cacheReadTokens: 90, cacheWriteTokens: 90 },
156
+ { cost: 0.01, externalCostUsd: 0.02 },
157
+ ];
158
+ for (const over of cases) {
159
+ const m = msg(over);
160
+ assert.equal(
161
+ messageUsage(m),
162
+ sumUsage([m]),
163
+ `messageUsage must match sumUsage for ${JSON.stringify(over)}`,
164
+ );
165
+ }
166
+ });
167
+
168
+ suite('messageUsage is shallow — it prices the one request, not the tree', () => {
169
+ // Deliberately blind to sub-agent traces and banked compaction usage: it answers
170
+ // "what did this call cost", which is what a turn snapshot needs. `sumUsage` is the
171
+ // function that walks everything underneath.
172
+ const m = msg({
173
+ inputTokens: 100,
174
+ outputTokens: 10,
175
+ cost: 0.05,
176
+ toolCalls: [
177
+ {
178
+ id: 't1',
179
+ name: 'delegate',
180
+ args: {},
181
+ subAgentTrace: [msg({ inputTokens: 900, outputTokens: 90, cost: 0.5 })],
182
+ },
183
+ ],
184
+ });
185
+ const usage = messageUsage(m)!;
186
+ assert.is(usage.costUsd, 0.05, 'the child conversation is not folded in');
187
+ assert.is(usage.uncachedInputTokens, 100);
188
+ assert.ok(sumUsage([m]).costUsd > usage.costUsd, 'sumUsage does fold it in');
189
+ });
190
+
140
191
  suite.run();
@@ -53,6 +53,51 @@ export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
53
53
  };
54
54
  }
55
55
 
56
+ /**
57
+ * The four buckets plus USD for ONE message — the per-message fields projected into
58
+ * the disjoint, addable shape, or `undefined` when the message reports no usage at
59
+ * all (a user turn, a display-only reasoning/narration split, a provider that
60
+ * reports none).
61
+ *
62
+ * Shallow by design: it does NOT recurse into `toolCalls[].subAgentTrace` and does
63
+ * NOT count `compaction.rolledUpUsage`, so it answers "what did this one request
64
+ * cost" rather than "what did this message and everything under it cost". Use
65
+ * {@link sumUsage} for the latter — passing a whole transcript through this one
66
+ * message at a time would silently drop both.
67
+ *
68
+ * `costUsd` folds in `externalCostUsd` (non-LLM spend a widget reported), matching
69
+ * `sumUsage`. It is `0` when the provider priced nothing, which is NOT a claim that
70
+ * the call was free — see {@link AggregateUsage}; `UsageRow.costUsd` is the
71
+ * shape that keeps unpriced distinguishable, and is the right tool for a ledger.
72
+ *
73
+ * @beta
74
+ */
75
+ export function messageUsage(m: ChatMessage): AggregateUsage | undefined {
76
+ if (
77
+ m.cost == null &&
78
+ m.externalCostUsd == null &&
79
+ m.inputTokens == null &&
80
+ m.outputTokens == null &&
81
+ m.cacheReadTokens == null &&
82
+ m.cacheWriteTokens == null
83
+ ) {
84
+ return undefined;
85
+ }
86
+ const cacheReadTokens = m.cacheReadTokens ?? 0;
87
+ const cacheWriteTokens = m.cacheWriteTokens ?? 0;
88
+ return {
89
+ costUsd: (m.cost ?? 0) + (m.externalCostUsd ?? 0),
90
+ // The uncached REMAINDER, clamped — the same arithmetic `accumulate` below applies,
91
+ // for the same reasons. The two are deliberately separate implementations (that walk
92
+ // mutates one accumulator rather than allocating per message) and must agree;
93
+ // `sum-usage.test.ts` pins them together.
94
+ uncachedInputTokens: Math.max(0, (m.inputTokens ?? 0) - cacheReadTokens - cacheWriteTokens),
95
+ cacheReadTokens,
96
+ cacheWriteTokens,
97
+ outputTokens: m.outputTokens ?? 0,
98
+ };
99
+ }
100
+
56
101
  /**
57
102
  * Sum cost and per-bucket token usage across a message list.
58
103
  *