@matatbread/matbot-core 0.4.1 → 0.4.3

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
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.1",
7
+ "version": "0.4.3",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.1"
38
+ "@matatbread/matbot-plugin-api": "^0.4.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/index.ts CHANGED
@@ -29,10 +29,12 @@ export {
29
29
  createBroadcaster, subscribable, createNotifier, scopedNotifier,
30
30
  installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
31
31
  contextSwitch, onContextQuiesce, flushIfQuiescent,
32
- installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
32
+ installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
33
+ currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
33
34
  } from '@matatbread/matbot-plugin-api/host';
34
35
  export type {
35
36
  Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
37
+ UsageScope,
36
38
  } from '@matatbread/matbot-plugin-api/host';
37
39
  export * from './session.js';
38
40
  export * from './usage.js';
package/src/registry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig } from './types.js';
1
+ import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig, FailedPlugin } from './types.js';
2
2
  import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
3
3
  import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
4
4
  import type {
@@ -11,14 +11,8 @@ import type { SettingsDoc } from './settings.js';
11
11
 
12
12
  // ── Internal state ────────────────────────────────────────────────────────────
13
13
 
14
- /**
15
- * A plugin the loader could not bring up — an incompatible runtime, an import that rejected, a
16
- * module that is not plugin-shaped, or a setup() throw. Recorded (not discarded) so the failure is
17
- * *graceful but not silent*: the `plugin` tool and web UI can show which configured plugins failed
18
- * and why, instead of the failure vanishing into a boot-time console line. `error` is pre-stringified
19
- * (sidesteps the `unknown` catch-var); `name` is present only when known before the failure.
20
- */
21
- export type FailedPlugin = { specifier: string; name?: string; error: string };
14
+ // `FailedPlugin` now lives in plugin-api: `plugin list` reports it, so it is part of that tool's
15
+ // contract, and the contract has to be declarable from a package this one can reach.
22
16
 
23
17
  // Mutable arrays/maps held in a single object to make _resetRegistry() simple.
24
18
  const state = {
package/src/runner.ts CHANGED
@@ -1,16 +1,33 @@
1
1
  import type {
2
- Session, Message, MessageContent, ModelContent, Usage, ProviderMeta, TruncatedToolResult,
2
+ Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
3
3
  TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
4
  Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
5
5
  } from './types.js';
6
6
  import type { MatbotPlugin } from './plugin.js';
7
7
  import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
8
- import { currentUsageSink } from '@matatbread/matbot-plugin-api/host';
8
+ import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
9
9
  import { HookRegistry } from './hooks.js';
10
10
  import { appendMessage, createMessage } from './session.js';
11
11
  import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
12
12
  import { addUsage } from './usage.js';
13
13
 
14
+ // Establish a usage call site across an async iteration. Wrapping the *loop* would not work: this
15
+ // runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
16
+ // context, losing any scope entered around the loop body. Scoping each `next()` instead means the
17
+ // tool's body — and anything it kicks off while running — always resumes under its own site.
18
+ function siteScoped<T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> {
19
+ return {
20
+ [Symbol.asyncIterator]: () => {
21
+ const it = src[Symbol.asyncIterator]();
22
+ return {
23
+ next: () => withUsageSite(site, () => it.next()),
24
+ ...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
25
+ ...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
26
+ };
27
+ },
28
+ };
29
+ }
30
+
14
31
  // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
15
32
  // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
16
33
  // internal token that reads as a real failure. A steer's continuation turn is the one place a model
@@ -239,6 +256,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
239
256
  const assistantParts: MessageContent[] = [];
240
257
  let textAcc = '';
241
258
  let turnUsage: Usage | undefined;
259
+ // The round's bracket — set where the provider call is actually issued, below, so it measures the
260
+ // call rather than the screen/contribute work that precedes it.
261
+ let roundStartedAt = Date.now();
262
+ // Book the round's own spend as an entry the moment the stream settles — including when the response
263
+ // is then thrown away (an in-situ restart) or the call died part-way. The tokens were billed either
264
+ // way; tying the record to whether the *answer* survived is what made a restarted round free. Clears
265
+ // as it books, so no exit path can double-count it.
266
+ const bookRound = (): void => {
267
+ const u = turnUsage;
268
+ if (u === undefined) return;
269
+ turnUsage = undefined;
270
+ withUsageSite({ kind: 'round', round }, () => recordUsage(config.provider, u, {
271
+ startedAt: new Date(roundStartedAt).toISOString(),
272
+ durationMs: Date.now() - roundStartedAt,
273
+ }));
274
+ };
242
275
  let truncation: { reason: 'max-tokens' | 'stream-end'; raw?: string } | undefined;
243
276
  // Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
244
277
  let restart = false;
@@ -264,14 +297,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
264
297
  // Tools advertised for THIS call. A presenter (optional) may present a subset of the snapshot and
265
298
  // grow it across iterations as the model discovers tools; absent ⇒ the whole snapshot. Withheld
266
299
  // tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
300
+ // Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
301
+ // that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
267
302
  const advertised = opts.toolPresenter
268
- ? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
303
+ ? await withUsageSite({ kind: 'round', round },
304
+ () => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
269
305
  : [...tools.values()];
270
306
  // Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
271
307
  // to cancel the in-flight request and stop backend generation, without aborting the whole turn.
272
308
  const callAc = new AbortController();
273
309
  const callSignal = AbortSignal.any([signal, callAc.signal]);
274
310
  try {
311
+ roundStartedAt = Date.now();
275
312
  for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
276
313
  // A raced verdict firing mid-generation changes the premise: discard this doomed response and
277
314
  // restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
@@ -308,9 +345,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
308
345
  case 'usage':
309
346
  turnUsage = addUsage(turnUsage, ev);
310
347
  yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
311
- ...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
312
348
  ...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
313
- ...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {}) };
349
+ ...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {}),
350
+ ...(ev.reported !== undefined ? { reported: ev.reported } : {}) };
314
351
  break;
