@matatbread/matbot-plugin-api 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
  },
package/src/hooks.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
5
  import { foldOntoUserTurn } from './session.js';
6
+ import { withUsageSite } from './usage-context.js';
6
7
 
7
8
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
8
9
 
@@ -43,9 +44,18 @@ export class HookRegistry implements HookRegistrar {
43
44
 
44
45
  // Run one handler, isolating a throw: log it, record it for a one-time marker, and return undefined
45
46
  // — which every run* method already treats as "no contribution from this hook".
47
+ //
48
+ // Also the accounting push site for the `hook` call site: a handler that runs a completion (a trigger
49
+ // classifier, a router) records against the channel and plugin that owns it. Established here rather
50
+ // than per-channel because it is the one place every handler invocation passes through — and because
51
+ // the site must be in force when the handler *starts*, so work it kicks off detached stays attributed
52
+ // to it however late it settles.
46
53
  private async invoke<R>(hook: Hook, run: () => R | Promise<R>): Promise<R | undefined> {
47
54
  try {
48
- return await run();
55
+ return await withUsageSite(
56
+ { kind: 'hook', channel: hook.on, ...(hook.pluginName !== undefined ? { plugin: hook.pluginName } : {}) },
57
+ run,
58
+ );
49
59
  } catch (err) {
50
60
  const message = err instanceof Error ? err.message : String(err);
51
61
  const owner = hook.pluginName !== undefined ? ` from "${hook.pluginName}"` : '';
package/src/plugin.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
5
  TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
6
6
  } from './types.js';
7
- import type { Notifier } from './notify.js';
7
+ import type { Notifications, Notifier } from './notify.js';
8
8
 
9
9
  /**
10
10
  * The plugin API contract version: this package's `major.minor`, and nothing else. It sat at `'0.1'`
@@ -150,6 +150,9 @@ export interface MatbotServices {
150
150
  * registered by a partitioning storage backend. Absent ⇒ frontends use the plain `fileStore.watch()`.
151
151
  * See {@link WatchVisibility}. */
152
152
  readonly WatchVisibility?: WatchVisibility | undefined;
153
+ /** Optional file-partition addressing, registered by the same partitioning storage backend as
154
+ * {@link WatchVisibility}. Absent ⇒ one undivided file area. See {@link FilePartition}. */
155
+ readonly FilePartition?: FilePartition | undefined;
153
156
  /** Optional steering policy: decides how a mid-turn submission under `mode: 'auto'` is disposed
154
157
  * (queue vs interrupt) and supplies the nudge folded onto an interrupt's continuation. Absent ⇒ the
155
158
  * runner uses its own defaults. A plain registered service, consumed by the runner as a member.
@@ -360,27 +363,91 @@ export interface StorageBackend {
360
363
  close?(): Promise<void>;
361
364
  }
362
365
 
366
+ /**
367
+ * What a {@link WatchVisibility} predicate decides on. A named object rather than positional parameters
368
+ * because `kind`/`namespace`/`id` are three adjacent strings around two `Principal`s — an ordering an
369
+ * implementation could get wrong silently.
370
+ *
371
+ * Populated by whoever consults the predicate; a frontend firehose fills it from the notification it is
372
+ * about to fan out.
373
+ */
374
+ export interface VisibilityQuery {
375
+ /** The principal asking — the owner of the connection this fan-out would reach. */
376
+ viewer: Principal;
377
+ /** The notification's `kind`. Present so an implementation can apply a policy PER KIND: some kinds are
378
+ * global, some are checked against the recipient (`viewer`), some against the sender (`origin`). Typed
379
+ * as the registry key for narrowing, but **open at runtime** like every `kind` — a plugin outside this
380
+ * build, or a bridged remote, can supply one, so `default` your switch and never assume exhaustiveness. */
381
+ kind: keyof Notifications;
382
+ /** Routing namespace of the changed item — the axis a profile isolates. Absent when the kind addresses
383
+ * no item (a `RegistryChange`, or a plugin kind carrying a measurement or an external event). */
384
+ namespace?: string;
385
+ /** Store id of the changed item; drives the shared-in check. Absent with `namespace`. */
386
+ id?: string;
387
+ /** Whose data the fact is about — `NotificationBase.principal`, the ownership axis, NOT attribution.
388
+ * Absent ⇒ a global fact. */
389
+ origin?: Principal;
390
+ }
391
+
363
392
  /**
364
393
  * The per-connection visibility layer, registered by a storage backend that partitions per principal (the
365
394
  * profiles backend). Consumed by a frontend firehose to decide, per SSE connection, whether a given
366
395
  * notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
367
- * sees everything. Generic across kinds: it answers on (`namespace`, `id`, `origin`), so files, skills and
368
- * stores all route through the one predicate.
396
+ * sees everything.
397
+ *
398
+ * **The predicate is consulted for EVERY kind, and decides for itself which it filters.** It used to be
399
+ * reached only for an `ItemChange` carrying a principal, because the caller gated on that — which left
400
+ * every plugin-defined kind fanned out unfiltered with no hook capable of stopping it, and made `kind` a
401
+ * constant not worth passing. Moving that judgement here is the point: routing a partitioned namespace and
402
+ * addressing a notification to a recipient are two different policies, and only the implementation knows
403
+ * which of its kinds want which. An implementation that has nothing to say about a kind returns `true`.
369
404
  */
370
405
  export interface WatchVisibility {
371
406
  /**
372
407
  * The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
373
- * connection owned by `viewer` see the change to (`namespace`, `id`) produced by `origin`? True iff the
374
- * viewer routes that namespace to the same partition as `origin` — `route(viewer, namespace) ===
375
- * route(origin, namespace)` — OR the item is shared INTO the viewer's partition (the owner edits a
376
- * shared-in item: origin=owner ≠ the viewer's route, yet the viewer holds a live link to it). This yields
377
- * the intended behavior uniformly: global/base events for namespaces the viewer has NOT isolated,
378
- * own-partition events for those it has, plus live updates to items shared in from another partition.
379
- * `origin` may be a partition principal (files, tagged by the merge) or the acting principal (skills,
380
- * stamped at write) — routing both sides makes either correct. `undefined` origin ⇒ base. `id` is the
381
- * store id of the changed item; it drives the shared-in check.
408
+ * connection owned by `q.viewer` see this change? For a partitioning backend, true iff the viewer routes
409
+ * `q.namespace` to the same partition as `q.origin` — `route(viewer, ns) === route(origin, ns)` — OR the
410
+ * item is shared INTO the viewer's partition (the owner edits a shared-in item: origin=owner ≠ the
411
+ * viewer's route, yet the viewer holds a live link to it). That yields the intended behavior uniformly:
412
+ * global/base events for namespaces the viewer has NOT isolated, own-partition events for those it has,
413
+ * plus live updates to items shared in from another partition. `q.origin` may be a partition principal
414
+ * (files, tagged by the merge) or the acting principal (skills, stamped at write) — routing both sides
415
+ * makes either correct.
416
+ *
417
+ * Fail OPEN on anything you do not recognise: a kind you have no policy for, or a query with no
418
+ * `namespace`/`origin` to route on. A predicate that guesses `false` silently drops another party's
419
+ * events; one that returns `true` leaves them exactly as unfiltered as they were before it registered.
382
420
  */
383
- visible(viewer: Principal, namespace: string, id: string, origin: Principal | undefined): boolean;
421
+ visible(q: VisibilityQuery): boolean;
422
+ }
423
+
424
+ /**
425
+ * Addressing for the file area, registered by a storage backend that partitions it per principal (the
426
+ * profiles backend). It exists so a caller that must name a file area OUT OF BAND — in a URL a plain
427
+ * browser GET will replay, with no ambient principal and no header to carry one — can do so without
428
+ * reimplementing the backend's routing.
429
+ *
430
+ * **The token is a partition, never a principal.** The two are not interchangeable: one principal may
431
+ * hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
432
+ * partition serving a principal's files is not derivable from the principal id. Minting the token from
433
+ * the identity in force — the bug this replaces — attached an addressable partition to files that were
434
+ * in the shared base area, and would have pointed at an empty partition the moment one was created
435
+ * under that name.
436
+ *
437
+ * `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
438
+ * returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
439
+ * than on the consumer because only the backend knows how a token resolves — a frontend that
440
+ * synthesised a principal from the token would be guessing at the routing again, one layer up.
441
+ */
442
+ export interface FilePartition {
443
+ /** The partition the CURRENT principal's file area resolves to, as an opaque token — or `undefined`
444
+ * for the shared base area, which needs no addressing (a bare path already reaches it). Never
445
+ * interpret the token: it is the backend's, and only `enter` gives it meaning. */
446
+ current(): string | undefined;
447
+ /** Run `fn` reading the file area named by `token` — the inverse of `current`. An unknown token
448
+ * resolves to whatever that backend serves for one; it is an address, not a capability, so it grants
449
+ * no access the caller's own gating (a file's `allowed` flag) does not already permit. */
450
+ enter<T>(token: string, fn: () => Promise<T>): Promise<T>;
384
451
  }
385
452
 
386
453
  // ── Plugin interface ──────────────────────────────────────────────────────────
@@ -37,6 +37,12 @@ export interface SortSpec {
37
37
  export interface StoreQuery {
38
38
  where?: Filter;
39
39
  sort?: SortSpec[];
40
+ /**
41
+ * Page size. `0` is the count form — the filter runs and `total` answers, but no document is
42
+ * materialised and no cursor is issued. It is a page size of zero rather than a `count` flag
43
+ * because that is already what the word means, in the grammar and in a pushdown backend's native
44
+ * query alike: fetch no rows, report how many there were.
45
+ */
40
46
  limit?: number;
41
47
  cursor?: string | undefined;
42
48
  /**
@@ -0,0 +1,175 @@
1
+ import type { PluginSource, Runtime } from '../plugin.js';
2
+ import type { ModelParameters } from './provider.js';
3
+ import type { ToolContract } from './tools.js';
4
+
5
+ /*
6
+ * Call contracts for the two builtin admin tools that have BOTH a node and a browser implementation —
7
+ * `plugin` (@matatbread/matbot-tool-plugin, @matatbread/matbot-browser) and `provider` (likewise).
8
+ *
9
+ * They live here, rather than beside either implementation, for two reasons.
10
+ *
11
+ * ONE DECLARATION, NOT TWO IDENTICAL ONES. A `ToolContracts` key is registered by declaration merging,
12
+ * which requires every declaration of it to have the same type. Two implementations of one tool name
13
+ * therefore cannot each describe themselves: the moment their shapes differ it is a TS2717, and since
14
+ * the dts scan reads the checker rather than the Program's diagnostics, that error was invisible — one
15
+ * declaration won on file order and its shape was emitted as the contract for whichever implementation
16
+ * was actually loaded. Both tools now name the alias below, so the two declarations are the same
17
+ * symbol and cannot drift; `buildMatbotToolsDts` reports it loudly if any pair ever does again.
18
+ *
19
+ * NAMED SHAPES ARE AUGMENTABLE. These were inline object literals, which closed the one extension point
20
+ * the rest of the API leans on: a host that overrides a builtin tool and returns a superset had no way
21
+ * to say so (declaring its own arm is TS2717; declaring nothing inherits a shape it does not return).
22
+ * Naming them applies the open-registry technique in DEVELOPING.md one level down — a host augments the
23
+ * shape and never touches `ToolContracts`:
24
+ *
25
+ * declare module '@matatbread/matbot-plugin-api' {
26
+ * interface LoadedPluginSummary { managedBy?: 'personal' | 'shipped' }
27
+ * }
28
+ *
29
+ * Where the two runtimes genuinely differ, the shared shape carries the superset and the divergent
30
+ * member is optional — absence is the honest encoding of "this runtime has nothing to report". Each
31
+ * implementation's `inputSchema` is unchanged and still the enforcement point, per the multi-action
32
+ * tool rule: loose schema, executor enforces.
33
+ */
34
+
35
+ /** A tool as reported by `plugin`'s `list`/`builtinTools` — the name and blurb, not the schema. */
36
+ export interface ToolSummary {
37
+ name: string;
38
+ description: string;
39
+ }
40
+
41
+ /**
42
+ * A plugin the loader could not bring up — an incompatible runtime, an import that rejected, a module
43
+ * that is not plugin-shaped, or a `setup()` throw. Recorded rather than discarded so the failure is
44
+ * *graceful but not silent*: `plugin list` and the web plugins panel show which configured plugins
45
+ * failed and why, instead of the failure vanishing into a boot-time console line. `error` is
46
+ * pre-stringified (sidesteps the `unknown` catch-var); `name` is present only when it was known before
47
+ * the failure.
48
+ */
49
+ export interface FailedPlugin {
50
+ specifier: string;
51
+ name?: string;
52
+ error: string;
53
+ }
54
+
55
+ /** One entry in `plugin list`'s `loaded`. */
56
+ export interface LoadedPluginSummary {
57
+ name: string;
58
+ apiVersion: string;
59
+ /** package.json `version`, read by the resolver at load. Absent ⇒ unreadable (no package.json, or a
60
+ * remote whose version isn't baked). */
61
+ version?: string;
62
+ /** The stable URL the loader imported, minus any reload cache-bust stamp. Absent on a host that
63
+ * constructed the plugin by hand, and on runtimes with no meaningful URL to report. */
64
+ resolvedUrl?: string;
65
+ /** Every channel the plugin contributes through (`tools`, `hooks`, `storage`, registered service
66
+ * keys, …) — reflected from the registries, not declared. */
67
+ types: string[];
68
+ tools: ToolSummary[];
69
+ specifier: string;
70
+ description?: string;
71
+ matbotRuntime?: readonly Runtime[];
72
+ }
73
+
74
+ export interface PluginListResult {
75
+ loaded: LoadedPluginSummary[];
76
+ configured: string[];
77
+ failed?: FailedPlugin[] | undefined;
78
+ /** Tools registered by the host rather than by any plugin. */
79
+ builtinTools?: ToolSummary[] | undefined;
80
+ }
81
+
82
+ /** One installable plugin found by `plugin discover_local`. */
83
+ export interface DiscoveredPlugin {
84
+ specifier: string;
85
+ name: string;
86
+ description: string;
87
+ version?: string;
88
+ /** `type` categorises the origin; `uri` is the concrete location as a scheme-qualified URI — `file://…`
89
+ * on disk, the `https://…` it was fetched from for a cached remote. Optional because it is the one
90
+ * member with no browser analogue: a baked-but-idle plugin was inlined into the artifact and has no
91
+ * location to name. Absent ⇒ nothing to resolve it back to, not "unknown origin". */
92
+ source?: { type: PluginSource; uri: string };
93
+ matbotRuntime?: readonly Runtime[];
94
+ /** Which config section already references this specifier, or `null` if none does. */
95
+ configuredVia: 'plugins' | 'providers' | null;
96
+ }
97
+
98
+ /** Result of the mutating `plugin` actions. `installationMessage` is the loaded plugin's own greeting. */
99
+ export interface PluginActionResult {
100
+ message: string;
101
+ installationMessage?: string;
102
+ }
103
+
104
+ /**
105
+ * The `plugin` tool's contract. Both implementations declare `plugin: PluginToolContract`.
106
+ *
107
+ * `reload`'s `refresh` is node-only in effect (it re-downloads a remote plugin's upstream source);
108
+ * the browser resolves every specifier through its import map and ignores the flag.
109
+ */
110
+ export type PluginToolContract =
111
+ | ToolContract<PluginListResult, { action: 'list' }>
112
+ | ToolContract<DiscoveredPlugin[], { action: 'discover_local' }>
113
+ | ToolContract<PluginActionResult, { action: 'add'; specifier: string }>
114
+ | ToolContract<PluginActionResult, { action: 'remove'; specifier: string }>
115
+ | ToolContract<PluginActionResult, { action: 'reload'; specifier: string; refresh?: boolean }>
116
+ | ToolContract<PluginActionResult, { action: 'store-key'; key: string }>;
117
+
118
+ /** One configured provider profile as reported by `provider list`. */
119
+ export interface ProviderSummary {
120
+ name: string;
121
+ /** The adapter's module specifier — the `module:` key in matbot.yaml. */
122
+ module: string;
123
+ model: string;
124
+ /** Whether a credential is on file. Never the credential itself. */
125
+ hasCredentials: boolean;
126
+ endpoint?: string;
127
+ parameters?: ModelParameters;
128
+ maxRounds?: number;
129
+ }
130
+
131
+ /** An adapter a `provider add` could name, offered by runtimes that can enumerate them. */
132
+ export interface AvailableProvider {
133
+ label: string;
134
+ module: string;
135
+ endpointHint?: string;
136
+ modelHint?: string;
137
+ /** Needs no endpoint/model/API key (e.g. a self-contained local adapter): the browser wizard hides
138
+ * those fields and submits just a name, using `modelHint` as the model. */
139
+ selfContained?: boolean;
140
+ }
141
+
142
+ export interface ProviderListResult {
143
+ providers: ProviderSummary[];
144
+ /** The adapters this runtime can offer. Present in the browser, where the set was baked into the
145
+ * artifact and is therefore knowable; absent on node, which discovers adapters through
146
+ * `plugin discover_local` instead of enumerating them here. */
147
+ adapters?: AvailableProvider[];
148
+ }
149
+
150
+ export interface ProviderActionResult {
151
+ message: string;
152
+ }
153
+
154
+ /**
155
+ * The `provider` tool's contract. Both implementations declare `provider: ProviderToolContract`.
156
+ *
157
+ * `add` is the superset of the two: `model` is optional because a self-contained browser adapter
158
+ * supplies its own, while node's executor requires one; `credentialKey`/`credentialEnvVar` name where
159
+ * node's vault should hold the secret, and the browser (which prompts and stores under its own key)
160
+ * ignores them.
161
+ */
162
+ export type ProviderToolContract =
163
+ | ToolContract<ProviderListResult, { action: 'list' }>
164
+ | ToolContract<ProviderActionResult, {
165
+ action: 'add';
166
+ name: string;
167
+ module: string;
168
+ model?: string;
169
+ endpoint?: string;
170
+ credentialKey?: string;
171
+ credentialEnvVar?: string;
172
+ parameters?: ModelParameters;
173
+ maxRounds?: number;
174
+ }>
175
+ | ToolContract<ProviderActionResult, { action: 'remove'; name: string }>;
@@ -23,7 +23,10 @@ export type TurnEvent =
23
23
  | { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
24
24
  | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
25
25
  | { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
26
- | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
26
+ // `durationMs` is the runner's own bracket around the executor — the same number persisted as the
27
+ // call's `span` entry, carried live so a frontend can draw it without waiting for the idle flush.
28
+ // Absent for a call that never ran (a rejected or severed one), which is not the same as 0ms.
29
+ | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; durationMs?: number; traceId: string }
27
30
 
28
31
  // ── live delivery of state that is ALREADY persisted; never the source of truth ──
29
32
  | { type: 'file'; handle: FileHandle; traceId: string }
@@ -1,6 +1,6 @@
1
1
  import type { HookPoint } from './hooks.js';
2
2
  import type { ISODate, MimeType } from './primitives.js';
3
- import type { ProviderMeta, Usage, UsageRecord } from './provider.js';
3
+ import type { ProviderMeta, TurnEntry, UsageRecord } from './provider.js';
4
4
 
5
5
  // ── Messages & Session ────────────────────────────────────────────────────────
6
6
 
@@ -29,14 +29,11 @@ export type MessageContent = (
29
29
  /** Provider-specific round-trip metadata, persisted and re-sent verbatim on replay — see
30
30
  * `ProviderMeta` and the `tool-call` `CompletionEvent`. */
31
31
  meta?: ProviderMeta }
32
- | { type: 'tool-result'; id: string; result: unknown; isError?: boolean;
33
- /**
34
- * Accounting addendum: completions the tool itself ran (e.g. `single_turn`, `dream_time`'s ranker/
35
- * merger), captured ambiently via the usage carrier and recorded for cost reckoning. Elided from
36
- * provider submission — adapters serialise only `id`/`result`/`isError`. One entry per provider
37
- * call, each tagged with the provider billed; never summed at write time, as rates differ.
38
- */
39
- usage?: UsageRecord[] }
32
+ // A tool's own completions (`single_turn`, `dream_time`'s ranker/merger) are no longer recorded here:
33
+ // they are entries on the turn head, tagged `site: { kind: 'tool', callId }`. A tool message is popped
34
+ // wholesale by a retract-and-rerun, which took its accounting out of reach of any reduction over
35
+ // `session.messages` — see `Message.usage`.
36
+ | { type: 'tool-result'; id: string; result: unknown; isError?: boolean }
40
37
 
41
38
  // ── frontend-facing: rendered to a person, never sent to the model ──
42
39
  | { type: 'form'; fields: FormField[]; submitLabel?: string }
@@ -128,9 +125,20 @@ export interface Message {
128
125
  createdAt: ISODate;
129
126
  traceId: string;
130
127
  providerName?: string;
131
- /** Token/cost accounting for the provider call that produced this message (assistant turns). The
132
- * billed provider is this message's `providerName`. Accounting only — elided from provider submission. */
133
- usage?: Usage;
128
+ /**
129
+ * The turn's activity log, anchored here — every provider call it caused, whatever ran it (a round, a
130
+ * tool, a hook), plus every bracket matbot held open that was not a call of its own (a tool span).
131
+ * Each entry is self-describing via its `site` and causal `traceId`. Bookkeeping only: elided from
132
+ * provider submission, and a `flatMap` away from any total or waterfall a consumer wants.
133
+ *
134
+ * Anchored on the turn's **head** (its user message) rather than on the message whose call produced
135
+ * it, because that is the one message a retract-and-rerun keeps: the pop stashes assistant and tool
136
+ * messages inside a retraction marker, where no reduction over `session.messages` will ever find them
137
+ * again, so a retried turn would silently under-report by the attempt it discarded. Locality is not
138
+ * lost — `site` already names the round or tool call, and physical adjacency carried nothing the
139
+ * coordinate does not. See docs/ACCOUNTING-RATIONALE.md.
140
+ */
141
+ activity?: TurnEntry[];
134
142
  metadata?: Record<string, unknown>;
135
143
  }
136
144
 
@@ -1,5 +1,7 @@
1
1
  import type { HealthStatus } from './health.js';
2
+ import type { HookPoint } from './hooks.js';
2
3
  import type { Message } from './messages.js';
4
+ import type { ISODate } from './primitives.js';
3
5
  import type { Tool } from './tools.js';
4
6
 
5
7
  // ── Provider ──────────────────────────────────────────────────────────────────
@@ -43,19 +45,119 @@ export interface ProviderConfig {
43
45
  export interface Usage {
44
46
  inputTokens: number;
45
47
  outputTokens: number;
46
- costUsd?: number;
47
48
  cacheReadTokens?: number;
48
49
  cacheCreationTokens?: number;
50
+ /**
51
+ * What the endpoint actually reported, under its own field names, verbatim — the adapter's `usage`
52
+ * object passed through rather than interpreted. Present only for fields the endpoint sent: an
53
+ * explicit `0` and an absent key are different facts, and collapsing them is not the adapter's call
54
+ * to make (a host that strips a capability and a call with genuinely no cache activity look identical
55
+ * once you do).
56
+ *
57
+ * The normalised counters above are still the comparable ones — every protocol has input and output
58
+ * tokens unambiguously, and something must be common or a turn spanning three providers cannot be
59
+ * totalled at all. This is what makes that normalisation non-destructive: `inputTokens` may be a real
60
+ * interpretation (openai-compat reports it net of cache hits, so it means what anthropic's
61
+ * `input_tokens` means), and keeping the components alongside means a consumer can check it, reverse
62
+ * it, or reconcile against a vendor's own dashboard.
63
+ *
64
+ * Not all values are counters — `service_tier` is a string that prices the call, `latency_checkpoint`
65
+ * a nested object — so this is deliberately not a `Record<string, number>`. Aggregation sums numeric
66
+ * values key-wise and leaves the rest alone; and only ever within one provider, since the same key
67
+ * means different things under different protocols (`prompt_tokens` includes cache hits,
68
+ * `input_tokens` does not). See docs/ACCOUNTING-RATIONALE.md.
69
+ */
70
+ reported?: Record<string, unknown>;
49
71
  }
50
72
 
73
+ /**
74
+ * Where in matbot's own control flow a completion happened — the one accounting fact no adapter and no
75
+ * plugin can recover after the fact, and therefore the one matbot must record. Everything derived from
76
+ * it (what a tool costs, what a user costs, what a "task" costs) is a *grouping* of these coordinates
77
+ * plus a rate table: policy, and a plugin's to own. See docs/ACCOUNTING-RATIONALE.md.
78
+ *
79
+ * Closed by construction: inside a turn there are exactly three places a completion can originate.
80
+ * Outside a turn there is no scope at all, and accounting is the documented no-op.
81
+ */
82
+ export type UsageSite =
83
+ /** The runner's own provider call for one agentic round (1-based, as counted against `maxRounds`). */
84
+ | { kind: 'round'; round: number }
85
+ /** A completion run by a tool executor — `single_turn`, a ranker, a merger. */
86
+ | { kind: 'tool'; callId: string; tool: string }
87
+ /** A completion run by a hook handler — a trigger classifier, a router, an auto-compaction. */
88
+ | { kind: 'hook'; channel: HookPoint; plugin?: string };
89
+
51
90
  /** One provider call's usage, tagged with the provider billed — the unit a tool accrues (a tool may
52
91
  * run completions against any provider, each with its own rates) and the element persisted on a
53
92
  * `tool-result`'s `usage` addendum. See the ambient usage carrier (`recordUsage`/`currentUsageSink`). */
54
93
  export interface UsageRecord {
55
94
  provider: string;
56
95
  usage: Usage;
96
+ /**
97
+ * The call site in force when this was recorded, stamped by the producer rather than inferred by the
98
+ * consumer. Absent only when a completion ran with no site established (a plugin reaching `complete`
99
+ * outside any turn).
100
+ *
101
+ * Attribution is *declared*, not derived from timing, and that is the whole point: the triggers
102
+ * classifier is kicked off detached inside a `screen` hook and resolves at an arbitrary later moment,
103
+ * so any scheme that infers ownership from when a record lands (as slicing the sink by index did)
104
+ * credits it to whichever tool happened to be running. Capturing the site where the work *starts*
105
+ * makes that race unrepresentable.
106
+ */
107
+ site?: UsageSite;
108
+ /** When the provider call started, and how long matbot's own scope around it was open. The *bracket*,
109
+ * not the endpoint's view: server-side latency, when an endpoint reports it, arrives in
110
+ * `usage.reported` like any other provider-named field. Neither substitutes for the other. */
111
+ startedAt?: ISODate;
112
+ durationMs?: number;
113
+ /**
114
+ * The turn that *caused* this call, which is not the same question as where the record ends up.
115
+ * Two ordinary behaviours separate the two: a completion can be recorded after its turn commits (a
116
+ * detached trigger classifier, a `followup` hook), and a retract-and-rerun moves messages around
117
+ * underneath it. Carrying the cause on the entry makes an entry self-describing, so grouping by turn
118
+ * is a *query* rather than something inferred from adjacency — and so are grouping by tool, by user
119
+ * and by session. See docs/ACCOUNTING-RATIONALE.md.
120
+ */
121
+ traceId?: string;
122
+ /**
123
+ * The originating human turn, carried down a resubmission chain (a human submit is its own root). Not
124
+ * redundant with `traceId`: a retract-and-rerun re-runs an *existing* user turn under a fresh traceId
125
+ * and introduces no user message of its own, so its entries have no head of their own to sit on. The
126
+ * root is what gives them one — and it is the honest answer anyway, since the retry's cost belongs to
127
+ * the turn being retried.
128
+ */
129
+ rootTraceId?: string;
57
130
  }
58
131
 
132
+ /**
133
+ * A bracket matbot held open that was not itself a provider call — today, a tool call. Only matbot can
134
+ * measure one: it is the interval between handing control to an executor and getting it back, which no
135
+ * adapter and no plugin can see from where it sits.
136
+ *
137
+ * Separate from `UsageRecord` because most tool calls spend no tokens at all — `bash`, `http`,
138
+ * `workspace` — so hanging duration off an accounting record would record it for exactly the tools that
139
+ * happen to call an LLM and lose it for the rest, which is backwards. A tool call that *does* run
140
+ * completions produces both: this span, and one `UsageRecord` per call it made, sharing its `site`.
141
+ */
142
+ export interface SpanRecord {
143
+ site: UsageSite;
144
+ traceId?: string;
145
+ /** See `UsageRecord.rootTraceId` — a span needs the same anchor for the same reason. */
146
+ rootTraceId?: string;
147
+ startedAt: ISODate;
148
+ durationMs: number;
149
+ }
150
+
151
+ /**
152
+ * One thing that happened during a turn, at a place only matbot can name — the element of the turn's
153
+ * activity log. A discriminated union rather than one loose shape with everything optional: a span has
154
+ * no provider and no tokens, and pretending otherwise would mean writing zeros that a consumer cannot
155
+ * tell from measured ones.
156
+ */
157
+ export type TurnEntry =
158
+ | ({ kind: 'call' } & UsageRecord)
159
+ | ({ kind: 'span' } & SpanRecord);
160
+
59
161
  /**
60
162
  * Opaque, provider-specific round-trip metadata attached to a tool-call — captured from a completion,
61
163
  * persisted, and re-sent verbatim when the call is replayed in history (e.g. a Gemini 3 "thought
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@ export * from './types/storage.js';
17
17
  export * from './types/knowledge.js';
18
18
  export * from './types/typescript.js';
19
19
  export * from './types/tools.js';
20
+ export * from './types/builtin-tools.js';
20
21
  export * from './types/files.js';
21
22
  export * from './types/frontend.js';
22
23
  export * from './types/vault.js';
@@ -1,17 +1,37 @@
1
- import type { Usage, UsageRecord } from './types.js';
1
+ import type { ISODate, TurnEntry, Usage, UsageSite } from './types.js';
2
+
3
+ /**
4
+ * The accounting bag for one turn (or one sub-turn): the entries recorded within it, the call site in
5
+ * force, and the parent it rolls up into. One object, carried ambiently, accumulating a *log* rather
6
+ * than a fold — per-provider totals are a derived view (`usageByProvider`), and folding at write time
7
+ * would bake in one grouping and destroy the others. See docs/ACCOUNTING-RATIONALE.md.
8
+ */
9
+ export interface UsageScope {
10
+ entries: TurnEntry[];
11
+ site?: UsageSite;
12
+ /** The turn this scope accounts for, and the human turn that ultimately caused it; both stamped
13
+ * onto every entry recorded within it. */
14
+ traceId?: string;
15
+ rootTraceId?: string;
16
+ parent?: UsageScope;
17
+ }
2
18
 
3
19
  /**
4
20
  * Ambient carrier for token accounting — the channel by which a completion run *inside a tool* (via
5
21
  * `MatbotRuntime.complete`/`singleTurn`) reports its usage back to the runner without that bookkeeping
6
22
  * being threaded through every tool, ranker, or merger signature. It mirrors the {@link PrincipalCarrier}:
7
- * a sink is established for an async extent, and code deep within (the host's `complete()`) appends to
8
- * whatever sink is in force via {@link recordUsage}.
23
+ * a scope is established for an async extent, and code deep within (the host's `complete()`) appends to
24
+ * whatever scope is in force via {@link recordUsage}.
9
25
  *
10
26
  * Why ambient. A tool reaches an LLM only through the core completion services — it cannot call a
11
27
  * provider directly. Recording at that one choke point therefore captures *every* tool's spend (a
12
28
  * `single_turn`, an `ask_inner_voice`, each of `dream_time`'s per-fact ranker/merger calls) with zero
13
- * per-tool code. The runner opens one sink per turn ({@link withUsageScope}) and slices it per tool
14
- * call, attributing each completion to the call that triggered it.
29
+ * per-tool code. The runner opens one scope per turn ({@link withUsageScope}) and labels the work
30
+ * within it ({@link withUsageSite}), so each record carries the call site that produced it.
31
+ *
32
+ * The same ambience is what makes attribution correct rather than approximate: a carrier captures at
33
+ * continuation creation, so a completion kicked off inside a `screen` hook and awaited long afterwards
34
+ * still reports the site it *started* from, not whatever was running when it happened to resolve.
15
35
  *
16
36
  * The host installs one platform implementation at boot:
17
37
  * - node (concurrent turns): an `AsyncLocalStorage`-backed carrier, so overlapping turns keep
@@ -23,10 +43,10 @@ import type { Usage, UsageRecord } from './types.js';
23
43
  * {@link currentUsageSink} are no-ops and usage simply isn't recorded — never an error.
24
44
  */
25
45
  export interface UsageCarrier {
26
- /** Establish a fresh `sink` for the dynamic extent of `fn`. Consume any async iterable *inside* `fn`. */
27
- run<T>(sink: UsageRecord[], fn: () => T): T;
28
- /** The sink in force for the current async flow, or `undefined` outside any scope. */
29
- tryCurrent(): UsageRecord[] | undefined;
46
+ /** Establish `scope` for the dynamic extent of `fn`. Consume any async iterable *inside* `fn`. */
47
+ run<T>(scope: UsageScope, fn: () => T): T;
48
+ /** The scope in force for the current async flow, or `undefined` outside any scope. */
49
+ tryCurrent(): UsageScope | undefined;
30
50
  }
31
51
 
32
52
  const CARRIER_KEY = Symbol.for('@matatbread/matbot-plugin-api#usageCarrier');
@@ -37,21 +57,95 @@ export function installUsageCarrier(impl: UsageCarrier): void {
37
57
  (globalThis as CarrierGlobal)[CARRIER_KEY] = impl;
38
58
  }
39
59
 
40
- /** The usage sink in force, or `undefined` when no carrier is installed or no scope is established. */
41
- export function currentUsageSink(): UsageRecord[] | undefined {
60
+ /** The usage scope in force, or `undefined` when no carrier is installed or none is established. */
61
+ export function currentUsageScope(): UsageScope | undefined {
42
62
  return (globalThis as CarrierGlobal)[CARRIER_KEY]?.tryCurrent();
43
63
  }
44
64
 
45
- /** Append one provider call's usage to the sink in force. No-op outside any scope (best-effort accounting). */
46
- export function recordUsage(provider: string, usage: Usage): void {
47
- currentUsageSink()?.push({ provider, usage });
65
+ /** The entries of the scope in force. `undefined` outside any scope. */
66
+ export function currentUsageSink(): TurnEntry[] | undefined {
67
+ return currentUsageScope()?.entries;
68
+ }
69
+
70
+ /** The call site in force, or `undefined` when none has been established. */
71
+ export function currentUsageSite(): UsageSite | undefined {
72
+ return currentUsageScope()?.site;
73
+ }
74
+
75
+ /**
76
+ * Append one provider call's usage to the scope in force, stamped with the call site in force. No-op
77
+ * outside any scope (best-effort accounting).
78
+ */
79
+ export function recordUsage(provider: string, usage: Usage, span?: { startedAt: ISODate; durationMs: number }): void {
80
+ const scope = currentUsageScope();
81
+ if (scope === undefined) return;
82
+ scope.entries.push({
83
+ kind: 'call', provider, usage,
84
+ ...(scope.site !== undefined ? { site: scope.site } : {}),
85
+ ...(scope.traceId !== undefined ? { traceId: scope.traceId } : {}),
86
+ ...(scope.rootTraceId !== undefined ? { rootTraceId: scope.rootTraceId } : {}),
87
+ ...(span !== undefined ? span : {}),
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Record a bracket matbot held open that was not a provider call — a tool call. Takes the site
93
+ * explicitly rather than reading the ambient one, because the caller records this *after* the scope it
94
+ * measured has closed.
95
+ */
96
+ export function recordSpan(site: UsageSite, startedAt: ISODate, durationMs: number): void {
97
+ const scope = currentUsageScope();
98
+ if (scope === undefined) return;
99
+ scope.entries.push({
100
+ kind: 'span', site, startedAt, durationMs,
101
+ ...(scope.traceId !== undefined ? { traceId: scope.traceId } : {}),
102
+ ...(scope.rootTraceId !== undefined ? { rootTraceId: scope.rootTraceId } : {}),
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Run `fn` with a fresh usage scope established for its async extent, handing the scope to `fn` so the
108
+ * caller can read what accrued. Passthrough (no scope) when no carrier is installed.
109
+ *
110
+ * Scopes **nest**: opened inside another, the child accumulates its own entries and rolls them up into
111
+ * the parent when `fn` settles — so a sub-turn can be asked what it cost without its spend vanishing
112
+ * from the turn containing it. Roll-up happens on rejection too: the tokens were spent either way.
113
+ */
114
+ export function withUsageScope<T>(
115
+ fn: (scope: UsageScope) => T,
116
+ ids?: { traceId?: string; rootTraceId?: string },
117
+ ): T {
118
+ const carrier = (globalThis as CarrierGlobal)[CARRIER_KEY];
119
+ if (carrier === undefined) return fn({ entries: [], ...ids });
120
+ const parent = carrier.tryCurrent();
121
+ const trace = ids?.traceId ?? parent?.traceId;
122
+ const root = ids?.rootTraceId ?? parent?.rootTraceId;
123
+ const scope: UsageScope = {
124
+ entries: [],
125
+ ...(trace !== undefined ? { traceId: trace } : {}),
126
+ ...(root !== undefined ? { rootTraceId: root } : {}),
127
+ ...(parent !== undefined ? { parent } : {}),
128
+ };
129
+ const rollUp = (): void => { if (parent !== undefined) parent.entries.push(...scope.entries); };
130
+ return carrier.run(scope, () => {
131
+ const r = fn(scope);
132
+ if (r instanceof Promise) return r.finally(rollUp) as unknown as T;
133
+ rollUp();
134
+ return r;
135
+ });
48
136
  }
49
137
 
50
- /** Run `fn` with a fresh, empty usage sink established for its async extent. Passthrough when no carrier. */
51
- export function withUsageScope<T>(fn: () => T): T {
138
+ /**
139
+ * Run `fn` with `site` established as the call site, sharing the enclosing scope's entries — a *label*,
140
+ * not a new bag. Pushed by the runner around its own provider call and around each tool executor, and
141
+ * by the hook registry around each handler: the three places a completion can originate. Passthrough
142
+ * when no carrier or no enclosing scope, so a site never conjures a bag that would be dropped.
143
+ */
144
+ export function withUsageSite<T>(site: UsageSite, fn: () => T): T {
52
145
  const carrier = (globalThis as CarrierGlobal)[CARRIER_KEY];
53
- if (carrier === undefined) return fn();
54
- return carrier.run([], fn);
146
+ const parent = carrier?.tryCurrent();
147
+ if (carrier === undefined || parent === undefined) return fn();
148
+ return carrier.run({ ...parent, site }, fn);
55
149
  }
56
150
 
57
151
  /**
@@ -60,11 +154,11 @@ export function withUsageScope<T>(fn: () => T): T {
60
154
  * the same single-turn assumption the constant principal carrier makes.
61
155
  */
62
156
  export function createSerialUsageCarrier(): UsageCarrier {
63
- let current: UsageRecord[] | undefined;
157
+ let current: UsageScope | undefined;
64
158
  return {
65
- run(sink, fn) {
159
+ run(scope, fn) {
66
160
  const prev = current;
67
- current = sink;
161
+ current = scope;
68
162
  try {
69
163
  const r = fn();
70
164
  if (r instanceof Promise) {