@matatbread/matbot-plugin-api 0.2.3 → 0.2.5
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/index.ts +3 -0
- package/src/invoke-tool.ts +100 -0
- package/src/plugin.ts +6 -3
- package/src/session.ts +17 -0
- package/src/store-query.ts +1 -1
- package/src/types.ts +63 -7
- package/src/usage-context.ts +82 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,7 +4,10 @@ export * from './store-query.js';
|
|
|
4
4
|
export * from './vault.js';
|
|
5
5
|
export * from './errors.js';
|
|
6
6
|
export * from './principal-context.js';
|
|
7
|
+
export * from './usage-context.js';
|
|
8
|
+
export * from './session.js';
|
|
7
9
|
export * from './context-switch.js';
|
|
8
10
|
export * from './hooks.js';
|
|
9
11
|
export * from './plugin.js';
|
|
10
12
|
export * from './broadcast.js';
|
|
13
|
+
export * from './invoke-tool.js';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { MatbotMachine } from './plugin.js';
|
|
2
|
+
import type { ToolContext, ToolEvent, ToolResultOf, PromptFn, FormField } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
|
|
6
|
+
* forwarding a call to another tool should pass its own {@link ToolContext} straight through as
|
|
7
|
+
* `opts` — `session`, `signal`, `prompt` and crucially **`provider`** all propagate, so a callee
|
|
8
|
+
* that needs an LLM (e.g. `find_fact`, or anything using `singleTurn`) inherits the same model
|
|
9
|
+
* rather than finding no provider. Only spell out individual fields when starting a call from
|
|
10
|
+
* scratch or deliberately overriding one; never hand-pick a subset of `ctx` and drop the rest.
|
|
11
|
+
*/
|
|
12
|
+
export type InvokeToolOptions =
|
|
13
|
+
Pick<ToolContext, 'session' | 'signal'>
|
|
14
|
+
& Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId'>>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Programmatically invoke a tool by name, the same way the harness does — resolve it off the
|
|
18
|
+
* machine's tool registry, build a `ToolContext` from the machine, and return its event stream.
|
|
19
|
+
* The host-only bits a one-shot caller can't derive (the session under which the call runs, its
|
|
20
|
+
* abort signal, and an optional interactive `prompt`/`provider`) come in via `opts` — pass the
|
|
21
|
+
* calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
|
|
22
|
+
* plugin (un)loading, workdir/configPath/files) is filled from `machine`.
|
|
23
|
+
*
|
|
24
|
+
* Throws synchronously if no tool is registered under `name`. When no `prompt` is supplied the tool
|
|
25
|
+
* runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
|
|
26
|
+
* event. Pair with {@link toolText} to collapse the stream to its result string.
|
|
27
|
+
*/
|
|
28
|
+
export function invokeTool<K extends string>(
|
|
29
|
+
machine: MatbotMachine,
|
|
30
|
+
name: K,
|
|
31
|
+
params: unknown,
|
|
32
|
+
opts: InvokeToolOptions,
|
|
33
|
+
): AsyncIterable<ToolEvent<ToolResultOf<K>>> {
|
|
34
|
+
const tool = machine.tools.resolve(name);
|
|
35
|
+
if (tool === null) throw new Error(`Tool "${name}" is not registered`);
|
|
36
|
+
|
|
37
|
+
const prompt = opts.prompt ?? rejectingPrompt;
|
|
38
|
+
const ctx: ToolContext = {
|
|
39
|
+
callId: opts.callId ?? crypto.randomUUID(),
|
|
40
|
+
session: opts.session,
|
|
41
|
+
signal: opts.signal,
|
|
42
|
+
vault: machine.Vault,
|
|
43
|
+
prompt,
|
|
44
|
+
loadPlugin: (specifier, refresh) => machine.loadPlugin(specifier, prompt, refresh),
|
|
45
|
+
unloadPlugin: (specifier) => machine.unloadPlugin(specifier),
|
|
46
|
+
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
47
|
+
...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
|
|
48
|
+
...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
|
|
49
|
+
...(machine.files !== undefined ? { files: machine.files } : {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// The registry is name-keyed and untyped (`ToolExecutor.execute` yields `ToolEvent<unknown>`); the
|
|
53
|
+
// `ToolResults` mapping is the call-site contract, asserted here so callers read a typed result.
|
|
54
|
+
return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultOf<K>>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
|
|
59
|
+
* paired with `invokeTool(machine, name, …)` it returns whatever `ToolResults[name]` declares (or
|
|
60
|
+
* `unknown` for an unregistered tool). This is the structured counterpart to {@link toolText} — use it
|
|
61
|
+
* for any tool that returns data; use `toolText` when you want the result collapsed to a string.
|
|
62
|
+
* Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
|
|
63
|
+
*/
|
|
64
|
+
export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
|
|
65
|
+
let value!: R;
|
|
66
|
+
let hadResult = false;
|
|
67
|
+
for await (const ev of events) {
|
|
68
|
+
if (ev.type === 'result') { value = ev.value; hadResult = true; }
|
|
69
|
+
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
70
|
+
}
|
|
71
|
+
if (!hadResult) throw new Error('Tool produced no result');
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its result as text. Stops and
|
|
77
|
+
* throws on the first `error` event, or if the tool finished without yielding a `result`. The result
|
|
78
|
+
* value is rendered the way the model would see it: a string verbatim, a `{ content: string }`
|
|
79
|
+
* (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
|
|
80
|
+
*/
|
|
81
|
+
export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
|
|
82
|
+
let result: unknown;
|
|
83
|
+
let hadResult = false;
|
|
84
|
+
for await (const ev of events) {
|
|
85
|
+
if (ev.type === 'result') { result = ev.value; hadResult = true; }
|
|
86
|
+
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
87
|
+
}
|
|
88
|
+
if (!hadResult) throw new Error('Tool produced no result');
|
|
89
|
+
|
|
90
|
+
if (typeof result === 'string') return result;
|
|
91
|
+
if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
|
|
92
|
+
return (result as { content: string }).content;
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(result, null, 2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
|
|
98
|
+
const label = typeof p === 'string' ? p : p.label;
|
|
99
|
+
return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
|
|
100
|
+
}) as PromptFn);
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
FileStore, Vault, Message, ModelParameters,
|
|
3
3
|
ProviderAdapter, ProviderConfig, Tool, ToolRegistry, FrontendInfo,
|
|
4
|
-
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner,
|
|
4
|
+
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { HookRegistry } from './hooks.js';
|
|
7
7
|
|
|
@@ -19,7 +19,7 @@ export interface CompletionRequest {
|
|
|
19
19
|
|
|
20
20
|
export interface CompletionResponse {
|
|
21
21
|
text: string;
|
|
22
|
-
usage:
|
|
22
|
+
usage: Usage;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface SingleTurnRequest {
|
|
@@ -116,8 +116,11 @@ export interface MatbotRuntime {
|
|
|
116
116
|
* Hot-load a plugin by specifier into the running process. Returns the loaded plugin.
|
|
117
117
|
* `prompt`, when supplied, resolves tool-name collisions interactively during the new
|
|
118
118
|
* plugin's setup(); the runner injects the triggering session's prompt automatically.
|
|
119
|
+
* `refresh` (default false) forces a remote (github/http) plugin's `.plugins/` cache subtree
|
|
120
|
+
* to be evicted and re-downloaded — a reload of changed upstream source. Cache-first otherwise:
|
|
121
|
+
* a programmatic loader gets the cached copy and stays offline-tolerant.
|
|
119
122
|
*/
|
|
120
|
-
loadPlugin(specifier: string, prompt?: PromptFn): Promise<MatbotPlugin>;
|
|
123
|
+
loadPlugin(specifier: string, prompt?: PromptFn, refresh?: boolean): Promise<MatbotPlugin>;
|
|
121
124
|
|
|
122
125
|
/**
|
|
123
126
|
* Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Session } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A session's `updatedAt` invariant: the timestamp of its last message, or its own `createdAt` when it
|
|
5
|
+
* has none. `updatedAt` tracks *conversational activity*, not structural or metadata edits — so a
|
|
6
|
+
* compaction, cut, split, fork, rename, or archive leaves it (and thus the session's place in a
|
|
7
|
+
* recency-sorted list) untouched, since none of them change which message is last. Kept as a
|
|
8
|
+
* materialised field, not a getter: `Session` round-trips as plain JSON through every storage backend
|
|
9
|
+
* and is sorted on `updatedAt` as a stored column, neither of which survives a live accessor.
|
|
10
|
+
*
|
|
11
|
+
* Apply it to the final session object at every write — `{ ...session, updatedAt: lastActivityAt(session) }`
|
|
12
|
+
* — rather than stamping a fresh `now()`.
|
|
13
|
+
*/
|
|
14
|
+
export function lastActivityAt(session: Session): string {
|
|
15
|
+
const last = session.messages[session.messages.length - 1];
|
|
16
|
+
return last ? last.createdAt : session.createdAt;
|
|
17
|
+
}
|
package/src/store-query.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -39,6 +39,29 @@ export interface ProviderConfig {
|
|
|
39
39
|
fallback?: string;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Token (and optional cost) counters for one provider call — pure accounting data, never conversation
|
|
44
|
+
* content. Recorded onto the message stream for cost reckoning but elided from provider submission. The
|
|
45
|
+
* producing provider is recorded alongside at each storage site (an assistant turn carries it on the
|
|
46
|
+
* message's `providerName`; a tool-invoked completion pairs it explicitly — see `tool-result`), since a
|
|
47
|
+
* tool may run a completion against any configured provider, each with its own rates.
|
|
48
|
+
*/
|
|
49
|
+
export interface Usage {
|
|
50
|
+
inputTokens: number;
|
|
51
|
+
outputTokens: number;
|
|
52
|
+
costUsd?: number;
|
|
53
|
+
cacheReadTokens?: number;
|
|
54
|
+
cacheCreationTokens?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One provider call's usage, tagged with the provider billed — the unit a tool accrues (a tool may
|
|
58
|
+
* run completions against any provider, each with its own rates) and the element persisted on a
|
|
59
|
+
* `tool-result`'s `usage` addendum. See the ambient usage carrier (`recordUsage`/`currentUsageSink`). */
|
|
60
|
+
export interface UsageRecord {
|
|
61
|
+
provider: string;
|
|
62
|
+
usage: Usage;
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
export type CompletionEvent =
|
|
43
66
|
| { type: 'text-delta'; delta: string }
|
|
44
67
|
| { type: 'tool-call'; id: string; name: string; input: unknown }
|
|
@@ -49,7 +72,7 @@ export type CompletionEvent =
|
|
|
49
72
|
| { type: 'reasoning-block'; reasoning: string }
|
|
50
73
|
| { type: 'refusal'; text: string }
|
|
51
74
|
| { type: 'unknown-block'; blockType: string; raw: unknown }
|
|
52
|
-
| { type: 'usage'
|
|
75
|
+
| ({ type: 'usage' } & Usage)
|
|
53
76
|
| { type: 'done' };
|
|
54
77
|
|
|
55
78
|
export interface ProviderAdapter {
|
|
@@ -77,7 +100,14 @@ export type MessageContent = (
|
|
|
77
100
|
| { type: 'document'; data: string; mimeType: MimeType; name?: string }
|
|
78
101
|
| { type: 'audio'; data: string; mimeType: MimeType }
|
|
79
102
|
| { type: 'tool-call'; id: string; name: string; input: unknown }
|
|
80
|
-
| { type: 'tool-result'; id: string; result: unknown; isError?: boolean
|
|
103
|
+
| { type: 'tool-result'; id: string; result: unknown; isError?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Accounting addendum: completions the tool itself ran (e.g. `single_turn`, `dream_time`'s ranker/
|
|
106
|
+
* merger), captured ambiently via the usage carrier and recorded for cost reckoning. Elided from
|
|
107
|
+
* provider submission — adapters serialise only `id`/`result`/`isError`. One entry per provider
|
|
108
|
+
* call, each tagged with the provider billed; never summed at write time, as rates differ.
|
|
109
|
+
*/
|
|
110
|
+
usage?: UsageRecord[] }
|
|
81
111
|
| { type: 'refusal'; text: string }
|
|
82
112
|
| { type: 'file-ref'; fileId: string; name: string; mimeType: MimeType }
|
|
83
113
|
| { type: 'form'; fields: FormField[]; submitLabel?: string }
|
|
@@ -158,6 +188,9 @@ export interface Message {
|
|
|
158
188
|
createdAt: ISODate;
|
|
159
189
|
traceId: string;
|
|
160
190
|
providerName?: string;
|
|
191
|
+
/** Token/cost accounting for the provider call that produced this message (assistant turns). The
|
|
192
|
+
* billed provider is this message's `providerName`. Accounting only — elided from provider submission. */
|
|
193
|
+
usage?: Usage;
|
|
161
194
|
metadata?: Record<string, unknown>;
|
|
162
195
|
}
|
|
163
196
|
|
|
@@ -405,11 +438,11 @@ export interface KnowledgeIndex {
|
|
|
405
438
|
|
|
406
439
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
407
440
|
|
|
408
|
-
export type ToolEvent =
|
|
441
|
+
export type ToolEvent<Result = unknown> =
|
|
409
442
|
| { type: 'stdout'; chunk: string }
|
|
410
443
|
| { type: 'stderr'; chunk: string }
|
|
411
444
|
| { type: 'progress'; pct: number; message?: string }
|
|
412
|
-
| { type: 'result'; value:
|
|
445
|
+
| { type: 'result'; value: Result }
|
|
413
446
|
| { type: 'file'; handle: FileHandle }
|
|
414
447
|
// A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
|
|
415
448
|
// side-effect). Persisted as a `marker`-role message; elided from provider submission like any
|
|
@@ -418,6 +451,26 @@ export type ToolEvent =
|
|
|
418
451
|
| { type: 'marker'; creator: string; data: unknown }
|
|
419
452
|
| { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
|
|
420
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Per-tool result-type registry: maps a tool's `name` to the type of the `value` it yields in its
|
|
456
|
+
* `result` event. Augment it exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the
|
|
457
|
+
* concrete result type back from {@link invokeTool} + `toolResult` instead of `unknown`:
|
|
458
|
+
*
|
|
459
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
460
|
+
* interface ToolResults { find_fact: string[] | null }
|
|
461
|
+
* }
|
|
462
|
+
*
|
|
463
|
+
* The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
|
|
464
|
+
* `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
|
|
465
|
+
* name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
|
|
466
|
+
* type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
|
|
467
|
+
* executor already produces. Keep the entry in sync with what the executor actually yields.
|
|
468
|
+
*/
|
|
469
|
+
export interface ToolResults {}
|
|
470
|
+
|
|
471
|
+
/** The `result` value a tool named `K` yields, or `unknown` when `K` isn't registered in {@link ToolResults}. */
|
|
472
|
+
export type ToolResultOf<K extends string> = K extends keyof ToolResults ? ToolResults[K] : unknown;
|
|
473
|
+
|
|
421
474
|
/**
|
|
422
475
|
* Ask the user a question and resolve with their answer. The host supplies the
|
|
423
476
|
* implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
|
|
@@ -455,8 +508,10 @@ export interface ToolContext {
|
|
|
455
508
|
files?: FileStore;
|
|
456
509
|
/** Prompt the user for input. The host provides a readline or form implementation. */
|
|
457
510
|
prompt: PromptFn;
|
|
458
|
-
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
459
|
-
|
|
511
|
+
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
512
|
+
* `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
|
|
513
|
+
* re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
|
|
514
|
+
loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
|
|
460
515
|
/**
|
|
461
516
|
* Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
|
|
462
517
|
* Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
|
|
@@ -616,9 +671,10 @@ export type PipelineEvent =
|
|
|
616
671
|
| { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
|
|
617
672
|
| { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
|
|
618
673
|
| { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
|
|
674
|
+
| { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
|
|
619
675
|
| { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
|
|
620
676
|
| { type: 'file'; handle: FileHandle; traceId: string }
|
|
621
|
-
| { type: 'usage';
|
|
677
|
+
| ({ type: 'usage'; traceId: string } & Usage)
|
|
622
678
|
| { type: 'done'; session: Session; traceId: string }
|
|
623
679
|
| { type: 'aborted'; reason: string; session: Session; traceId: string }
|
|
624
680
|
| { type: 'cancelled'; sessionId: string; traceId: string }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Usage, UsageRecord } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ambient carrier for token accounting — the channel by which a completion run *inside a tool* (via
|
|
5
|
+
* `MatbotRuntime.complete`/`singleTurn`) reports its usage back to the runner without that bookkeeping
|
|
6
|
+
* being threaded through every tool, ranker, or merger signature. It mirrors the {@link PrincipalCarrier}:
|
|
7
|
+
* a sink is established for an async extent, and code deep within (the host's `complete()`) appends to
|
|
8
|
+
* whatever sink is in force via {@link recordUsage}.
|
|
9
|
+
*
|
|
10
|
+
* Why ambient. A tool reaches an LLM only through the core completion services — it cannot call a
|
|
11
|
+
* provider directly. Recording at that one choke point therefore captures *every* tool's spend (a
|
|
12
|
+
* `single_turn`, an `ask_inner_voice`, each of `dream_time`'s per-fact ranker/merger calls) with zero
|
|
13
|
+
* per-tool code. The runner opens one sink per turn ({@link withUsageScope}) and slices it per tool
|
|
14
|
+
* call, attributing each completion to the call that triggered it.
|
|
15
|
+
*
|
|
16
|
+
* The host installs one platform implementation at boot:
|
|
17
|
+
* - node (concurrent turns): an `AsyncLocalStorage`-backed carrier, so overlapping turns keep
|
|
18
|
+
* separate sinks (see the node app's `createAlsUsageCarrier`).
|
|
19
|
+
* - browser / single-turn realms: {@link createSerialUsageCarrier} — a set-and-restore global,
|
|
20
|
+
* correct while turns do not truly overlap (the same realm the constant principal carrier assumes).
|
|
21
|
+
*
|
|
22
|
+
* Capture is best-effort accounting: with no carrier installed (e.g. tests), {@link recordUsage} and
|
|
23
|
+
* {@link currentUsageSink} are no-ops and usage simply isn't recorded — never an error.
|
|
24
|
+
*/
|
|
25
|
+
export interface UsageCarrier {
|
|
26
|
+
/** Establish a fresh `sink` for the dynamic extent of `fn`. Consume any async iterable *inside* `fn`. */
|
|
27
|
+
run<T>(sink: UsageRecord[], fn: () => T): T;
|
|
28
|
+
/** The sink in force for the current async flow, or `undefined` outside any scope. */
|
|
29
|
+
tryCurrent(): UsageRecord[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CARRIER_KEY = Symbol.for('@matatbread/matbot-plugin-api#usageCarrier');
|
|
33
|
+
type CarrierGlobal = { [CARRIER_KEY]?: UsageCarrier };
|
|
34
|
+
|
|
35
|
+
/** Install the host's platform carrier. Called once at boot. */
|
|
36
|
+
export function installUsageCarrier(impl: UsageCarrier): void {
|
|
37
|
+
(globalThis as CarrierGlobal)[CARRIER_KEY] = impl;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The usage sink in force, or `undefined` when no carrier is installed or no scope is established. */
|
|
41
|
+
export function currentUsageSink(): UsageRecord[] | undefined {
|
|
42
|
+
return (globalThis as CarrierGlobal)[CARRIER_KEY]?.tryCurrent();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Append one provider call's usage to the sink in force. No-op outside any scope (best-effort accounting). */
|
|
46
|
+
export function recordUsage(provider: string, usage: Usage): void {
|
|
47
|
+
currentUsageSink()?.push({ provider, usage });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run `fn` with a fresh, empty usage sink established for its async extent. Passthrough when no carrier. */
|
|
51
|
+
export function withUsageScope<T>(fn: () => T): T {
|
|
52
|
+
const carrier = (globalThis as CarrierGlobal)[CARRIER_KEY];
|
|
53
|
+
if (carrier === undefined) return fn();
|
|
54
|
+
return carrier.run([], fn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A carrier for realms with no async-context isolation (the browser, tests): a single module-global
|
|
59
|
+
* sink, set on `run` and restored once `fn` settles. Correct as long as scopes do not truly overlap —
|
|
60
|
+
* the same single-turn assumption the constant principal carrier makes.
|
|
61
|
+
*/
|
|
62
|
+
export function createSerialUsageCarrier(): UsageCarrier {
|
|
63
|
+
let current: UsageRecord[] | undefined;
|
|
64
|
+
return {
|
|
65
|
+
run(sink, fn) {
|
|
66
|
+
const prev = current;
|
|
67
|
+
current = sink;
|
|
68
|
+
try {
|
|
69
|
+
const r = fn();
|
|
70
|
+
if (r instanceof Promise) {
|
|
71
|
+
return r.finally(() => { current = prev; }) as unknown as ReturnType<typeof fn>;
|
|
72
|
+
}
|
|
73
|
+
current = prev;
|
|
74
|
+
return r;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
current = prev;
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
tryCurrent: () => current,
|
|
81
|
+
};
|
|
82
|
+
}
|