@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 +1 -1
- package/src/context-switch.ts +173 -35
- package/src/hooks.ts +11 -1
- package/src/plugin.ts +101 -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/context-switch.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
//
|
|
25
|
-
// quiescers and one
|
|
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<
|
|
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<
|
|
50
|
+
quiescers: new Set<Quiescer>(),
|
|
33
51
|
depth: 0,
|
|
34
52
|
flushing: false,
|
|
35
53
|
}));
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
50
|
-
* {@link
|
|
51
|
-
* queued while busy (depth > 0) this no-ops and the
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
368
|
-
*
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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(
|
|
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 ──────────────────────────────────────────────────────────
|
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) {
|