315
352
  case 'done':
316
353
  break;
@@ -322,12 +359,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
322
359
  if (restart || (callAc.signal.aborted && !signal.aborted)) {
323
360
  restart = true;
324
361
  } else if (signal.aborted) {
362
+ bookRound();
325
363
  // Save whatever the LLM streamed before the abort hit.
326
364
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
327
365
  if (assistantParts.length > 0) {
328
366
  session = appendMessage(session, createMessage({
329
367
  role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
330
- ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
331
368
  }));
332
369
  }
333
370
  yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
@@ -343,10 +380,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
343
380
  // belong to the call that failed, and the model never finished the thought. The `error` event
344
381
  // carries no session (a failure is not a transcript), so this yields the terminal itself rather
345
382
  // than the session — but it commits first, exactly like every other exit.
383
+ bookRound();
346
384
  yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
347
385
  return;
348
386
  }
349
387
  }
388
+ bookRound();
350
389
 
351
390
  // In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
352
391
  // raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
@@ -370,7 +409,6 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
370
409
  content: assistantParts,
371
410
  traceId,
372
411
  providerName: config.provider,
373
- ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
374
412
  });
375
413
  session = appendMessage(session, assistantMsg);
376
414
  }
@@ -487,9 +525,10 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
487
525
  let result: unknown;
488
526
  let isError = false;
489
527
  // Token accounting for completions this tool runs (via singleTurn/complete) accrues into the
490
- // ambient turn sink; slice off whatever it appends during *this* call to attribute it here.
491
- const usageSink = currentUsageSink();
492
- const usageMark = usageSink?.length ?? 0;
528
+ // ambient turn scope, stamped with the site established below — never attached to this message.
529
+ // A tool message is popped wholesale by a retract-and-rerun, which would take its accounting
530
+ // somewhere no reduction over the session can reach; the entry lives on the turn head instead.
531
+ const site: UsageSite = { kind: 'tool', callId: tc.id, tool: tc.name };
493
532
  const startedAt = Date.now();
494
533
 
495
534
  const toolCtx: ToolContext = {
@@ -503,7 +542,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
503
542
  };
