@yeaft/webchat-agent 1.0.608 → 1.0.610

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.608",
3
+ "version": "1.0.610",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -46,7 +46,7 @@ import { utf8PrefixWithinBytes } from '../utf8.js';
46
46
  * @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
47
47
  * @typedef {{ type: 'provider_activity' }} ProviderActivityEvent Content-free progress on a non-empty hidden delta; watchdog only, never UI/history.
48
48
  * @typedef {{ type: 'provider_state', providerState: object, providerStateBytes: number }} ProviderStateEvent Internal only; never forward to UI/search.
49
- * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean }} UsageEvent
49
+ * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean, reportedTokenFields?: string[], usageIncomplete?: boolean }} UsageEvent
50
50
  * @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
51
51
  * @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
52
52
  *
@@ -64,7 +64,7 @@ export function applyAnthropicThinking(body, model, effort, effortContext = {})
64
64
  }
65
65
  }
66
66
 
67
- import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage } from './provider-state.js';
67
+ import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
68
68
 
69
69
  const DEFAULT_BASE_URL = 'https://api.anthropic.com';
70
70
  const API_VERSION = '2023-06-01';
@@ -330,7 +330,7 @@ export class AnthropicAdapter extends LLMAdapter {
330
330
  const result = await response.json();
331
331
  // Account for consumed tokens even when the completed response cannot
332
332
  // safely publish tools. Budget consumers may abort on this usage event.
333
- yield { type: 'usage', inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
333
+ yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'anthropic'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
334
334
  cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
335
335
  ...reasoningUsage(result.usage, 'anthropic') };
336
336
  if (signal?.aborted) throw new LLMAbortError();
@@ -557,11 +557,13 @@ export class AnthropicAdapter extends LLMAdapter {
557
557
  }
558
558
  yield {
559
559
  type: 'usage',
560
+ reportedTokenFields: reportedUsageFields(event.usage, 'anthropic'),
561
+ usageIncomplete: !hasUsageCounts(event.usage, ['output_tokens']),
560
562
  ...reasoning,
561
563
  inputTokens: 0, // Only in message_start
562
564
  outputTokens,
563
565
  };
564
- }
566
+ } else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
565
567
  if (signal?.aborted) throw new LLMAbortError();
566
568
  const stopReason = event.delta?.stop_reason;
567
569
  if (stopReason === 'max_tokens' && hasToolBlocks()) {
@@ -598,13 +600,15 @@ export class AnthropicAdapter extends LLMAdapter {
598
600
  cumulativeReasoningTokens = reasoning.reasoningTokens || 0;
599
601
  yield {
600
602
  type: 'usage',
603
+ reportedTokenFields: reportedUsageFields(event.message.usage, 'anthropic'),
604
+ usageIncomplete: !hasUsageCounts(event.message.usage),
601
605
  ...reasoning,
602
606
  inputTokens: event.message.usage.input_tokens || 0,
603
607
  outputTokens: cumulativeOutputTokens,
604
608
  cacheReadTokens: event.message.usage.cache_read_input_tokens || 0,
605
609
  cacheWriteTokens: event.message.usage.cache_creation_input_tokens || 0,
606
610
  };
607
- }
611
+ } else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
608
612
  } else if (type === 'error') {
609
613
  stateFailed = true;
610
614
  yield {
@@ -52,7 +52,7 @@ import {
52
52
  mapEffortToOpenAIReasoning,
53
53
  } from '../models.js';
54
54
 
55
- import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage } from './provider-state.js';
55
+ import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
56
56
 
57
57
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
58
58
 
@@ -369,7 +369,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
369
369
  if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
370
370
  }
371
371
  const usage = result.usage || {};
372
- yield { type: 'usage', inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
372
+ yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'openai-responses'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
373
373
  cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0, cacheWriteTokens: 0,
374
374
  cacheTokensAreIncludedInInput: true,
375
375
  ...reasoningUsage(usage, 'openai-responses') };
