@matatbread/matbot-core 0.2.2 → 0.2.4

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.2.2",
7
+ "version": "0.2.4",
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.2.2"
38
+ "@matatbread/matbot-plugin-api": "^0.2.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0"
package/src/index.ts CHANGED
@@ -25,8 +25,11 @@ export {
25
25
  installPrincipalCarrier, currentPrincipal, tryCurrentPrincipal,
26
26
  runAs, enterPrincipal, createConstantPrincipalCarrier,
27
27
  contextSwitch, onContextQuiesce, flushIfQuiescent,
28
+ installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
29
+ lastActivityAt,
28
30
  } from '@matatbread/matbot-plugin-api';
29
31
  export * from './session.js';
32
+ export * from './usage.js';
30
33
  export * from './hooks.js';
31
34
  export * from './runner.js';
32
35
  export * from './session-runner.js';
package/src/runner.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type {
2
- Session, MessageContent,
2
+ Session, MessageContent, Usage,
3
3
  PipelineEvent, 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
+ import { currentUsageSink } from '@matatbread/matbot-plugin-api';
7
8
  import { HookRegistry } from './hooks.js';
8
9
  import { appendMessage, createMessage } from './session.js';
10
+ import { addUsage } from './usage.js';
9
11
 
10
12
  export interface RunSessionOpts {
11
13
  session: Session;
@@ -130,6 +132,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
130
132
  const pendingCalls: Array<{ id: string; name: string; input: unknown }> = [];
131
133
  const assistantParts: MessageContent[] = [];
132
134
  let textAcc = '';
135
+ let turnUsage: Usage | undefined;
133
136
 
134
137
  // One provider call. The outgoing array (system context + history, with screen's ephemeral
135
138
  // blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
@@ -172,6 +175,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
172
175
  pendingCalls.push({ id: ev.id, name: ev.name, input: ev.input });
173
176
  break;
174
177
  case 'usage':
178
+ turnUsage = addUsage(turnUsage, ev);
175
179
  yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
176
180
  ...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
177
181
  ...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
@@ -188,6 +192,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
188
192
  if (assistantParts.length > 0) {
189
193
  session = appendMessage(session, createMessage({
190
194
  role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
195
+ ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
191
196
  }));
192
197
  }
193
198
  await store.set(session.id, session);
@@ -210,6 +215,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
210
215
  content: assistantParts,
211
216
  traceId,
212
217
  providerName: config.provider,
218
+ ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
213
219
  });
214
220
  session = appendMessage(session, assistantMsg);
215
221
  } else {
@@ -257,6 +263,10 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
257
263
 
258
264
  let result: unknown;
259
265
  let isError = false;
266
+ // Token accounting for completions this tool runs (via singleTurn/complete) accrues into the
267
+ // ambient turn sink; slice off whatever it appends during *this* call to attribute it here.
268
+ const usageSink = currentUsageSink();
269
+ const usageMark = usageSink?.length ?? 0;
260
270
  const startedAt = Date.now();
261
271
 
262
272
  const toolCtx: ToolContext = {
@@ -299,7 +309,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
299
309
  tool, result, isError, durationMs: Date.now() - startedAt,
300
310
  });
301
311
 
302
- toolResults.push({ type: 'tool-result', id: tc.id, result, isError });
312
+ const callUsage = usageSink ? usageSink.slice(usageMark) : [];
313
+ toolResults.push({ type: 'tool-result', id: tc.id, result, isError,
314
+ ...(callUsage.length > 0 ? { usage: callUsage } : {}) });
303
315
  yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
304
316
  }
305
317
 
