@matatbread/matbot-plugin-api 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.2.2",
7
+ "version": "0.2.4",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ 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';
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: { inputTokens: number; outputTokens: number };
22
+ usage: Usage;
23
23
  }
24
24
 
25
25
  export interface SingleTurnRequest {
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
+ }
@@ -38,7 +38,7 @@ export interface StoreQuery {
38
38
  where?: Filter;
39
39
  sort?: SortSpec[];
40
40
  limit?: number;
41
- cursor?: string;
41
+ cursor?: string | undefined;
42
42
  }
43
43
 
44
44
  export interface QueryResult<T> {
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'; inputTokens: number; outputTokens: number; costUsd?: number; cacheReadTokens?: number; cacheCreationTokens?: number }
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
 
@@ -618,7 +651,7 @@ export type PipelineEvent =
618
651
  | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
619
652
  | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
620
653
  | { type: 'file'; handle: FileHandle; traceId: string }
621
- | { type: 'usage'; inputTokens: number; outputTokens: number; costUsd?: number; cacheReadTokens?: number; cacheCreationTokens?: number; traceId: string }
654
+ | ({ type: 'usage'; traceId: string } & Usage)
622
655
  | { type: 'done'; session: Session; traceId: string }
623
656
  | { type: 'aborted'; reason: string; session: Session; traceId: string }
624
657
  | { 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
+ }