@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
@@ -7,7 +7,7 @@ import { createInteractionContext, } from '../../state/interaction-context';
7
7
  import { applyCondensation } from '../../utils/condense-history';
8
8
  import { applyHistoryCap, buildCompactionSummaryPrompt, findCompactionCut, normalizeForProvider, } from '../../utils/history-transform';
9
9
  import { logger } from '../../utils/logger';
10
- import { sumUsage } from '../../utils/sum-usage';
10
+ import { messageUsage, sumUsage } from '../../utils/sum-usage';
11
11
  import { TOOL_FOLD_SYMBOL } from '../../utils/tool-fold';
12
12
  /**
13
13
  * Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
@@ -683,7 +683,14 @@ export class ChatDriver extends EventTarget {
683
683
  if (resolvedName !== this.lastDispatchedProviderName) {
684
684
  this.lastDispatchedProviderName = resolvedName;
685
685
  recordMetaEvent(this.sessionKey, 'provider.selected', {
686
+ // `provider` is the registry SLOT (a tier name like 'high'), kept under that key
687
+ // for compatibility; `model` and `vendor` are what it resolved to. Recording all
688
+ // three is the difference between "the agent switched to its high tier" and
689
+ // knowing which model that actually was — a tier can be repointed mid-session,
690
+ // and a slot name alone cannot distinguish anthropic from gemini.
686
691
  provider: resolvedName,
692
+ model: status.model,
693
+ vendor: status.provider,
687
694
  agent: this.activeAgentName,
688
695
  });
689
696
  this.dispatchEvent(new CustomEvent('provider-changed', { detail: { name: resolvedName } }));
@@ -841,6 +848,10 @@ export class ChatDriver extends EventTarget {
841
848
  * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
842
849
  * before each LLM call — that's the latest point where the prompt, tool
843
850
  * surface, and agent state line up with what the model is about to see.
851
+ *
852
+ * Returns the pushed object so the caller can back-fill what only the response
853
+ * knows (`usage`). Mutating it after the fact is safe whether or not the ring
854
+ * buffer has since evicted it — an evicted snapshot is simply no longer exported.
844
855
  */
