@matatbread/matbot-plugin-api 0.4.1 → 0.4.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.4.1",
7
+ "version": "0.4.4",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -5,80 +5,218 @@ import { globalSlot } from './global-state.js';
5
5
  /**
6
6
  * A context switch is the machine analogue of an OS one: it pages in any pending machine state and
7
7
  * sets the owner. {@link runAs} is the bare "set the owner" instruction (the principal carrier);
8
- * {@link contextSwitch} is the scheduler operation layered on top — it also lands any machine
9
- * mutation that was deferred while the machine was busy.
8
+ * {@link machineBusy} is the other half — "hold the machine, I am mid-operation" — and
9
+ * {@link contextSwitch} is the two together, for an operation that is both.
10
10
  *
11
11
  * Why deferral exists: a service like the `StorageBackend` is the system of record, and swapping it
12
12
  * under a running operation would split a compare-and-swap (or a turn) across two backends. So a
13
- * swap is queued and applied only at the *quiescent edge* — the instant no context is active — which
14
- * is exactly a context-switch boundary. The principal carrier stays a pure identity primitive; this
15
- * module owns the machine-update half, so the two concerns share call sites without sharing code.
13
+ * swap is queued and applied only at the *quiescent edge* — the instant nothing holds the machine.
14
+ * The principal carrier stays a pure identity primitive; this module owns the machine half.
16
15
  *
17
- * The host registers what to flush ({@link onContextQuiesce}); the three entry points (a web
18
- * request, a telegram message, a turn) switch context through {@link contextSwitch} instead of
19
- * calling {@link runAs} directly. The boot principal (an `enter`, not a `run`) is the depth-0
20
- * baseline and is never counted.
16
+ * **What counts as busy is the operation, not the turn.** The two are not the same thing, and
17
+ * assuming they were is what made this edge wrong: a turn commits its session document, and *then*
18
+ * its pump reads that document back, appends followup markers, rewrites it for a retract, and starts
19
+ * the next queued turn — all of it store work outside the turn, and all of it quiescent under a
20
+ * per-turn hold. A mutation landing in that window splits exactly what the deferral exists to keep
21
+ * whole. So the pump holds the machine for its whole queue and switches principal per item, which is
22
+ * also where accounting already flushes: "the end of a turn" is not a moment anything can be totalled
23
+ * or swapped at, and the queue draining is.
24
+ *
25
+ * The host registers what to flush ({@link onContextQuiesce}). Entry points that receive work but do
26
+ * not perform it — a web request holding an SSE stream open, a telegram message — use {@link runAs}
27
+ * and are deliberately NOT busy: their scope spans a long-lived stream, and counting it would mean
28
+ * the machine never reaches an edge at all. The boot principal (an `enter`, not a `run`) is likewise
29
+ * never counted.
21
30
  */
22
31
 
23
32
  // State-shaped and reachable from a plugin (a storage plugin registering a deferred-swap flusher, or
24
- // switching context), so it lives in the global slot: a duplicated plugin-api copy shares one set of
25
- // quiescers and one depth count rather than splitting them. See ./global-state.ts.
33
+ // holding the machine), so it lives in the global slot: a duplicated plugin-api copy shares one set
34
+ // of quiescers and one hold count rather than splitting them. See ./global-state.ts.
35
+ //
36
+ // `depth` keeps its name across the change of meaning — it counts machine holds now, of which a
37
+ // principal-bearing context switch is one — because the slot is shared by every loaded copy of this
38
+ // module, including published older ones. Both spellings of "busy" increment the same field, so a
39
+ // skewed install still agrees on when the machine is idle; a renamed field would have left an old
40
+ // copy marking a turn busy on `depth` while a new copy read `busy` and called the machine quiet.
26
41
  interface ContextSwitchState {
27
- quiescers: Set<() => void>;
42
+ quiescers: Set<Quiescer>;
28
43
  depth: number;
29
44
  flushing: boolean;
45
+ /** The flush currently settling, when a flusher went async. Held so a second caller joins the one
46
+ * in flight rather than starting a second, and so {@link quiesced} has something to wait on. */
47
+ inflight?: Promise<void> | undefined;
30
48
  }
31
49
  const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
32
- quiescers: new Set<() => void>(),
50
+ quiescers: new Set<Quiescer>(),
33
51
  depth: 0,
34
52
  flushing: false,
35
53
  }));
36
54
 