@@ -6,7 +6,7 @@ import type {
6
6
  import type { MatbotPlugin } from './plugin.js';
7
7
  import type { HookRegistry } from './hooks.js';
8
8
  import { appendMessage, createMessage } from './session.js';
9
- import { contextSwitch } from '@matatbread/matbot-plugin-api';
9
+ import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
10
10
  import { runSession } from './runner.js';
11
11
 
12
12
  export interface SessionRunnerDeps {
@@ -219,7 +219,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
219
219
  // the callback: an async iterator returned out of the scope would lose it before it pulls.
220
220
  // contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
221
221
  // deferred during it lands at this scope's quiescent edge — never mid-CAS.
222
- await contextSwitch(head.principal, async () => {
222
+ await contextSwitch(head.principal, () => withUsageScope(async () => {
223
223
  for await (const ev of runSession({
224
224
  session,
225
225
  config: { provider: head.provider, traceId: head.traceId },
@@ -242,7 +242,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
242
242
  })) {
243
243
  emit(s, ev);
244
244
  }
245
- });
245
+ }));
246
246
 
247
247
  // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
248
248
  // head-enqueue a robo follow-up (its own real turn, running next). Skipped on abort; runs
package/src/session.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Session, Message, MessageContent, MessageRole, Principal } from './types.js';
1
+ import type { Session, Message, MessageContent, MessageRole, Principal, Usage } from './types.js';
2
2
 
3
3
  export interface CreateSessionOpts {
4
4
  ownerPrincipal: Principal;
@@ -32,10 +32,12 @@ export function createSession(opts: CreateSessionOpts): Session {
32
32
  }
33
33
 
34
34
  export function appendMessage(session: Session, message: Message): Session {
35
+ // updatedAt tracks conversational activity: the new last message's timestamp (the lastActivityAt
36
+ // invariant), not a fresh `now()` — so it stays consistent with structural edits that preserve the tail.
35
37
  return {
36
38
  ...session,
37
39
  messages: [...session.messages, message],
38
- updatedAt: new Date().toISOString(),
40
+ updatedAt: message.createdAt,
39
41
  version: crypto.randomUUID(),
40
42
  };
41
43
  }
@@ -45,6 +47,7 @@ export function createMessage(opts: {
45
47
  content: MessageContent[];
46
48
  traceId: string;
47
49
  providerName?: string;
50
+ usage?: Usage;
48
51
  metadata?: Record<string, unknown>;
49
52
  }): Message {
50
53
  return {
@@ -54,6 +57,7 @@ export function createMessage(opts: {
54
57
  createdAt: new Date().toISOString(),
55
58
  traceId: opts.traceId,
56
59
  ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
60
+ ...(opts.usage !== undefined ? { usage: opts.usage } : {}),
57
61
  ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
58
62
  };
59
63
  }
@@ -33,7 +33,9 @@ export function createSingleTurnTool(services: MatbotMachine): Tool {
33
33
  signal: ctx.signal,
34
34
  ...(typeof args.system === 'string' ? { system: args.system } : {}),
35
35
  });
36
- yield { type: 'result', value: { text: res.text, usage: res.usage } };
36
+ // Usage is accounting, not conversation: it is captured ambiently (the host's complete() reports
37
+ // it into the turn's usage sink, attributed to this tool call) — the model gets only the text.
38
+ yield { type: 'result', value: { text: res.text } };
37
39
  },
38
40
  };
39
41
 
@@ -42,13 +44,13 @@ export function createSingleTurnTool(services: MatbotMachine): Tool {
42
44
  description:
43
45
  'Run a single-turn completion against a configured provider and return its reply. This is a ' +
44
46
  'one-shot call — not your own response: you send one `prompt` (and optional `system`), and get ' +
45
- 'back its text and token usage. Use it to consult a different model — e.g. a second, ' +
47
+ 'back its text. Use it to consult a different model — e.g. a second, ' +
46
48
  'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
47
49
  'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
48
50
  'a configured provider to switch models (list or add providers with the provider tool).\n\n' +
49
51
  'Parameters (TypeScript):\n' +
50
52
  '```ts\n' +
51
- '{ provider?: string; prompt: string; system?: string } // -> { text, usage: { inputTokens, outputTokens } }\n' +
53
+ '{ provider?: string; prompt: string; system?: string } // -> { text }\n' +
52
54
  '```',
53
55
  inputSchema: {
54
56
  type: 'object',
package/src/usage.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Message, Usage } from './types.js';
2
+
3
+ /** Fold one usage tally into a running total; optional fields are summed only when either side has them. */
4
+ export function addUsage(acc: Usage | undefined, next: Usage): Usage {
5
+ const a = acc ?? { inputTokens: 0, outputTokens: 0 };
6
+ const add = (x: number | undefined, y: number | undefined): number | undefined =>
7
+ x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
8
+ return {
9
+ inputTokens: a.inputTokens + next.inputTokens,
10
+ outputTokens: a.outputTokens + next.outputTokens,
11
+ ...(((c) => c !== undefined ? { costUsd: c } : {}))(add(a.costUsd, next.costUsd)),
12
+ ...(((c) => c !== undefined ? { cacheReadTokens: c } : {}))(add(a.cacheReadTokens, next.cacheReadTokens)),
13
+ ...(((c) => c !== undefined ? { cacheCreationTokens: c } : {}))(add(a.cacheCreationTokens, next.cacheCreationTokens)),
14
+ };
15
+ }
16
+
17
+ /**
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.
22
+ */
23
+ export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
24
+ const out = new Map<string, Usage>();
25
+ 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));
28
+ }
29
+ 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
+ }
33
+ }
34
+ }
35
+ return out;
36
+ }