845
856
  recordTurnSnapshot(resolvedSystemPrompt, temperature, toolChoice, tailContext) {
846
857
  let agentSnapshot;
@@ -857,7 +868,7 @@ export class ChatDriver extends EventTarget {
857
868
  }
858
869
  const turnIndex = String(this.globalTurnIndex);
859
870
  this.globalTurnIndex += 1;
860
- this.turnSnapshots.push({
871
+ const snapshot = {
861
872
  turnIndex,
862
873
  timestamp: new Date().toISOString(),
863
874
  agentName: this.activeAgentName,
@@ -868,10 +879,12 @@ export class ChatDriver extends EventTarget {
868
879
  temperature,
869
880
  toolChoice,
870
881
  agentSnapshot,
871
- });
882
+ };
883
+ this.turnSnapshots.push(snapshot);
872
884
  if (this.turnSnapshots.length > this.maxTurnSnapshots) {
873
885
  this.turnSnapshots.shift();
874
886
  }
887
+ return snapshot;
875
888
  }
876
889
  /**
877
890
  * Optional transform applied to conversation history immediately before each LLM request.
@@ -2134,7 +2147,7 @@ export class ChatDriver extends EventTarget {
2134
2147
  // on a free-text answer; top-level agents stay 'auto'. (Transports no-op a
2135
2148
  // force when no tools are advertised.)
2136
2149
  const effectiveToolChoice = resolvedToolChoice !== null && resolvedToolChoice !== void 0 ? resolvedToolChoice : (this.isSubAgent ? 'required' : undefined);
2137
- this.recordTurnSnapshot(systemPrompt, resolvedTemperature, effectiveToolChoice, tailContext);
2150
+ const turnSnapshot = this.recordTurnSnapshot(systemPrompt, resolvedTemperature, effectiveToolChoice, tailContext);
2138
2151
  // Capture the pending user input, then clear the slots BEFORE the chat
2139
2152
  // call. `sendMessage` already appended the user message to `this.history`,
2140
2153
  // so on retries (empty / malformed) we must rely on history alone —
@@ -2180,6 +2193,20 @@ export class ChatDriver extends EventTarget {
2180
2193
  // here and cached for the agent's lifetime.
2181
2194
  // oxlint-disable-next-line no-await-in-loop
2182
2195
  const activeProvider = yield this.resolveProviderForTurn(promptCtx);
2196
+ // Attribute the turn to the tier/model it resolved. Stamped HERE, not inside
2197
+ // `recordTurnSnapshot`: the snapshot is taken before this line runs, so reading
2198
+ // `lastResolved*` there yields the PREVIOUS call's model — wrong on precisely the
2199
+ // turn where an agent's per-state `provider` selector switches tier, which is the
2200
+ // turn a reader is looking for. `model` is refined to the serving model once the
2201
+ // response lands (see below); until then — and on a call that throws — it is the
2202
+ // model we ASKED for, which is the only thing knowable at that point.
2203
+ if (this.lastResolvedProviderName !== undefined) {
2204
+ turnSnapshot.providerName = this.lastResolvedProviderName;
2205
+ }
2206
+ if (this.lastResolvedProvider !== undefined)
2207
+ turnSnapshot.provider = this.lastResolvedProvider;
2208
+ if (this.lastResolvedModel !== undefined)
2209
+ turnSnapshot.model = this.lastResolvedModel;
2183
2210
  let response;
2184
2211
  try {
2185
2212
  // oxlint-disable-next-line no-await-in-loop
@@ -2359,6 +2386,19 @@ export class ChatDriver extends EventTarget {
2359
2386
  if (this.lastResolvedProviderName !== undefined) {
2360
2387
  response.providerName = this.lastResolvedProviderName;
2361
2388
  }
2389
+ // Back-fill what this call cost onto the snapshot taken just before it, so the
2390
+ // exported debug log prices each model call next to the prompt/tools/state that
2391
+ // produced it (GENC-1480 follow-up). Stamped BEFORE the empty-response branch
2392
+ // below deliberately: a blank or refused response is billed and then thrown away,
2393
+ // so the snapshot is the only place that spend is ever recorded.
2394
+ turnSnapshot.usage = messageUsage(response);
2395
+ // Take the SERVING model over the requested one, now that it is known. `response.model`
2396
+ // was just filled from `lastResolvedModel` if the transport left it unset, so this is
2397
+ // the same rule the message gets — which is the point: a turn and the message it
2398
+ // produced must never disagree about which model ran, including when a server-side
2399
+ // fallback chain answered on a different model than the one we asked for.
2400
+ if (response.model !== undefined)
2401
+ turnSnapshot.model = response.model;
2362
2402
  const isThinkingStep = response.content && ((_f = response.toolCalls) === null || _f === void 0 ? void 0 : _f.length);
2363
2403
  const isEmptyResponse = !((_g = response.content) === null || _g === void 0 ? void 0 : _g.trim()) && !((_h = response.toolCalls) === null || _h === void 0 ? void 0 : _h.length);
2364
2404
  // A pre-output refusal (safety-classifier decline, e.g. Fable 5 `stop_reason: 'refusal'`)
@@ -2405,8 +2445,23 @@ export class ChatDriver extends EventTarget {
2405
2445
  // `sumCosts`/`sumTokens` don't double-count and `contextTokens` reads it. Reasoning/narration are
2406
2446
  // display-only (usage undefined) and are skipped when building the provider request. `model` /
2407
2447
  // `provider` / `providerName` stay on every split message so each is still attributed.
2448
+ //
2449
+ // EVERY usage field has to be cleared here, not just the three the invariant was
2450
+ // originally written against — keep this list in step with the usage fields on
2451
+ // `ChatMessage`. The cache buckets arrived later (GENC-1475) and were left riding
2452
+ // along on the copies, so a response carrying reasoning AND narration counted its
2453
+ // cache read/write volume three times in `sumUsage`/`usageRows` — invisible in the
2454
+ // cost total (which comes from `cost`) but wrong in every bucket display and in the
2455
+ // exported log.
2408
2456
  const { reasoning } = response, rest = __rest(response, ["reasoning"]);
2409
- const displayOnly = { cost: undefined, inputTokens: undefined, outputTokens: undefined };
2457
+ const displayOnly = {
2458
+ cost: undefined,
2459
+ externalCostUsd: undefined,
2460
+ inputTokens: undefined,
2461
+ outputTokens: undefined,
2462
+ cacheReadTokens: undefined,
2463
+ cacheWriteTokens: undefined,
2464
+ };
2410
2465
  if (reasoning) {
2411
2466
  this.appendToHistory(Object.assign(Object.assign(Object.assign({}, rest), displayOnly), { content: reasoning, toolCalls: undefined, category: 'reasoning' }));
2412
2467
  }
@@ -0,0 +1,268 @@
1
+ import { __awaiter } from "tslib";
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
4
+ import { messageUsage, sumUsage } from '../../utils/sum-usage';
5
+ // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
6
+ // jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
7
+ import './align-event-globals';
8
+ import { ChatDriver } from './chat-driver';
9
+ // ---------------------------------------------------------------------------
10
+ // Per-call usage on the turn snapshots — the four token buckets plus USD priced
11
+ // onto each `kind: 'turn'` entry of the exported debug log.
12
+ //
13
+ // The snapshot is captured BEFORE the model call (that is where the prompt, tool
14
+ // surface and agent state line up with what the model saw), so usage has to be
15
+ // back-filled when the response lands. Two things follow, and both are asserted
16
+ // here: a completed call's turn agrees with the message it produced, and a call
17
+ // that produced NO message (a blank response, retried) is still priced — that
18
+ // spend appears nowhere in the transcript.
19
+ // ---------------------------------------------------------------------------
20
+ const makeRegistry = (provider) => ({
21
+ get: () => provider,
22
+ default: () => provider,
23
+ defaultName: () => 'test',
24
+ names: () => ['test'],
25
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
26
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
27
+ });
28
+ /** Answers from a queue, then ends the turn with a plain unpriced reply. */
29
+ const scriptedProvider = (responses) => {
30
+ const queue = [...responses];
31
+ return {
32
+ chat: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = queue.shift()) !== null && _a !== void 0 ? _a : { role: 'assistant', content: 'done' }; }),
33
+ };
34
+ };
35
+ const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
36
+ const makeDriver = (config, provider) => {
37
+ const driver = new ChatDriver(makeRegistry(provider), {
38
+ maxToolIterations: 50,
39
+ maxFoldOperations: 5,
40
+ sessionKey: '',
41
+ });
42
+ driver.applyAgent(config);
43
+ return driver;
44
+ };
45
+ const def = (name) => ({
46
+ name,
47
+ description: `${name} tool`,
48
+ parameters: { type: 'object', properties: {} },
49
+ });
50
+ /** One priced response: a 1000-token prompt that was mostly a cache hit. */
51
+ const priced = (over = {}) => (Object.assign({ role: 'assistant', content: 'answer', cost: 0.1, inputTokens: 1000, cacheReadTokens: 900, cacheWriteTokens: 50, outputTokens: 20 }, over));
52
+ const suite = createLogicSuite('ChatDriver per-turn usage');
53
+ suite('prices each turn snapshot with the four buckets and USD of its own call', () => __awaiter(void 0, void 0, void 0, function* () {
54
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([priced()]));
55
+ yield driver.sendMessage('go');
56
+ const [turn] = driver.getTurnSnapshots();
57
+ assert.equal(turn.usage, {
58
+ costUsd: 0.1,
59
+ // The prompt split into disjoint buckets: 1000 total, 900 read + 50 written,
60
+ // so 50 uncached. A reader of the log gets this without re-deriving it.
61
+ uncachedInputTokens: 50,
62
+ cacheReadTokens: 900,
63
+ cacheWriteTokens: 50,
64
+ outputTokens: 20,
65
+ }, 'the turn carries its own call’s usage');
66
+ // The turn and the message it produced are the SAME charge, so they must agree
67
+ // exactly — a log whose two views of one call disagree is worse than one view.
68
+ assert.equal(turn.usage, sumUsage(driver.getHistory()), 'turn agrees with the transcript total');
69
+ }));
70
+ suite('prices every model call in a tool loop separately', () => __awaiter(void 0, void 0, void 0, function* () {
71
+ const driver = makeDriver(agent({
72
+ name: 'a',
73
+ toolDefinitions: [def('work')],
74
+ toolHandlers: { work: () => __awaiter(void 0, void 0, void 0, function* () { return 'worked'; }) },
75
+ }), scriptedProvider([
76
+ priced({ content: '', cost: 0.02, toolCalls: [{ id: 'w1', name: 'work', args: {} }] }),
77
+ priced({ cost: 0.03 }),
78
+ ]));
79
+ yield driver.sendMessage('go');
80
+ const snapshots = driver.getTurnSnapshots();
81
+ assert.is(snapshots.length, 2, 'one snapshot per model call, not per user turn');
82
+ assert.equal(snapshots.map((s) => { var _a; return (_a = s.usage) === null || _a === void 0 ? void 0 : _a.costUsd; }), [0.02, 0.03]);
83
+ }));
84
+ suite('prices a blank response that produced no message', () => __awaiter(void 0, void 0, void 0, function* () {
85
+ var _a;
86
+ // A blank turn is billed and then discarded before the retry, so the transcript can
87
+ // never account for it. The snapshot is the only record — which is the whole reason
88
+ // usage is stamped before the empty-response branch runs.
89
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([
90
+ priced({ content: '', cost: 0.07, outputTokens: 0 }),
91
+ priced({ cost: 0.03 }),
92
+ ]));
93
+ yield driver.sendMessage('go');
94
+ const snapshots = driver.getTurnSnapshots();
95
+ assert.is(snapshots.length, 2, 'the retried attempt has its own snapshot');
96
+ assert.is((_a = snapshots[0].usage) === null || _a === void 0 ? void 0 : _a.costUsd, 0.07, 'the discarded attempt is still priced');
97
+ assert.is(sumUsage(driver.getHistory()).costUsd, 0.03, 'and is genuinely absent from the transcript — that is the gap the snapshot fills');
98
+ }));
99
+ suite('leaves usage undefined when the provider reports none', () => __awaiter(void 0, void 0, void 0, function* () {
100
+ // Absent, not zeroed: "this provider reports no usage" (e.g. Chrome's built-in model)
101
+ // must not read as a free call.
102
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
103
+ yield driver.sendMessage('go');
104
+ assert.is(driver.getTurnSnapshots()[0].usage, undefined);
105
+ }));
106
+ suite.run();
107
+ // ---------------------------------------------------------------------------
108
+ // The reasoning/narration/answer split must not multiply the cache buckets.
109
+ //
110
+ // One model response becomes up to three messages; only the last carries usage.
111
+ // The clearing list was written when usage meant cost + input + output, so when the
112
+ // cache buckets arrived they kept riding along on the display-only copies — and every
113
+ // bucket aggregate (session totals, usage rows, the exported log) counted a split
114
+ // turn's cache volume two or three times. The cost total hid it, since that comes
115
+ // from `cost`, which WAS cleared.
116
+ // ---------------------------------------------------------------------------
117
+ const split = createLogicSuite('ChatDriver response split usage');
118
+ split('counts a reasoning + answer split once, not twice', () => __awaiter(void 0, void 0, void 0, function* () {
119
+ const response = priced({ reasoning: 'thinking…' });
120
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([response]));
121
+ yield driver.sendMessage('go');
122
+ const history = driver.getHistory();
123
+ assert.is(history.filter((m) => m.role === 'assistant').length, 2, 'the response did split into a reasoning message and an answer');
124
+ assert.equal(sumUsage(history), messageUsage(response), 'the split totals exactly one call’s usage');
125
+ const reasoning = history.find((m) => m.category === 'reasoning');
126
+ assert.is(reasoning.cacheReadTokens, undefined, 'the display-only copy carries no cache volume');
127
+ assert.is(reasoning.cacheWriteTokens, undefined);
128
+ }));
129
+ split('counts a reasoning + narration + answer split once', () => __awaiter(void 0, void 0, void 0, function* () {
130
+ // The three-way case: content alongside a tool call is interstitial narration, so this
131
+ // response produces reasoning + narration + the tool-call message. Worst case for the
132
+ // old behaviour — cache volume counted three times.
133
+ const first = priced({
134
+ content: 'let me look that up',
135
+ reasoning: 'thinking…',
136
+ cost: 0.02,
137
+ toolCalls: [{ id: 'w1', name: 'work', args: {} }],
138
+ });
139
+ const driver = makeDriver(agent({
140
+ name: 'a',
141
+ toolDefinitions: [def('work')],
142
+ toolHandlers: { work: () => __awaiter(void 0, void 0, void 0, function* () { return 'worked'; }) },
143
+ }), scriptedProvider([first, priced({ cost: 0.03 })]));
144
+ yield driver.sendMessage('go');
145
+ const total = sumUsage(driver.getHistory());
146
+ assert.is(total.costUsd, 0.05, 'two calls, each counted once');
147
+ assert.is(total.cacheReadTokens, 1800, '900 per call — not 2700 with the narration copies');
148
+ assert.is(total.cacheWriteTokens, 100);
149
+ assert.is(total.uncachedInputTokens, 100);
150
+ }));
151
+ split.run();
152
+ // ---------------------------------------------------------------------------
153
+ // Per-turn model attribution across a tier switch.
154
+ //
155
+ // The shape this exists for is an agent whose `provider` selector varies by state —
156
+ // e.g. the showcase trade-operations agent: `({state}) => isPlanning(state) ? High : Low`,
157
+ // planning on sonnet and executing on haiku. Each call has to name the model that ran
158
+ // it, on the turn itself: joining a turn to the message after it works for a normal
159
+ // step but not for a call that produced no message, and `provider.selected` fires only
160
+ // when the slot CHANGES, so the nearest event can be many turns back.
161
+ //
162
+ // The ordering trap: the snapshot is recorded BEFORE the provider is resolved for that
163
+ // call, so stamping `lastResolvedModel` at creation time yields the previous call's
164
+ // model — wrong on exactly the turn the tier changes.
165
+ // ---------------------------------------------------------------------------
166
+ const tiers = createLogicSuite('ChatDriver per-turn model attribution');
167
+ /** A tiered registry: named slots, each reporting its own model via `getStatus`. */
168
+ const tieredRegistry = (slots, defaultName) => ({
169
+ get: (name) => slots[name],
170
+ default: () => slots[defaultName],
171
+ defaultName: () => defaultName,
172
+ names: () => Object.keys(slots),
173
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
174
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
175
+ });
176
+ const tierProvider = (model, responses) => {
177
+ const queue = [...responses];
178
+ return {
179
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ model, provider: 'anthropic' }); }),
180
+ chat: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = queue.shift()) !== null && _a !== void 0 ? _a : { role: 'assistant', content: 'done' }; }),
181
+ };
182
+ };
183
+ tiers('attributes each step to the tier that ran it when the agent switches mid-flow', () => __awaiter(void 0, void 0, void 0, function* () {
184
+ // Responses deliberately carry NO `model` of their own — the common case for a
185
+ // transport that leaves attribution to the driver, and the one where a stale
186
+ // `lastResolved*` read would go unnoticed.
187
+ const slots = {
188
+ high: tierProvider('claude-sonnet-4-6', [
189
+ priced({
190
+ content: '',
191
+ cost: 0.05,
192
+ toolCalls: [{ id: 'p1', name: 'finish_planning', args: {} }],
193
+ }),
194
+ ]),
195
+ low: tierProvider('claude-haiku-4-5-20251001', [priced({ content: 'Booked.', cost: 0.001 })]),
196
+ };
197
+ // The "state" the selector reads, advanced by the tool — as a flow agent's machine does.
198
+ let planning = true;
199
+ const driver = new ChatDriver(tieredRegistry(slots, 'low'), {
200
+ maxToolIterations: 20,
201
+ maxFoldOperations: 5,
202
+ sessionKey: 'tiers',
203
+ });
204
+ driver.applyAgent(agent({
205
+ name: 'Trade Operations',
206
+ provider: () => (planning ? 'high' : 'low'),
207
+ toolDefinitions: [def('finish_planning')],
208
+ toolHandlers: {
209
+ finish_planning: () => __awaiter(void 0, void 0, void 0, function* () {
210
+ planning = false;
211
+ return 'planned';
212
+ }),
213
+ },
214
+ }));
215
+ yield driver.sendMessage('book me a trade');
216
+ const snapshots = driver.getTurnSnapshots();
217
+ assert.equal(snapshots.map((s) => { var _a; return [s.providerName, s.model, (_a = s.usage) === null || _a === void 0 ? void 0 : _a.costUsd]; }), [
218
+ ['high', 'claude-sonnet-4-6', 0.05],
219
+ ['low', 'claude-haiku-4-5-20251001', 0.001],
220
+ ], 'the planning call is attributed to the high tier and the execution call to the low one');
221
+ assert.equal(snapshots.map((s) => s.provider), ['anthropic', 'anthropic'], 'the vendor behind each slot is recorded too');
222
+ // A turn and the message it produced must never disagree about which model ran.
223
+ const assistantModels = driver
224
+ .getHistory()
225
+ .filter((m) => m.role === 'assistant' && m.cost != null)
226
+ .map((m) => m.model);
227
+ assert.equal(assistantModels, snapshots.map((s) => s.model));
228
+ }));
229
+ tiers('records the SERVING model when a fallback answers on another model', () => __awaiter(void 0, void 0, void 0, function* () {
230
+ // A server-side fallback chain answers on a different model than the one requested.
231
+ // The transport stamps the real one; the turn must follow it rather than relabel the
232
+ // call as the tier's configured model — that would misattribute the spend.
233
+ const slots = {
234
+ high: tierProvider('claude-fable-5', [
235
+ priced({ content: 'answered by the fallback', model: 'claude-opus-4-8' }),
236
+ ]),
237
+ };
238
+ const driver = new ChatDriver(tieredRegistry(slots, 'high'), {
239
+ maxToolIterations: 20,
240
+ maxFoldOperations: 5,
241
+ sessionKey: 'fallback',
242
+ });
243
+ driver.applyAgent(agent({ name: 'a', provider: 'high' }));
244
+ yield driver.sendMessage('go');
245
+ const [turn] = driver.getTurnSnapshots();
246
+ assert.is(turn.model, 'claude-opus-4-8', 'the model that answered, not the one asked for');
247
+ assert.is(turn.providerName, 'high', 'the slot asked for is still recorded');
248
+ }));
249
+ tiers('names the model and vendor on the provider.selected event, not just the slot', () => __awaiter(void 0, void 0, void 0, function* () {
250
+ var _a, _b, _c;
251
+ clearMetaEventRegistry();
252
+ const slots = {
253
+ high: tierProvider('claude-sonnet-4-6', [priced({ content: 'hi' })]),
254
+ };
255
+ const driver = new ChatDriver(tieredRegistry(slots, 'high'), {
256
+ maxToolIterations: 20,
257
+ maxFoldOperations: 5,
258
+ sessionKey: 'selected',
259
+ });
260
+ driver.applyAgent(agent({ name: 'a', provider: 'high' }));
261
+ yield driver.sendMessage('go');
262
+ const selected = getMetaEvents('selected').find((e) => e.type === 'provider.selected');
263
+ assert.ok(selected, 'a provider.selected event is recorded');
264
+ assert.is((_a = selected.detail) === null || _a === void 0 ? void 0 : _a.provider, 'high', 'the registry slot');
265
+ assert.is((_b = selected.detail) === null || _b === void 0 ? void 0 : _b.model, 'claude-sonnet-4-6', 'and the model behind it');
266
+ assert.is((_c = selected.detail) === null || _c === void 0 ? void 0 : _c.vendor, 'anthropic');
267
+ }));
268
+ tiers.run();
@@ -43,7 +43,7 @@ import { AssistantAppSettingsProvider, } from '../provider/assistant-app-setting
43
43
  import { recordMetaEvent, getMetaEvents, clearSession as clearMetaSession, DEBUG_LOG_README, } from '../state/debug-event-log';