37
55
  /**
38
- * Register a machine-update flush, run at every quiescent edge (no context active). Flushers MUST be
39
- * idempotent: a no-op when nothing is pending, since the edge is reached after every operation.
40
- * Returns an unregister fn. A throwing flusher is isolated — one must not block the others or escape
41
- * into the operation that triggered the edge.
56
+ * A machine-update flush. Returning a promise makes the edge *wait*: the deferred work is no longer
57
+ * merely started at the edge but finished before the machine is handed to whoever asked to be
58
+ * quiesced first (see {@link quiesced}). That matters for work that reads and rewrites a document the
59
+ * next operation is about to read — a store edge deferred out of a turn lands in the window before
60
+ * the next turn takes its copy, or not at all.
42
61
  */
43
- export function onContextQuiesce(flush: () => void): () => void {
62
+ export type Quiescer = () => void | Promise<void>;
63
+
64
+ /**
65
+ * Register a machine-update flush, run at every quiescent edge (nothing holding the machine).
66
+ * Flushers MUST be idempotent: a no-op when nothing is pending, since the edge is reached after every
67
+ * operation. Returns an unregister fn.
68
+ *
69
+ * What the edge guarantees a flusher:
70
+ *
71
+ * - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
72
+ * started in that order and then left to settle alongside the rest.
73
+ * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
74
+ * yields at all, which is what lets the host stage a mutation and land it with a bare
75
+ * `flushIfQuiescent()` — in effect before the call returns.
76
+ * - **No second sweep begins until the current one has completely settled**, and a mutation staged
77
+ * meanwhile is turned away rather than applied.
78
+ * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
79
+ * completion, nor escape into the operation whose release happened to reach the edge.
80
+ *
81
+ * **It does not give exclusivity, and could not.** A synchronous flusher has it for free — the runtime
82
+ * is single-threaded — and that is the argument for keeping a flusher synchronous whenever the work
83
+ * allows. The moment one awaits, the window is open: `depth` is 0 while a flush settles, so an HTTP
84
+ * endpoint can accept a request and run a tool call to completion inside a single `await`, and only an
85
+ * operation that asked to be {@link quiesced} first (the pump) is held back. Flushers may therefore
86
+ * overlap each other too, and sequencing them would buy nothing — it would remove one source of
87
+ * concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
88
+ * slowest.
89
+ *
90
+ * So contention over a service is the *service's* to resolve, not the sweep's: a `Store` answers it
91
+ * with compare-and-swap, and a subsystem with a stronger requirement owns a stronger answer. The
92
+ * sweep's job is to make contention rare, not to pretend the machine stops.
93
+ *
94
+ * **Known gap, not caused here.** Compare-and-swap answers "did this document change?", not "did the
95
+ * medium change?" — a read from one `StorageBackend` and a write to another compares a version from
96
+ * the first against the second, and a session migrates with nothing in a position to notice. Staging
97
+ * a swap rather than applying it while a flush settles narrows that window, but it is a mitigation
98
+ * in the wrong place: an HTTP tool call has always been able to straddle a swap the same way, so the
99
+ * exposure predates asynchronous flushers and is merely easier to reach with them. The fix belongs
100
+ * where the consequence lands — a `cas` that checks the backend it is writing to is the one it read
101
+ * from, and fails when it is not.
102
+ *
103
+ * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
104
+ * registers — carry no protection here or anywhere: they change under a running turn too, by the
105
+ * design decision that only the system of record is worth deferring. A flusher that depends on one
106
+ * should resolve it once rather than either side of an await, exactly as a turn should.
107
+ */
108
+ export function onContextQuiesce(flush: Quiescer): () => void {
44
109
  cs.quiescers.add(flush);
45
110
  return () => { cs.quiescers.delete(flush); };
46
111
  }
47
112
 
48
113
  /**
49
- * Run the registered flushers iff no context is currently active — the quiescent edge. Called by
50
- * {@link contextSwitch} at both edges, and by the host right after it *queues* a deferred mutation:
51
- * queued while busy (depth > 0) this no-ops and the exit edge lands it; queued while idle (depth 0)
114
+ * Run the registered flushers iff nothing holds the machine — the quiescent edge. Called by
115
+ * {@link machineBusy} at both edges, and by the host right after it *queues* a deferred mutation:
116
+ * queued while busy (depth > 0) this no-ops and the release edge lands it; queued while idle (depth 0)
52
117
  * it applies immediately, so an idle-time swap doesn't wait for the next request to take effect.
118
+ *
119
+ * Returns the settling promise when some flusher went async, and `undefined` when the edge is already
120
+ * complete — the caller decides whether it can afford to wait ({@link quiesced} does; a synchronous
121
+ * caller cannot). Re-entering while one is settling joins it rather than starting a second: `flushing`
122
+ * stays raised for the whole asynchronous extent, not just the synchronous sweep.
53
123
  */
54
- export function flushIfQuiescent(): void {
55
- if (cs.depth !== 0 || cs.flushing) return;
124
+ export function flushIfQuiescent(): Promise<void> | undefined {
125
+ if (cs.depth !== 0 || cs.flushing) return cs.inflight;
56
126
  cs.flushing = true;
57
- try {
58
- for (const q of cs.quiescers) {
59
- try { q(); }
60
- catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); }
61
- }
62
- } finally {
127
+
128
+ // Snapshot: a one-shot flusher unregisters itself as it fires, and a flusher may register another.
129
+ // Iterating a copy makes both safe and settles what a mid-sweep registration means — it was not
130
+ // registered when this edge began, so it runs at the next one.
131
+ const settling = [...cs.quiescers]
132
+ .map(q => {
133
+ // A synchronous throw is the flusher's own business, never that of the operation whose release
134
+ // happened to reach the edge. A rejection is handled below, by allSettled.
135
+ try { return q() ?? undefined; }
136
+ catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); return undefined; }
137
+ })
138
+ .filter((p): p is Promise<void> => p !== undefined);
139
+
140
+ if (settling.length === 0) {
141
+ // Every flusher was synchronous, so the edge is complete on return — which is what lets the host
142
+ // stage a mutation and land it with a bare `flushIfQuiescent()`, in effect before the call returns.
63
143
  cs.flushing = false;
144
+ cs.inflight = undefined;
145
+ return undefined;
64
146
  }