504
543
 
505
544
  try {
506
- for await (const toolEv of tool.executor.execute(tc.input, toolCtx)) {
545
+ for await (const toolEv of siteScoped(site, tool.executor.execute(tc.input, toolCtx))) {
507
546
  switch (toolEv.type) {
508
547
  case 'stdout': yield { type: 'tool:stdout', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
509
548
  case 'stderr': yield { type: 'tool:stderr', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
@@ -534,16 +573,23 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
534
573
 
535
574
  // toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
536
575
  // or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
576
+ const durationMs = Date.now() - startedAt;
577
+
578
+ // The bracket, recorded for EVERY tool call — not just the ones that spent tokens. Most tools
579
+ // (bash, http, workspace) run no completion at all, so hanging duration off an accounting record
580
+ // would capture it precisely for the tools that happen to call an LLM and lose it for the rest.
581
+ // Previously this number was computed, handed to the `toolresult` hook, and then discarded, so a
582
+ // consumer had to re-derive it — less accurately — from event arrival times.
583
+ recordSpan(site, new Date(startedAt).toISOString(), durationMs);
584
+
537
585
  result = await hookReg.runToolResult({
538
586
  session, config, signal,
539
587
  toolCall: { id: tc.id, name: tc.name, input: tc.input },
540
- tool, result, isError, durationMs: Date.now() - startedAt,
588
+ tool, result, isError, durationMs,
541
589
  });
542
590
 
543
- const callUsage = usageSink ? usageSink.slice(usageMark) : [];
544
- toolResults.push({ type: 'tool-result', id: tc.id, result, isError,
545
- ...(callUsage.length > 0 ? { usage: callUsage } : {}) });
546
- yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
591
+ toolResults.push({ type: 'tool-result', id: tc.id, result, isError });
592
+ yield { type: 'tool:end', callId: tc.id, result, isError, durationMs, traceId };
547
593
  }
548
594
 
549
595
  // Add tool results message, then loop for the next provider call.
@@ -2,7 +2,7 @@ import type {
2
2
  Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
3
3
  Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
4
4
  ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
5
- SteeringPolicy, SteeringDecision,
5
+ SteeringPolicy, SteeringDecision, TurnEntry,
6
6
  } from './types.js';
7
7
  import type { MatbotPlugin } from './plugin.js';
8
8
  import type { HookRegistry } from './hooks.js';
@@ -103,6 +103,11 @@ interface SessionState {
103
103
  // Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
104
104
  // turn boundary — committed history comes from the store, not from here.
105
105
  replay: PipelineEvent[];
106
+ // Accounting entries awaiting a flush, held BY REFERENCE to each turn's usage scope. A completion
107
+ // can be recorded after its turn commits — a detached trigger classifier, a `followup` hook — and it
108
+ // appends to the very array captured here, so a late arrival needs no coordination: whatever is in
109
+ // these arrays at the flush is what gets written, and each entry says which turn caused it.
110
+ pendingUsage: TurnEntry[][];
106
111
  }
107
112
 
108
113
  interface Sink {
@@ -163,7 +168,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
163
168
  const stateFor = (id: string): SessionState => {
164
169
  let s = states.get(id);
165
170
  if (s === undefined) {
166
- s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
171
+ s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [] };
167
172
  states.set(id, s);
168
173
  }
169
174
  return s;
@@ -190,6 +195,63 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
190
195
  // How many submissions sit ahead of the item at queue index `i` (the running turn counts as one).
191
196
  const aheadOf = (s: SessionState, i: number): number => (s.running ? 1 : 0) + i;
192
197
 
198
+ /**
199
+ * Write whatever accounting has accrued onto the turn heads that caused it, then clear it.
200
+ *
201
+ * Entries are drained by SPLICING each pending array, so an entry appended after this point (a
202
+ * classifier still in flight) accumulates afresh and lands on the next flush rather than being
203
+ * written twice. That is the whole concurrency story: no marks, no coordination, and eventual
204
+ * delivery — which is all a log needs, since each entry names the turn that caused it.
205
+ *
206
+ * Anchored on the turn's head (its user message) because that is the message a retract-and-rerun
207
+ * keeps; assistant and tool messages are popped into a retraction marker where no reduction over
208
+ * `session.messages` would find them again. An entry whose turn head has since gone (a session
209
+ * edit, a compaction) is dropped rather than orphaned onto an unrelated message.
210
+ */
211
+ const flushUsage = async (id: string, s: SessionState): Promise<void> => {
212
+ // Splice each array in place and KEEP the references. Dropping a drained array is the obvious
213
+ // tidy-up and it silently loses exactly the case this exists for: a classifier that settles after
214
+ // the flush appends to the array its scope captured, so if the pump has stopped holding it, the
215
+ // write goes nowhere observable. They are left registered for the life of the session state, at a
216
+ // cost of one empty array per turn.
217
+ //
218
+ // The limit of that: `maybeCleanup` disposes the state once the session is quiet and unsubscribed,
219
+ // so a completion still in flight at THAT point is lost. This is the carrier's stated best-effort
220
+ // contract rather than a hole to plug — nothing can know what is still pending — and it is bounded
221
+ // in practice, since the two channels that settle late (a `followup` hook, a user-phase classifier
222
+ // the runner polls) are both awaited inside the pump iteration this scope wraps.
223
+ const drained = s.pendingUsage.flatMap(entries => entries.splice(0));
224
+ if (drained.length === 0) return;
225
+
226
+ const session = await deps.store.get(id);
227
+ if (!session) return;
228
+
229
+ // Anchor on the entry's own turn head when it has one, and on its ROOT's otherwise. A
230
+ // retract-and-rerun re-runs an existing user turn under a fresh traceId and introduces no user
231
+ // message, so its entries have no head of their own — and the root is the honest home anyway, since
232
+ // a retry's cost belongs to the turn being retried. Without this the redo's spend was dropped
233
+ // outright, which is the very under-report anchoring on the turn head exists to prevent.
234
+ const heads = new Set(session.messages.filter(m => m.role === 'user').map(m => m.traceId));
235
+ const byTrace = new Map<string, TurnEntry[]>();
236
+ for (const e of drained) {
237
+ const anchor = e.traceId !== undefined && heads.has(e.traceId) ? e.traceId : e.rootTraceId;
238
+ if (anchor === undefined || !heads.has(anchor)) continue;
239
+ const list = byTrace.get(anchor);
240
+ if (list) list.push(e); else byTrace.set(anchor, [e]);
241
+ }
242
+
243
+ let changed = false;
244
+ const messages = session.messages.map(m => {
245
+ const add = m.role === 'user' ? byTrace.get(m.traceId) : undefined;
246
+ if (add === undefined) return m;
247
+ byTrace.delete(m.traceId);
248
+ changed = true;
249
+ return { ...m, activity: [...(m.activity ?? []), ...add] };
250
+ });
251
+ if (!changed) return;
252
+ await deps.store.set(id, { ...session, messages });
253
+ };
254
+
193
255
  const pump = async (id: string, s: SessionState): Promise<void> => {
194
256
  if (s.running) return;
195
257
  s.running = true;
@@ -298,7 +360,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
298
360
  // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
299
361
  // signal-driven aborts and not the policy ones.
300
362
  let terminal: PipelineEvent['type'] | undefined;
301
- await contextSwitch(head.principal, () => withUsageScope(async () => {
363
+ // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
364
+ // definition and a detached classifier settles whenever it settles, so a scope that ended at
365
+ // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
366
+ // The entries array is registered for flush before any work starts, so a late arrival lands
367
+ // in something already tracked and needs no coordination of its own.
368
+ await withUsageScope(async usage => {
369
+ s.pendingUsage.push(usage.entries);
370
+ await contextSwitch(head.principal, async () => {
302
371
  for await (const ev of runSession({
303
372
  session,
304
373
  config: { provider: head.provider, traceId: head.traceId },
@@ -323,7 +392,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
323
392
  if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
324
393
  emit(s, ev);
325
394
  }
326
- }));
395
+ });
327
396
 
328
397
  // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
329
398
  // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
@@ -439,6 +508,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
439
508
  }
440
509
  }
441
510
  }
511
+ }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
442
512
  } catch (e) {
443
513
  emit(s, { type: 'error', error: String(e), traceId: head.traceId });
444
514
  } finally {
@@ -449,6 +519,11 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
449
519
  s.running = false;
450
520
  s.runningTraceId = undefined;
451
521
  s.replay = [];
522
+ // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
523
+ // is not a well-defined moment to total anything at: steers terminate and resume, a retract
524
+ // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
525
+ // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
526
+ await flushUsage(id, s);
452
527
  // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
453
528
  // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
454
529
  // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
package/src/session.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Session, Message, MessageContent, MessageRole, Usage } from './types.js';
1
+ import type { Session, Message, MessageContent, MessageRole } from './types.js';
2
2
 
3
3
  export interface CreateSessionOpts {
4
4
  title?: string;
@@ -37,7 +37,6 @@ export function createMessage(opts: {
37
37
  content: MessageContent[];
38
38
  traceId: string;
39
39
  providerName?: string;
40
- usage?: Usage;
41
40
  metadata?: Record<string, unknown>;
42
41
  }): Message {
43
42
  return {
@@ -47,7 +46,6 @@ export function createMessage(opts: {
47
46
  createdAt: new Date().toISOString(),
48
47
  traceId: opts.traceId,
49
48
  ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
50
- ...(opts.usage !== undefined ? { usage: opts.usage } : {}),
51
49
  ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
52
50
  };
53
51
  }
@@ -47,9 +47,12 @@ export function executeQuery<T extends { id: string; version: string }>(docs: T[
47
47
  const slice = ordered.slice(page.offset, end);
48
48
  const next = page.offset + slice.length;
49
49
 
50
+ // `limit: 0` is the count form: the filter runs, `total` answers, no document is materialised. It
51
+ // gets no cursor — a zero-length page never advances `offset`, so one would page forever on the
52
+ // same empty slice.
50
53
  return {
51
54
  items: slice,
52
55
  total,
53
- ...(next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
56
+ ...(page.limit !== 0 && next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
54
57
  };
55
58
  }
package/src/usage.ts CHANGED
@@ -1,6 +1,13 @@
1
- import type { Message, Usage } from './types.js';
1
+ import type { Message, TurnEntry, Usage, UsageRecord } from './types.js';
2
2
 
3
- /** Fold one usage tally into a running total; optional fields are summed only when either side has them. */
3
+ /**
4
+ * Fold one usage tally into a running total; optional fields are summed only when either side has them.
5
+ *
6
+ * `reported` sums **numeric values key-wise** and drops the rest: a total `service_tier` is meaningless
7
+ * and a summed `latency_checkpoint` is nonsense, so non-numeric retained values stay per-call facts that
8
+ * a consumer reads off the entries. Callers must fold only within ONE provider — `usageByProvider` does,
9
+ * by grouping first — since the same key means different things under different protocols.
10
+ */
4
11
  export function addUsage(acc: Usage | undefined, next: Usage): Usage {
5
12
  const a = acc ?? { inputTokens: 0, outputTokens: 0 };
6
13
  const add = (x: number | undefined, y: number | undefined): number | undefined =>
@@ -8,29 +15,71 @@ export function addUsage(acc: Usage | undefined, next: Usage): Usage {
8
15
  return {
9
16
  inputTokens: a.inputTokens + next.inputTokens,
10
17
  outputTokens: a.outputTokens + next.outputTokens,
11
- ...(((c) => c !== undefined ? { costUsd: c } : {}))(add(a.costUsd, next.costUsd)),
12
18
  ...(((c) => c !== undefined ? { cacheReadTokens: c } : {}))(add(a.cacheReadTokens, next.cacheReadTokens)),
13
19
  ...(((c) => c !== undefined ? { cacheCreationTokens: c } : {}))(add(a.cacheCreationTokens, next.cacheCreationTokens)),
20
+ ...(((r) => r !== undefined ? { reported: r } : {}))(addReported(a.reported, next.reported)),
14
21
  };
15
22
  }
16
23
 
24
+ function addReported(
25
+ a: Record<string, unknown> | undefined,
26
+ b: Record<string, unknown> | undefined,
27
+ ): Record<string, unknown> | undefined {
28
+ if (a === undefined) return b;
29
+ if (b === undefined) return a;
30
+ const out: Record<string, unknown> = { ...a };
31
+ for (const [k, v] of Object.entries(b)) {
32
+ const prev = out[k];
33
+ out[k] = typeof v === 'number' && typeof prev === 'number' ? prev + v : v;
34
+ }
35
+ return out;
36
+ }
37
+
17
38
  /**
18
- * Aggregate the token accounting recorded on a set of messages, keyed by the provider billed: an
19
- * assistant turn's own `Message.usage` (billed to its `providerName`) plus every `tool-result` block's
20
- * `usage` records (each provider-tagged — a tool may run completions against several). Pass a single
21
- * turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
39
+ * Everything a set of messages records about what happened — provider calls and the brackets around
40
+ * them — in message order.
41
+ *
42
+ * Entries are anchored on turn heads and are self-describing (`site`, `traceId`), so this is a flat
43
+ * read with no correlation to do: filter it for whatever question is being asked — one turn
44
+ * (`traceId`), one tool call (`site`), one session (pass the lot) — and the same one fact set answers
45
+ * "what did this tool cost", "what did this user cost" and "how long did that take".
22
46
  */
23
- export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
24
- const out = new Map<string, Usage>();
47
+ export function turnActivity(messages: Iterable<Message>): TurnEntry[] {
48
+ const out: TurnEntry[] = [];
25
49
  for (const m of messages) {
26
- if (m.usage !== undefined && m.providerName !== undefined) {
27
- out.set(m.providerName, addUsage(out.get(m.providerName), m.usage));
50
+ if (m.activity !== undefined) out.push(...m.activity);
51
+
52
+ // Sessions written before accounting moved onto the turn head: an assistant message's own `usage`
53
+ // object, and a `tool-result`'s `usage` array. Neither shape is written any more.
54
+ const legacyOwn = (m as { usage?: Usage }).usage;
55
+ if (legacyOwn !== undefined && m.providerName !== undefined) {
56
+ out.push({ kind: 'call', provider: m.providerName, usage: legacyOwn, traceId: m.traceId });
28
57
  }
29
58
  for (const c of m.content) {
30
- if (c.type === 'tool-result' && c.usage !== undefined) {
31
- for (const r of c.usage) out.set(r.provider, addUsage(out.get(r.provider), r.usage));
32
- }
59
+ const legacy = c.type === 'tool-result' ? (c as { usage?: UsageRecord[] }).usage : undefined;
60
+ if (legacy !== undefined) out.push(...legacy.map((r): TurnEntry => ({ kind: 'call', ...r })));
33
61
  }
34
62
  }
35
63
  return out;
36
64
  }
65
+
66
+ /** The provider calls in a set of messages — `turnActivity` less the spans. */
67
+ export function usageEntries(messages: Iterable<Message>): UsageRecord[] {
68
+ return turnActivity(messages).filter((e): e is { kind: 'call' } & UsageRecord => e.kind === 'call');
69
+ }
70
+
71
+ /**
72
+ * Aggregate the token accounting on a set of messages, keyed by the provider billed. Pass a single
73
+ * turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
74
+ *
75
+ * Note what this deliberately does NOT do: sum a provider's `reported` fields across providers. The
76
+ * same key means different things under different protocols (`prompt_tokens` includes cache hits,
77
+ * `input_tokens` does not), so only the normalised counters are comparable across a mixed turn.
78
+ */
79
+ export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
80
+ const out = new Map<string, Usage>();
81
+ for (const r of usageEntries(messages)) {
82
+ out.set(r.provider, addUsage(out.get(r.provider), r.usage));
83
+ }
84
+ return out;
85
+ }