44
44
  import { getOrCreateDriver, getDriver, getDriverAgentsKey, deleteDriver, } from '../state/driver-registry';
45
45
  import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
46
- import { assembleDebugLog } from '../state/persistence/diagnostics';
46
+ import { assembleDebugLog, withFreshMetaSnapshot } from '../state/persistence/diagnostics';
47
47
  import { deleteDiagnosticsCursorsFor, resetDiagnosticsCursorsFor, } from '../state/persistence/diagnostics-cursors';
48
48
  import { getOrCreatePersister, getPersister, deletePersister, } from '../state/persistence/persister-registry';
49
49
  import { SessionPersister, } from '../state/persistence/session-persister';
@@ -3690,7 +3690,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3690
3690
  // an already-enabled non-style animation (e.g. `halo`) would otherwise duplicate.
3691
3691
  this.enabledAnimations = resolveExclusiveLoadingStyle([...new Set(animations)], this.enabledAnimations);
3692
3692
  }
3693
- /** The live current-page debug log (`{ readme, timeline, meta }`). @public */
3693
+ /** The live current-page debug log (`{ readme, sessionUsage, timeline, meta }`). @public */
3694
3694
  getDebugLog() {
3695
3695
  return assembleDebugLog(this.buildDiagnosticEntries(), DEBUG_LOG_README);
3696
3696
  }