147
+
148
+ // allSettled, not all: one flusher's rejection must not deny the others their completion, and the
149
+ // aggregate must never reject — the release below depends on it, and there is nobody to catch it.
150
+ const all = Promise.allSettled(settling).then(results => {
151
+ for (const r of results) {
152
+ if (r.status === 'rejected') console.error('[matbot] context-quiesce flush rejected:', r.reason instanceof Error ? r.reason.message : r.reason);
153
+ }
154
+ cs.flushing = false;
155
+ cs.inflight = undefined;
156
+ });
157
+ cs.inflight = all;
158
+ return all;
65
159
  }
66
160
 
67
161
  /**
68
- * Switch into the machine as `principal`: land any pending machine mutation (only safe while idle),
69
- * then run `fn` under the principal. Mirrors {@link runAs}'s sync/async return — when `fn` is async,
70
- * the context stays open until its promise settles, so the depth count tracks the real operation.
162
+ * Wait until the edge is not merely reached but *complete*: reach it, and let any deferred work it
163
+ * starts (or finds already settling) finish. An operation that must not overlap deferred work — a turn
164
+ * about to take its copy of a session that a previous turn's deferred edit is rewriting — awaits this
165
+ * before holding the machine.
166
+ *
167
+ * **One pass, deliberately not a loop.** An `async` flusher returns a promise whether or not it had
168
+ * anything to do — "nothing pending" is unobservable from out here, since an immediately-resolved
169
+ * promise and a real unit of work are the same object to us. A loop that waited for a pass to produce
170
+ * no promise would therefore never terminate against an idempotent async flusher, and neither would a
171
+ * re-run scheduled after each settle: each pass would manufacture the evidence for the next. So work
172
+ * staged *during* a flush lands at the following edge — the next `machineBusy` release, or the next
173
+ * caller through here — which is all the mount contract has ever promised: eventual and ordered,
174
+ * never timed.
71
175
  */