@@ -528,6 +528,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
528
528
  const usage = respObj.usage || {};
529
529
  yield {
530
530
  type: 'usage',
531
+ reportedTokenFields: reportedUsageFields(respObj.usage, 'openai-responses'),
532
+ usageIncomplete: !hasUsageCounts(respObj.usage),
531
533
  inputTokens: usage.input_tokens || 0,
532
534
  outputTokens: usage.output_tokens || 0,
533
535
  cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0,
@@ -241,3 +241,16 @@ export function reasoningUsage(usage, protocol) {
241
241
  const value = protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens;
242
242
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? { reasoningTokens: value } : {};
243
243
  }
244
+
245
+ /** Which counters the provider actually reported, before legacy zero defaults. */
246
+ export function hasUsageCounts(usage, fields = ['input_tokens', 'output_tokens']) {
247
+ return fields.every(key => Number.isSafeInteger(usage?.[key]) && usage[key] >= 0);
248
+ }
249
+
250
+ export function reportedUsageFields(usage, protocol) {
251
+ const counts = { inputTokens: usage?.input_tokens, outputTokens: usage?.output_tokens,
252
+ cacheReadTokens: protocol === 'anthropic' ? usage?.cache_read_input_tokens : usage?.input_tokens_details?.cached_tokens,
253
+ cacheWriteTokens: protocol === 'anthropic' ? usage?.cache_creation_input_tokens : undefined,
254
+ reasoningTokens: protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens };
255
+ return Object.keys(counts).filter(key => Number.isSafeInteger(counts[key]) && counts[key] >= 0);
256
+ }
@@ -6,6 +6,7 @@ import { normalizeKnownProviderForRuntime } from '../llm/known-providers.js';
6
6
  import { normalizeEffort, resolveContextWindow, resolveMaxOutputTokens } from '../models.js';
7
7
  import { utf8PrefixWithinBytes } from '../utf8.js';
8
8
  import { bytes, digest, fail, LIMITS, PersonError } from './contracts.js';
9
+ import { addUsage } from './turn-diagnostics.js';
9
10
 
10
11
  export const MODEL_LIMITS = Object.freeze({ candidates: 8, available: 100 });
11
12
  export function validateModelCandidates(refs) {
@@ -92,7 +93,7 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
92
93
  const explicitImages = m.supportsImages ?? rawModel?.supportsImages ?? rawProvider?.supportsImages;
93
94
  const protocol = routedModel?.protocol || routedProvider?.protocol || inferProtocolFromModelId(m.id) || 'openai-responses';
94
95
  const imageBudget = !routedProvider || explicitImages === false ? null : imageInputBudget(m.id, protocol);
95
- return { id: m.ref || m.id, efforts, maxOutput, supportsImages: imageBudget !== null, imageBudget,
96
+ return { id: m.ref || m.id, efforts, maxOutput, usageCacheIncluded: protocol === 'anthropic' ? false : undefined, supportsImages: imageBudget !== null, imageBudget,
96
97
  contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
97
98
  }).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
98
99
  safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
@@ -147,8 +148,7 @@ export async function collectOutput(adapter, params, onEffort) {
147
148
  else if (event.type === 'error') fail('PROVIDER_FAILED');
148
149
  else if (event.type === 'tool_call') fail('INVALID_PROPOSAL');
149
150
  else if (event.type === 'usage') {
150
- usage = {};
151
- for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) if (Number.isFinite(event[key]) && event[key] >= 0) usage[key] = event[key];
151
+ usage = addUsage(usage, event, params.usageCacheIncluded);
152
152
  }
153
153
  // Hidden thinking, signatures, opaque provider state and raw HTTP exchanges are deliberately not archived.
154
154
  }
@@ -259,6 +259,7 @@ export class PersonRuntime {
259
259
  }) };