@@ -3703,7 +3703,38 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3703
3703
  * been appended to the persisted stream.
3704
3704
  */
3705
3705
  buildDiagnosticEntries() {
3706
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
3706
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
3707
+ const stateKey = this.getStateKey();
3708
+ // The message/turn/event timeline entries — built by the shared, pure `buildTimelineEntries`
3709
+ // (the same helper a headless consumer uses to harvest its own log), from the driver's pull
3710
+ // surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
3711
+ const timelineEntries = buildTimelineEntries({
3712
+ turnSnapshots: (_c = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getTurnSnapshots) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : [],
3713
+ messages: (_f = (_e = (_d = this.driver) === null || _d === void 0 ? void 0 : _d.getRawHistory) === null || _e === void 0 ? void 0 : _e.call(_d)) !== null && _f !== void 0 ? _f : this.messages,
3714
+ metaEvents: stateKey ? getMetaEvents(stateKey) : [],
3715
+ });
3716
+ // Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
3717
+ // ChatDriver whose collated log an interaction widget returned on its result). They ride the
3718
+ // same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
3719
+ // timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
3720
+ return [
3721
+ ...timelineEntries,
3722
+ ...((_j = (_h = (_g = this.driver) === null || _g === void 0 ? void 0 : _g.getExternalDiagnostics) === null || _h === void 0 ? void 0 : _h.call(_g)) !== null && _j !== void 0 ? _j : []),
3723
+ this.buildMetaSnapshot(),
3724
+ ];
3725
+ }
3726
+ /**
3727
+ * The single `meta-snapshot` entry for right now — the export-time `meta` block
3728
+ * (agent summary, active prompt/state, context + cost) plus the `dedupSignature`
3729
+ * the forward-capture delta keys on.
3730
+ *
3731
+ * Separate from `buildDiagnosticEntries` because the download path needs a
3732
+ * FRESH one on its own: the persisted stream only re-appends this block when the
3733
+ * near-static config changes, so the newest stored snapshot's volatile half — the
3734
+ * `context` figures especially — is typically frozen at the session's first flush.
3735
+ */
3736
+ buildMetaSnapshot() {
3737
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3707
3738
  const timestamp = new Date().toISOString().replace(/:/g, '-');
3708
3739
  // Snapshot the live active agent from the DRIVER — the instance whose
3709
3740
  // `onActivate` ran, so its `getDebugSnapshot` closure holds state.
@@ -3720,15 +3751,6 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3720
3751
  const contextUsagePercent = this.contextTokens != null && this.contextLimit != null && this.contextLimit > 0
3721
3752
  ? Math.round((this.contextTokens / this.contextLimit) * 100)
3722
3753
  : undefined;
3723
- const stateKey = this.getStateKey();
3724
- // The message/turn/event timeline entries — built by the shared, pure `buildTimelineEntries`
3725
- // (the same helper a headless consumer uses to harvest its own log), from the driver's pull
3726
- // surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
3727
- const timelineEntries = buildTimelineEntries({
3728
- turnSnapshots: (_f = (_e = (_d = this.driver) === null || _d === void 0 ? void 0 : _d.getTurnSnapshots) === null || _e === void 0 ? void 0 : _e.call(_d)) !== null && _f !== void 0 ? _f : [],
3729
- messages: (_j = (_h = (_g = this.driver) === null || _g === void 0 ? void 0 : _g.getRawHistory) === null || _h === void 0 ? void 0 : _h.call(_g)) !== null && _j !== void 0 ? _j : this.messages,
3730
- metaEvents: stateKey ? getMetaEvents(stateKey) : [],
3731
- });
3732
3754
  // The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
3733
3755
  // in the same forward stream (the latest one wins on reassembly, and the
3734
3756
  // accumulated history exposes config/state evolution across the lifetime).
@@ -3743,7 +3765,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3743
3765
  // like an object-form `toolHandlers`, and recurses subAgents — no manual
3744
3766
  // exclusion list to keep in sync. We only override toolDefinitions
3745
3767
  // afterwards to expand the fold tree for the log.
3746
- agentSummary: (_k = this.agents) === null || _k === void 0 ? void 0 : _k.map((a) => {
3768
+ agentSummary: (_d = this.agents) === null || _d === void 0 ? void 0 : _d.map((a) => {
3747
3769
  var _a;
3748
3770
  return (Object.assign(Object.assign({}, stripAgentHandlers(a)), { toolDefinitions: Array.isArray(a.toolDefinitions)
3749
3771
  ? typeof a.toolHandlers === 'function'
@@ -3755,10 +3777,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3755
3777
  ? '<dynamic — resolved per turn>'
3756
3778
  : [] }));
3757
3779
  }),
3758
- activeSystemPrompt: typeof ((_l = this.activeAgent) === null || _l === void 0 ? void 0 : _l.systemPrompt) === 'function'
3780
+ activeSystemPrompt: typeof ((_e = this.activeAgent) === null || _e === void 0 ? void 0 : _e.systemPrompt) === 'function'
3759
3781
  ? '<dynamic — resolved per turn>'
3760
- : (_m = this.activeAgent) === null || _m === void 0 ? void 0 : _m.systemPrompt,
3761
- activePrimerHistory: (_o = this.activeAgent) === null || _o === void 0 ? void 0 : _o.primerHistory,
3782
+ : (_f = this.activeAgent) === null || _f === void 0 ? void 0 : _f.systemPrompt,
3783
+ activePrimerHistory: (_g = this.activeAgent) === null || _g === void 0 ? void 0 : _g.primerHistory,
3762
3784
  activeFoldStack: this.driver instanceof ChatDriver ? this.driver.getActiveFoldNames() : undefined,
3763
3785
  // Context window + cost snapshot. `sessionCostUsd` is the chat-scoped
3764
3786
  // total (per-message `cost` summed); the transport's lifetime cost is
@@ -3780,7 +3802,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3780
3802
  // Snapshot captured fresh at log-export time — reflects state NOW, which
3781
3803
  // may have transitioned since the last LLM call.
3782
3804
  activeDebugSnapshot,
3783
- debug: (_p = this.debugStateFactory) === null || _p === void 0 ? void 0 : _p.call(this),
3805
+ debug: (_h = this.debugStateFactory) === null || _h === void 0 ? void 0 : _h.call(this),
3784
3806
  },
3785
3807
  };
3786
3808
  // Stable dedup signature for the forward-capture stream (GENC-1351 §5.8 / #12):
@@ -3791,6 +3813,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3791
3813
  // instead of "only when the block actually changed". The volatile evolution is
3792
3814
  // already in the timeline (turn snapshots + `context.updated` events), so the
3793
3815
  // persister only needs a fresh meta-snapshot when the config/prompt changes.
3816
+ // Consequence for the download path: the newest STORED snapshot's context/cost
3817
+ // figures are stale, which is why `buildDownloadLog` appends a fresh one.
3794
3818
  const m = metaSnapshot.meta;
3795
3819
  metaSnapshot.dedupSignature = JSON.stringify({
3796
3820
  host: m.host,
@@ -3799,15 +3823,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3799
3823
  activePrimerHistory: m.activePrimerHistory,
3800
3824
  activeFoldStack: m.activeFoldStack,
3801
3825
  });
3802
- // Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
3803
- // ChatDriver whose collated log an interaction widget returned on its result). They ride the
3804
- // same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
3805
- // timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
3806
- return [
3807
- ...timelineEntries,
3808
- ...((_s = (_r = (_q = this.driver) === null || _q === void 0 ? void 0 : _q.getExternalDiagnostics) === null || _r === void 0 ? void 0 : _r.call(_q)) !== null && _s !== void 0 ? _s : []),
3809
- metaSnapshot,
3810
- ];
3826
+ return metaSnapshot;
3811
3827
  }