72
- export function contextSwitch<T>(principal: Principal, fn: () => T): T {
176
+ export async function quiesced(): Promise<void> {
177
+ await flushIfQuiescent();
178
+ }
179
+
180
+ /**
181
+ * Hold the machine for the extent of `fn`: land any pending mutation first (only safe while idle),
182
+ * then keep the machine off-limits to further ones until `fn` is done. Mirrors {@link runAs}'s
183
+ * sync/async return — when `fn` is async the hold stays up until its promise settles, so the count
184
+ * tracks the real operation rather than the synchronous call.
185
+ *
186
+ * The hold is released on **every** exit — a synchronous throw, a rejected promise, a bare `return`
187
+ * from anywhere inside — because the release is bound to the call, not written at the exits. That is
188
+ * the whole reason this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early
189
+ * returns (the pump has several per queue item) cannot leave the counter stuck, and a stuck counter
190
+ * is unrecoverable — every later flush would silently no-op forever, and the symptom would be a
191
+ * deferred mutation that simply never happens.
192
+ */
193
+ export function machineBusy<T>(fn: () => T): T {
73
194
  flushIfQuiescent();
74
195
  cs.depth++;
75
- const settle = (): void => { cs.depth--; flushIfQuiescent(); };
196
+ let settled = false;
197
+ const settle = (): void => {
198
+ if (settled) return; // the sync-throw path settles before rethrowing; never settle twice
199
+ settled = true;
200
+ cs.depth--;
201
+ flushIfQuiescent();
202
+ };
76
203
  let result: T;
77
204
  try {
78
- result = runAs(principal, fn);
205
+ result = fn();
79
206
  } catch (e) {
80
207
  settle();
81
208
  throw e;
82
209
  }
83
210
  return result instanceof Promise ? (result.finally(settle) as T) : (settle(), result);
84
211
  }
212
+
213
+ /**
214
+ * Switch into the machine as `principal`: hold it for the extent of `fn` ({@link machineBusy}) and
215
+ * run `fn` under the principal ({@link runAs}). The composition, for an operation that both owns the
216
+ * machine and has an owner — a single-turn request standing on its own. An operation that decomposes
217
+ * into differently-owned units (the pump: one queue, a principal per item) holds the machine once
218
+ * around the whole of it and calls `runAs` per unit instead.
219
+ */
220
+ export function contextSwitch<T>(principal: Principal, fn: () => T): T {
221
+ return machineBusy(() => runAs(principal, fn));
222
+ }
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.
@@ -358,29 +361,114 @@ export interface StorageBackend {
358
361
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
359
362
  readonly fileStore: FileStore;
360
363
  close?(): Promise<void>;
364
+ /**
365
+ * The namespaces this backend currently holds documents for — the `createStore` arguments that
366
+ * would reach real data. `createStore` is addressed BY name, so without this a caller can only read
367
+ * a namespace it already knows about; enumerating is what makes a backend traversable (copying one
368
+ * backend into another, auditing what is stored, reporting on a `.data` directory).
369
+ *
370
+ * **Optional because absence is a type.** A backend addressing a medium with no listing operation
371
+ * cannot answer, and must not guess: a caller that needs a complete list degrades to being told the
372
+ * namespaces explicitly rather than silently working from a partial one. Do not implement this by
373
+ * returning "the namespaces `createStore` happened to be called with this session" — that is a lower
374
+ * bound masquerading as an answer, and a traversal built on it silently skips whatever no plugin
375
+ * touched. If the medium cannot be listed, leave it off, or throw with a reason.
376
+ *
377
+ * Excludes the file store: files are a separate axis with their own enumeration
378
+ * ({@link FileStore.list}), not a namespace among the document stores.
379
+ *
380
+ * A namespace holding no documents may be omitted — "empty" and "absent" are not usefully different
381
+ * to a caller that is about to read it. Implementations return the list sorted, so a diff of two
382
+ * backends is stable.
383
+ */
384
+ namespaces?(): Promise<string[]>;
385
+ }
386
+
387
+ /**
388
+ * What a {@link WatchVisibility} predicate decides on. A named object rather than positional parameters
389
+ * because `kind`/`namespace`/`id` are three adjacent strings around two `Principal`s — an ordering an
390
+ * implementation could get wrong silently.
391
+ *
392
+ * Populated by whoever consults the predicate; a frontend firehose fills it from the notification it is
393
+ * about to fan out.
394
+ */
395
+ export interface VisibilityQuery {
396
+ /** The principal asking — the owner of the connection this fan-out would reach. */
397
+ viewer: Principal;
398
+ /** The notification's `kind`. Present so an implementation can apply a policy PER KIND: some kinds are
399
+ * global, some are checked against the recipient (`viewer`), some against the sender (`origin`). Typed
400
+ * as the registry key for narrowing, but **open at runtime** like every `kind` — a plugin outside this
401
+ * build, or a bridged remote, can supply one, so `default` your switch and never assume exhaustiveness. */
402
+ kind: keyof Notifications;
403
+ /** Routing namespace of the changed item — the axis a profile isolates. Absent when the kind addresses
404
+ * no item (a `RegistryChange`, or a plugin kind carrying a measurement or an external event). */
405
+ namespace?: string;
406
+ /** Store id of the changed item; drives the shared-in check. Absent with `namespace`. */
407
+ id?: string;
408
+ /** Whose data the fact is about — `NotificationBase.principal`, the ownership axis, NOT attribution.
409
+ * Absent ⇒ a global fact. */
410
+ origin?: Principal;
361
411
  }
362
412
 
363
413
  /**
364
414
  * The per-connection visibility layer, registered by a storage backend that partitions per principal (the
365
415
  * profiles backend). Consumed by a frontend firehose to decide, per SSE connection, whether a given
366
416
  * 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.
417
+ * sees everything.
418
+ *
419
+ * **The predicate is consulted for EVERY kind, and decides for itself which it filters.** It used to be
420
+ * reached only for an `ItemChange` carrying a principal, because the caller gated on that — which left
421
+ * every plugin-defined kind fanned out unfiltered with no hook capable of stopping it, and made `kind` a
422
+ * constant not worth passing. Moving that judgement here is the point: routing a partitioned namespace and
423
+ * addressing a notification to a recipient are two different policies, and only the implementation knows
424
+ * which of its kinds want which. An implementation that has nothing to say about a kind returns `true`.
369
425
  */
370
426
  export interface WatchVisibility {
371
427
  /**
372
428
  * 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.
429
+ * connection owned by `q.viewer` see this change? For a partitioning backend, true iff the viewer routes
430
+ * `q.namespace` to the same partition as `q.origin` — `route(viewer, ns) === route(origin, ns)` — OR the
431
+ * item is shared INTO the viewer's partition (the owner edits a shared-in item: origin=owner ≠ the
432
+ * viewer's route, yet the viewer holds a live link to it). That yields the intended behavior uniformly:
433
+ * global/base events for namespaces the viewer has NOT isolated, own-partition events for those it has,
434
+ * plus live updates to items shared in from another partition. `q.origin` may be a partition principal
435
+ * (files, tagged by the merge) or the acting principal (skills, stamped at write) — routing both sides
436
+ * makes either correct.
437
+ *
438
+ * Fail OPEN on anything you do not recognise: a kind you have no policy for, or a query with no
439
+ * `namespace`/`origin` to route on. A predicate that guesses `false` silently drops another party's
440
+ * events; one that returns `true` leaves them exactly as unfiltered as they were before it registered.
382
441
  */
383
- visible(viewer: Principal, namespace: string, id: string, origin: Principal | undefined): boolean;
442
+ visible(q: VisibilityQuery): boolean;
443
+ }
444
+
445
+ /**
446
+ * Addressing for the file area, registered by a storage backend that partitions it per principal (the
447
+ * profiles backend). It exists so a caller that must name a file area OUT OF BAND — in a URL a plain
448
+ * browser GET will replay, with no ambient principal and no header to carry one — can do so without
449
+ * reimplementing the backend's routing.
450
+ *
451
+ * **The token is a partition, never a principal.** The two are not interchangeable: one principal may
452
+ * hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
453
+ * partition serving a principal's files is not derivable from the principal id. Minting the token from
454
+ * the identity in force — the bug this replaces — attached an addressable partition to files that were
455
+ * in the shared base area, and would have pointed at an empty partition the moment one was created
456
+ * under that name.
457
+ *
458
+ * `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
459
+ * returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
460
+ * than on the consumer because only the backend knows how a token resolves — a frontend that
461
+ * synthesised a principal from the token would be guessing at the routing again, one layer up.
462
+ */
463
+ export interface FilePartition {
464
+ /** The partition the CURRENT principal's file area resolves to, as an opaque token — or `undefined`
465
+ * for the shared base area, which needs no addressing (a bare path already reaches it). Never
466
+ * interpret the token: it is the backend's, and only `enter` gives it meaning. */
467
+ current(): string | undefined;
468
+ /** Run `fn` reading the file area named by `token` — the inverse of `current`. An unknown token
469
+ * resolves to whatever that backend serves for one; it is an address, not a capability, so it grants
470
+ * no access the caller's own gating (a file's `allowed` flag) does not already permit. */
471
+ enter<T>(token: string, fn: () => Promise<T>): Promise<T>;
384
472
  }
385
473
 
386
474
  // ── 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) {