260
260
  output = await collectOutput(observingAdapter, {
261
261
  model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
262
+ usageCacheIncluded: provider.catalog.find(model => model.id === selection.model)?.usageCacheIncluded,
262
263
  system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
263
264
  }, decision => {
264
265
  effective.effort = decision.effective; effective.effortObserved = true;
@@ -7,6 +7,7 @@ import { loadConfig } from '../config.js';
7
7
  import { validateFiles } from './attachments.js';
8
8
  import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
9
9
  import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
10
+ import { turnsPage } from './turn-diagnostics.js';
10
11
  import { inspectRequest, personName, searchRequest } from './inspection.js';
11
12
 
12
13
  /**
@@ -125,6 +126,7 @@ export function createPersonService(options = {}) {
125
126
  }
126
127
  case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
127
128
  case 'messages': return repository.list(ownerId, 'messages', page(payload));
129
+ case 'turns': return repository.turns(ownerId, turnsPage(payload));
128
130
  // These reads deliberately bypass recover(), Recall and the runtime/provider.
129
131
  case 'inspect': {
130
132
  const request = inspectRequest(payload);
@@ -105,6 +105,7 @@ export class SqlitePersonRepository {
105
105
  async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
106
106
  async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
107
107
  async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
108
+ async turns(ownerId, options) { return this.request('turns', [ownerId, options]); }
108
109
  async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
109
110
  async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
110
111
  async search(ownerId, options) { return this.request('search', [ownerId, options]); }
@@ -75,6 +75,8 @@ CREATE TABLE IF NOT EXISTS traces (
75
75
  PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
76
76
  FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
77
77
  ) STRICT;
78
+ CREATE INDEX IF NOT EXISTS traces_turn_metadata ON traces(namespace, ownerId, personId,
79
+ json_extract(record, '$.episodeId'), seq);
78
80
  CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
79
81
  json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
80
82
  WHERE json_extract(record, '$.kind') = 'call_output';
@@ -9,6 +9,7 @@ import { SCHEMA, TABLES } from './sqlite-schema.js';
9
9
  import { attachmentMetadata, attachmentRequestHash, validateFiles } from './attachments.js';
10
10
  import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
11
11
  import { conceptView, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
12
+ import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
12
13
  import { inspectCapabilities } from './capabilities.js';
13
14
 
14
15
  const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
@@ -44,11 +45,14 @@ const publicOutput = (output, failed) => {
44
45
  if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
45
46
  if (Number.isFinite(output.usage[key]) && output.usage[key] >= 0) usage[key] = output.usage[key];
46
47
  }
48
+ if (usage && output.usage.accountingVersion === 1) usage.accountingVersion = 1;
49
+ if (usage && output.usage.accountingIncomplete === true) usage.accountingIncomplete = true;
50
+ if (usage && typeof output.usage.cacheTokensAreIncludedInInput === 'boolean') usage.cacheTokensAreIncludedInInput = output.usage.cacheTokensAreIncludedInInput;
47
51
  const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
48
52
  return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
49
53
  : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
50
54
  };
51
- const READS = new Set(['receipt', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
55
+ const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
52
56
  const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
53
57
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
54
58
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
@@ -445,6 +449,35 @@ export class SqlitePersonStore {
445
449
  ' AND (? IS NULL OR seq < ?) AND instr(text, ?) > 0 ORDER BY seq DESC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
446
450
  return inspectionPage(records, limit, 'seq', messageView);
447
451
  }
452
+ turns(ownerId, options = {}) {
453
+ const { cursor, limit } = turnsPage(options);
454
+ const scope = this.scope(ownerId);
455
+ if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
456
+ const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', json_extract(record, '$.${path}')`).join(', ')})`;
457
+ const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
458
+ FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
459
+ .all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
460
+ const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
461
+ requestedModel: 'requested.model', requestedEffort: 'requested.effort', effectiveEffort: 'effective.effort', effortObserved: 'effective.effortObserved',
462
+ selectionOrigin: 'selectionOrigin', reason: 'reason', code: 'code', terminalCode: 'terminalCode', outcome: 'outcome',
463
+ contextBytes: 'manifest.contextBytes', contextBudgetBytes: 'manifest.contextBudgetBytes', outputTokensReserved: 'manifest.outputTokensReserved' };
464
+ const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', json_extract(record, '$.output.usage.${key}')`).join(', ')},
465
+ 'accountingVersion', json_extract(record, '$.output.usage.accountingVersion'),
466
+ 'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
467
+ 'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
468
+ const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
469
+ 'usage', ${usage})`;
470
+ const items = episodes.slice(0, limit).map(episode => {
471
+ const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE}
472
+ AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
473
+ ('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq ASC`)
474
+ .all(...scopeValues(scope), episode.id).map(row => {
475
+ const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
476
+ });
477
+ return turnView(episode, events);
478
+ });
479
+ return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
480
+ }
448
481
  list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
449
482
  if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
450
483
  this.getPerson(ownerId); boundedLimit(limit);
@@ -0,0 +1,89 @@
1
+ import { fail, object } from './contracts.js';
2
+
3
+ export const TOKEN_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']);
4
+ const numeric = value => Number.isSafeInteger(value) && value >= 0;
5
+ const token = value => numeric(value) ? value : null;
6
+ const string = value => typeof value === 'string' ? value : null;
7
+
8
+ /** Turn cursor is an exclusive numeric episode watermark, never a trace offset. */
9
+ export function turnsPage(payload = {}) {
10
+ object(payload, ['cursor', 'limit'], []);
11
+ const { cursor = null, limit = 20 } = payload;
12
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20 ||
13
+ (cursor !== null && (!Number.isSafeInteger(cursor) || cursor < 1))) fail('INVALID_REQUEST');
14
+ return { cursor, limit };
15
+ }
16
+
17
+ /** Adapter usage events are additive. Keep unknown fields unknown, including cache semantics. */
18
+ export function addUsage(usage, event, inclusion) {
19
+ const result = { ...usage, accountingVersion: 1 };
20
+ if (event.usageIncomplete === true) result.accountingIncomplete = true;
21
+ for (const key of TOKEN_FIELDS) if ((!Array.isArray(event.reportedTokenFields) || event.reportedTokenFields.includes(key)) && numeric(event[key])) result[key] = (result[key] ?? 0) + event[key];
22
+ const flag = typeof event.cacheTokensAreIncludedInInput === 'boolean' ? event.cacheTokensAreIncludedInInput : inclusion;
23
+ if (typeof flag === 'boolean') {
24
+ if (typeof result.cacheTokensAreIncludedInInput === 'boolean' && result.cacheTokensAreIncludedInInput !== flag) result.cacheTokensAreIncludedInInput = null;
25
+ else if (result.cacheTokensAreIncludedInInput !== null) result.cacheTokensAreIncludedInInput = flag;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ export function callUsage(raw, completed) {
31
+ const usage = Object.fromEntries(TOKEN_FIELDS.map(key => [key, token(raw?.[key])]));
32
+ const reported = TOKEN_FIELDS.some(key => usage[key] !== null);
33
+ const caches = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
34
+ // Historical traces dropped the flag. Nonzero cache counts without it are ambiguous.
35
+ usage.inputTotalTokens = raw?.accountingVersion !== 1 || raw.accountingIncomplete === true || usage.inputTokens === null || (caches > 0 && typeof raw?.cacheTokensAreIncludedInInput !== 'boolean') ? null
36
+ : usage.inputTokens + (raw?.cacheTokensAreIncludedInInput === false ? caches : 0);
37
+ usage.totalTokens = usage.inputTotalTokens === null || usage.outputTokens === null ? null : usage.inputTotalTokens + usage.outputTokens;
38
+ // Reasoning is a subset of output, not an additional billable output quantity.
39
+ return { ...usage, reportedCalls: Number(reported), missingCalls: Number(!reported), complete: completed && usage.totalTokens !== null };
40
+ }
41
+
42
+ function sumUsage(calls, terminal) {
43
+ const fields = [...TOKEN_FIELDS, 'inputTotalTokens', 'totalTokens'];
44
+ const usage = Object.fromEntries(fields.map(key => {
45
+ const known = calls.map(call => call.usage[key]).filter(value => value !== null);
46
+ // Raw metrics sum reported values even for partial calls. Exact totals require every call.
47
+ return [key, !calls.length ? 0 : (key.endsWith('TotalTokens') || key === 'totalTokens') && known.length !== calls.length ? null
48
+ : known.length ? known.reduce((sum, value) => sum + value, 0) : null];
49
+ }));
50
+ return { ...usage, reportedCalls: calls.reduce((sum, call) => sum + call.usage.reportedCalls, 0),
51
+ missingCalls: calls.reduce((sum, call) => sum + call.usage.missingCalls, 0), complete: terminal && calls.every(call => call.usage.complete) };
52
+ }
53
+
54
+ /** Only metadata supplied by SQLite's explicit projection enters this view. */
55
+ export function turnView(episode, events) {
56
+ const calls = new Map();
57
+ for (const event of events) {
58
+ if (event.kind === 'call_started') {
59
+ calls.set(event.callId, { callId: event.callId, index: numeric(event.callIndex) ? event.callIndex + 1 : calls.size + 1,
60
+ status: 'running', requested: { model: string(event.requestedModel), effort: string(event.requestedEffort) },
61
+ // Native router dispatches the configured ref (only credential refresh can retry).
62
+ // It does not expose the response model: do not label configured intent as observed reality.
63
+ dispatched: { model: string(event.requestedModel) }, effective: { model: null, effort: null },
64
+ selectionOrigin: string(event.selectionOrigin), reason: string(event.reason), createdAt: event.createdAt, endedAt: null, code: null,
65
+ contextBytes: token(event.contextBytes), contextBudgetBytes: token(event.contextBudgetBytes), outputTokensReserved: token(event.outputTokensReserved),
66
+ usage: callUsage(null, false) });
67
+ continue;
68
+ }
69
+ const call = calls.get(event.callId);
70
+ if (!call) continue;
71
+ if (event.kind === 'call_output' || event.kind === 'call_failed') {
72
+ call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
73
+ call.endedAt = event.createdAt; call.code = string(event.code);
74
+ call.effective.effort = event.effortObserved === true ? string(event.effectiveEffort) : null;
75
+ call.usage = callUsage(event.usage, event.kind === 'call_output');
76
+ } else if (event.kind === 'proposal_rejected') {
77
+ call.status = 'rejected'; call.code = string(event.code);
78
+ } else if (event.kind === 'capability_started') {
79
+ call.capability = { id: string(event.capabilityId), status: 'running', code: null };
80
+ } else if (['capability_result', 'capability_failed', 'capability_finalized'].includes(event.kind)) {
81
+ call.capability = { id: string(event.capabilityId) ?? call.capability?.id ?? null,
82
+ status: (event.outcome ?? event.kind) === 'capability_failed' ? 'failed' : 'completed', code: string(event.code) ?? string(event.terminalCode) };
83
+ }
84
+ }
85
+ const items = [...calls.values()].sort((a, b) => a.index - b.index);
86
+ return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, createdAt: episode.createdAt,
87
+ endedAt: episode.endedAt, terminalCode: episode.terminalCode, budget: { calls: token(episode.calls), timeoutMs: token(episode.timeoutMs) },
88
+ models: [...new Set(items.map(call => call.dispatched.model).filter(Boolean))], usage: sumUsage(items, episode.status !== 'running'), calls: items };
89
+ }