3812
3828
  downloadDebugLog() {
3813
3829
  return __awaiter(this, void 0, void 0, function* () {
@@ -3844,6 +3860,14 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3844
3860
  * chat" — diagnostics are a forensic stream keyed on provider capability, not on
3845
3861
  * whether the *chat* is remembered (like preferences). Falls back to the live
3846
3862
  * current-page log when diagnostics isn't available or the fetch fails.
3863
+ *
3864
+ * The stored `meta-snapshot`s are replaced by a fresh one (`withFreshMetaSnapshot`)
3865
+ * before reassembly. The persisted stream only re-appends that block when the
3866
+ * near-static config signature changes (see `collectDiagnosticsDelta`), so on a session
3867
+ * whose config never changes the newest STORED snapshot is the first one — its
3868
+ * `context` half (session cost/usage, context tokens, live agent state) frozen seconds
3869
+ * into the session, which is how a lifetime log came out reporting near-zero spend
3870
+ * against a transcript full of priced messages.
3847
3871
  */
3848
3872
  buildDownloadLog() {
3849
3873
  return __awaiter(this, void 0, void 0, function* () {
@@ -3855,8 +3879,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3855
3879
  // Land the current page's unflushed delta first so the download includes it.
3856
3880
  yield ((_a = this.persister()) === null || _a === void 0 ? void 0 : _a.flushDiagnostics());
3857
3881
  const stored = yield provider.loadDiagnostics(key);
3858
- if (stored.length)
3859
- return assembleDebugLog(stored, DEBUG_LOG_README);
3882
+ if (stored.length) {
3883
+ return assembleDebugLog(withFreshMetaSnapshot(stored, this.buildMetaSnapshot()), DEBUG_LOG_README);
3884
+ }
3860
3885
  }
3861
3886
  catch (e) {
3862
3887
  logger.error('Diagnostics load failed — using current-page log:', e);
@@ -198,17 +198,22 @@ export function clearSession(key) {
198
198
  */
199
199
  export const DEBUG_LOG_README = [
200
200
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
201
+ "`sessionUsage` is what this session spent: `costUsd` (USD, provider-reported per request and summed, cache discounts already applied) plus the four TOKEN BUCKETS — `uncachedInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Those four are disjoint and safe to add up for a total-tokens figure; the per-message fields further down are NOT (see kind:'message'). Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a big token count against a small cost means the prompt was mostly cache hits, not a missing charge. It covers sub-agent turns and spend a compaction banked, so for the CONVERSATION it is the authoritative total: prefer it over re-summing the timeline, which can be short by whatever the ring buffers evicted. The same figures appear under `meta.context`. It is as current as the snapshot it came from: the assistant's own download stamps a fresh one, but a log stitched by another tool can carry an older one — compare `meta.timestamp` with the last timeline entry, and if it is well behind, read these totals as historical and fall back to summing the timeline. One scope caveat: it is derived from the transcript, so it excludes billed calls that produced no message (a blank or refused attempt that was retried — see kind:'turn'.`usage`). Summing the turns can therefore come out HIGHER than this, and the difference is exactly that discarded spend, not a double-count.",
202
+ "What 'session' means for those figures: the CURRENT conversation under this session key (a host-supplied per-project id, or the element id + header title when the host supplies none) — which is not necessarily everything in `timeline`. It is re-derived from the live transcript, so it spans page loads only when the chat is being remembered, and a Clear resets it to zero. `timeline` is append-only regardless: it keeps every page load and the pre-Clear conversation, with the session.cleared event as the boundary. So on a long-lived log, read these totals as the latest conversation's and the earlier sections as history — not as a total of the whole file.",
201
203
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
202
204
  'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',
203
205
  "kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, where `inputTokens` is the WHOLE prompt for that request and `cacheReadTokens`/`cacheWriteTokens` BREAK IT DOWN rather than add to it — uncached input is `inputTokens` minus those two, and adding the cache fields to `inputTokens` counts the prompt twice. The cache fields are absent on providers that report no cache split (Gemini reports reads only, since implicit caching bills no write) and on messages persisted before they existed, so read them as 0 when missing. Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a large token count at a small cost means the prompt was mostly cache hits. `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
204
206
  "Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\"<parent> › <sub-agent>\"` breadcrumb (composing when nested, e.g. `\"UI Builder › Planner › Grounding\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.",
205
207
  "kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
208
+ "kind:'turn'.`model`/`providerName`/`provider` — which model ran that call: the concrete model id, the registry slot it resolved under (a tier name like 'high'/'low', or the default), and the vendor. Recorded per CALL, so an agent whose `provider` selector varies by state — a flow that plans on a high tier and executes on a low one — has every step attributed, including calls that produced no message. `model` is the SERVING model where the provider reports one, so a turn answered by a server-side fallback names the model that answered rather than the one requested; it always matches the `model` on the message that call produced. Absent when the provider exposes no `getStatus` and the transport stamped nothing.",
209
+ "kind:'turn'.`usage` — what that ONE call cost, in the same four-bucket + `costUsd` shape as `sessionUsage`. Absent while a call is in flight and on providers that report no usage. This is the SAME money as the message the call produced, not extra money: never add turn usage to message usage, and read a turn plus its message as one charge. Its distinct value is the calls that produced NO message — a blank or refused response is billed and then discarded before the retry, so the turn entry is the only record of that spend, and a turn with `usage` but no message after it is exactly that. `costUsd: 0` alongside a nonzero token count means the provider reported no price, not that the call was free.",
206
210
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
207
211
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
208
212
  "Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
209
- 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (model/provider for the upcoming turns), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
213
+ 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
210
214
  'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
211
- "`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, token usage, session cost), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
215
+ "`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
216
+ 'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',
212
217
  'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
213
218
  ];
214
219
  /**