@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 +1 -1
- package/src/hooks.ts +11 -1
- package/src/plugin.ts +80 -13
- package/src/store-query.ts +6 -0
- package/src/types/builtin-tools.ts +175 -0
- package/src/types/events.ts +4 -1
- package/src/types/messages.ts +20 -12
- package/src/types/provider.ts +103 -1
- package/src/types.ts +1 -0
- package/src/usage-context.ts +115 -21
package/package.json
CHANGED
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
|
|
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.
|
|
368
|
-
*
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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(
|
|
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 ──────────────────────────────────────────────────────────
|
package/src/store-query.ts
CHANGED
|
@@ -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 }>;
|
package/src/types/events.ts
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/src/types/messages.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
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
|
|
package/src/types/provider.ts
CHANGED
|
@@ -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';
|
package/src/usage-context.ts
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
|
-
import type { Usage,
|
|
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
|
|
8
|
-
* whatever
|
|
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
|
|
14
|
-
*
|
|
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
|
|
27
|
-
run<T>(
|
|
28
|
-
/** The
|
|
29
|
-
tryCurrent():
|
|
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
|
|
41
|
-
export function
|
|
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
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
|
|
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
|
-
/**
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
157
|
+
let current: UsageScope | undefined;
|
|
64
158
|
return {
|
|
65
|
-
run(
|
|
159
|
+
run(scope, fn) {
|
|
66
160
|
const prev = current;
|
|
67
|
-
current =
|
|
161
|
+
current = scope;
|
|
68
162
|
try {
|
|
69
163
|
const r = fn();
|
|
70
164
|
if (r instanceof Promise) {
|