@matatbread/matbot-plugin-api 0.1.0

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/src/types.ts ADDED
@@ -0,0 +1,704 @@
1
+ import type { MatbotPlugin } from './plugin.js';
2
+ import type { StoreQuery, QueryResult } from './store-query.js';
3
+
4
+ // ── Primitives ────────────────────────────────────────────────────────────────
5
+
6
+ export type ISODate = string;
7
+ export type MimeType = string;
8
+ export type JSONSchema = Record<string, unknown>;
9
+
10
+ // ── Principal ───────────────────────────────────────────────────────────────
11
+
12
+ /** Identity of whatever initiated an operation — a turn, a tool call, a store read/write.
13
+ * Established at each entry point and carried ambiently via the `PrincipalCarrier`
14
+ * (`currentPrincipal()` / `runAs()`), so any layer can attribute or test the origin without it
15
+ * being threaded through every signature. It grants nothing — policy is the service's concern. */
16
+ export interface Principal {
17
+ id: string;
18
+ type: 'user' | 'agent' | 'system';
19
+ }
20
+
21
+ // ── Provider ──────────────────────────────────────────────────────────────────
22
+
23
+ export interface ModelParameters {
24
+ temperature?: number;
25
+ maxTokens?: number;
26
+ topP?: number;
27
+ stopSequences?: string[];
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface ProviderConfig {
32
+ name: string;
33
+ /** Plugin module specifier (npm name or file URL) that provides the adapter for this provider. */
34
+ module: string;
35
+ model: string;
36
+ credentials?: Record<string, string>;
37
+ endpoint?: string;
38
+ parameters?: ModelParameters;
39
+ fallback?: string;
40
+ }
41
+
42
+ export type CompletionEvent =
43
+ | { type: 'text-delta'; delta: string }
44
+ | { type: 'tool-call'; id: string; name: string; input: unknown }
45
+ | { type: 'tool-result'; id: string; result: unknown }
46
+ | { type: 'thinking'; delta: string }
47
+ | { type: 'thinking-block'; thinking: string; signature: string }
48
+ | { type: 'redacted-thinking'; data: string }
49
+ | { type: 'reasoning-block'; reasoning: string }
50
+ | { type: 'refusal'; text: string }
51
+ | { type: 'unknown-block'; blockType: string; raw: unknown }
52
+ | { type: 'usage'; inputTokens: number; outputTokens: number; costUsd?: number; cacheReadTokens?: number; cacheCreationTokens?: number }
53
+ | { type: 'done' };
54
+
55
+ export interface ProviderAdapter {
56
+ readonly name: string;
57
+ complete(
58
+ messages: Message[],
59
+ config: ProviderConfig,
60
+ tools: readonly Tool[],
61
+ signal: AbortSignal
62
+ ): AsyncIterable<CompletionEvent>;
63
+ health(): Promise<HealthStatus>;
64
+ }
65
+
66
+ // ── Messages & Session ────────────────────────────────────────────────────────
67
+
68
+ export type MessageRole = 'user' | 'assistant' | 'tool' | 'system' | 'marker';
69
+
70
+ export type MessageContent = (
71
+ | { type: 'text'; text: string }
72
+ | { type: 'thinking'; thinking: string; signature: string }
73
+ | { type: 'redacted-thinking'; data: string }
74
+ | { type: 'reasoning'; reasoning: string }
75
+ | { type: 'image'; data: string; mimeType: MimeType }
76
+ | { type: 'image-url'; url: string; detail?: 'low' | 'high' | 'auto' }
77
+ | { type: 'document'; data: string; mimeType: MimeType; name?: string }
78
+ | { type: 'audio'; data: string; mimeType: MimeType }
79
+ | { type: 'tool-call'; id: string; name: string; input: unknown }
80
+ | { type: 'tool-result'; id: string; result: unknown; isError?: boolean }
81
+ | { type: 'refusal'; text: string }
82
+ | { type: 'file-ref'; fileId: string; name: string; mimeType: MimeType }
83
+ | { type: 'form'; fields: FormField[]; submitLabel?: string }
84
+ | { type: 'form-response'; values: Record<string, string> }
85
+ | { type: 'marker'; creator: string; data: unknown }
86
+ | { type: 'unknown-content'; blockType: string; raw: unknown }
87
+ ) & {
88
+ /**
89
+ * Authorship provenance, for *presentation only* — orthogonal to the message's `role`, which is
90
+ * the LLM-protocol identity. Absent ⇒ authored per the role (a human for `user`, the model for
91
+ * `assistant`). `'robo'` ⇒ machine-authored by matbot — a `followup` resubmission, or a hook-
92
+ * injected fragment inside a human turn. It is still carried to the model as ordinary
93
+ * role-appropriate content (the model sees a `user` block either way); the flag is OOB metadata
94
+ * that frontends use to present it agent-side (a robot indicator) rather than as the user's words.
95
+ */
96
+ origin?: 'robo';
97
+ };
98
+
99
+ /**
100
+ * A marker is opaque, durable annotation carried in the message stream: links, status,
101
+ * cross-references that are meaningful to a frontend but transparent to the LLM. They are
102
+ * persisted unchanged, elided from provider submission, and deliberately preserved by
103
+ * session compaction (removing one can break things — e.g. a pointer back to an ancestor
104
+ * session). Any code with session access may emit one, normally as its own message.
105
+ *
106
+ * `creator` is the emitting plugin's reference; `data` is anything serialisable. For
107
+ * per-creator type safety, augment `MarkerData` and read/write via `Marker<'your-creator'>`:
108
+ *
109
+ * declare module '@matatbread/matbot-plugin-api' {
110
+ * interface MarkerData { 'split-session': { peerSessionId: string } }
111
+ * }
112
+ *
113
+ * Unregistered creators fall back to `data: unknown`. The base `MessageContent` member stays
114
+ * loose (`creator: string`) so the union and its exhaustive switches are unaffected.
115
+ */
116
+ export interface MarkerData {
117
+ /** Emitted by the hook dispatcher when a hook handler threw: the hook was skipped (treated as a
118
+ * no-op) and this records it once, so a misconfigured/throwing hook degrades visibly instead of
119
+ * bricking the turn. `channel` is the hook point, `pluginName` the owning plugin if known. */
120
+ 'matbot-hooks': { channel: HookPoint; pluginName?: string; message: string };
121
+ }
122
+
123
+ export type Marker<K extends string = string> = {
124
+ type: 'marker';
125
+ creator: K;
126
+ data: K extends keyof MarkerData ? MarkerData[K] : unknown;
127
+ };
128
+
129
+ export interface FormField {
130
+ name: string;
131
+ label: string;
132
+ type: 'text' | 'password' | 'select' | 'confirm';
133
+ options?: string[];
134
+ /** select-only: render an "Other…" affordance that lets the user type a free-form answer instead
135
+ * of picking an option. The typed value is returned verbatim, on the same channel as a pick —
136
+ * callers never learn whether the answer was a preset or free text. Ignored for other types. */
137
+ allowOther?: boolean;
138
+ default?: string;
139
+ required?: boolean;
140
+ /** Presentation hint only (default true): whether the frontend offers a cancel affordance (the
141
+ * "give up" path — see `PromptFn`). Set false to render a hard-blocking prompt with no out. The
142
+ * runner and server never consult this; a frontend with no cancel UI simply can't fire one. */
143
+ cancelable?: boolean;
144
+ }
145
+
146
+ /**
147
+ * Canonical values a `type: 'confirm'` prompt resolves to. The rendered label and buttons are a
148
+ * cosmetic, host-specific concern (and may be localised), so consumers MUST branch on these tokens
149
+ * — never on the displayed string. Compare case-insensitively to tolerate host casing differences.
150
+ */
151
+ export const CONFIRM_YES = 'yes';
152
+ export const CONFIRM_NO = 'no';
153
+
154
+ export interface Message {
155
+ id: string;
156
+ role: MessageRole;
157
+ content: MessageContent[];
158
+ createdAt: ISODate;
159
+ traceId: string;
160
+ providerName?: string;
161
+ metadata?: Record<string, unknown>;
162
+ }
163
+
164
+ export type SessionStatus = 'active' | 'archived' | 'pinned';
165
+
166
+ export interface Session {
167
+ id: string;
168
+ version: string;
169
+ ownerPrincipalId: string;
170
+ actorPrincipalId?: string;
171
+ persona?: string;
172
+ title?: string;
173
+ status: SessionStatus;
174
+ contexts: string[];
175
+ messages: Message[];
176
+ parentSessionId?: string;
177
+ branchPointMessageId?: string;
178
+ createdAt: ISODate;
179
+ updatedAt: ISODate;
180
+ }
181
+
182
+ // ── System context ────────────────────────────────────────────────────────────
183
+
184
+ export type SystemContextContributor = (ctx: {
185
+ session: Session;
186
+ signal: AbortSignal;
187
+ }) => string | null | Promise<string | null>;
188
+
189
+ export interface SystemContextRegistry {
190
+ register(contributor: SystemContextContributor, pluginName?: string): void;
191
+ removeByPlugin(pluginName: string): void;
192
+ /** Calls all contributors and joins non-null, non-empty results with double newlines. */
193
+ build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null>;
194
+ }
195
+
196
+ // ── Pipeline hooks ────────────────────────────────────────────────────────────
197
+
198
+ export interface RunConfig {
199
+ provider: string;
200
+ persona?: string;
201
+ sessionId?: string;
202
+ traceId?: string;
203
+ }
204
+
205
+ /**
206
+ * Hooks are sorted by the *job* they do, not by lifecycle position — the channel name is the
207
+ * contract. Each channel has a fixed home, cadence, and effect-ceiling (a hook may always do
208
+ * less: returning nothing makes it a pure observer). The discriminated union on `on` is what
209
+ * keeps the effects honest — `contribute` hands you a read-only session, `toolcall`/`react` can't
210
+ * return one at all, so a write that goes nowhere won't type-check.
211
+ *
212
+ * screen runner, once per turn before the first provider call. The only channel that may
213
+ * durably mutate history. Returns any of: a replacement `session` (persisted),
214
+ * turn-scoped `ephemeral` context (appended onto the tail of this turn's outgoing
215
+ * messages — the freshest input the model reads — never persisted, and placed at the
216
+ * tail rather than a system prefix so a "do X now" directive keeps its salience and
217
+ * doesn't bust the cached prefix), `durable` context (the persisted, visible twin of
218
+ * `ephemeral`: folded onto this turn's user message as `origin: 'robo'` blocks and
219
+ * carried live as a `robo-user` event, so it survives into the next turn's history),
220
+ * or `abort`. Mix freely. This is where the durable-vs-ephemeral choice for incoming
221
+ * user input lives.
222
+ * contribute runner, before *every* provider call. Ephemeral by construction (it re-fires, so a
223
+ * durable mutation would accumulate). Returns a transformed copy of `outgoing` — the
224
+ * message array about to be sent — and never touches the stored session. Mind prompt
225
+ * caching: vary the *tail* (newest turn) freely, but a transform that rewrites the
226
+ * cached prefix (system / early history) busts the cache on every call.
227
+ * toolcall runner, before each tool execution. Read-only. Returns `rejectTool` (skip this call,
228
+ * feed an error result back so the model self-corrects, without breaking the
229
+ * tool_use/tool_result pairing) and/or `abort`.
230
+ * toolresult runner, after each tool execution, before the result is recorded/yielded. Folds the
231
+ * result through each hook: return `{ result }` to replace it (hard redaction, truncation),
232
+ * or nothing to observe (auditing — the context carries args, result, isError and
233
+ * `durationMs`). It owns the LLM-facing + persisted result and the `tool:end` event;
234
+ * note it does NOT see the live `tool:stdout/stderr` chunks, which stream before the
235
+ * result exists.
236
+ * followup pump, once after a turn commits (post-persist). May `resubmit` a robo follow-up turn
237
+ * (head-enqueued, so it runs next as its own real turn; `resubmitDepth` is the chain
238
+ * length for the hook's own budget — the runner also hard-caps it), `retractAndRerun`
239
+ * (pop the committed turn into a marker and re-run the originating user turn with
240
+ * ephemeral context — supersede rather than follow), and/or append durable `markers`
241
+ * to the committed session (LLM-invisible annotations — the second durable-write point
242
+ * after `screen`, safe because it too fires once per turn).
243
+ */
244
+ export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup';
245
+
246
+ export interface ScreenContext {
247
+ session: Session;
248
+ config: RunConfig;
249
+ signal: AbortSignal;
250
+ /** The turn's interactive prompt, when a frontend supplied one (a live user behind this turn).
251
+ * A hook that drives an interactive tool (e.g. a trigger invoking `ask_user`) forwards this into
252
+ * the tool's context; absent (cron/background/no frontend) the tool gets a rejecting prompt. */
253
+ prompt?: PromptFn;
254
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
255
+ removeHook(): void;
256
+ }
257
+ export interface ScreenResult {
258
+ session?: Session;
259
+ /** Turn-scoped context appended onto the tail of this turn's outgoing messages (the freshest
260
+ * input the model reads), never persisted. At the tail, not a system prefix, so a directive
261
+ * keeps its salience and the cached system/history prefix stays stable across turns. */
262
+ ephemeral?: MessageContent[];
263
+ /**
264
+ * The persisted, visible twin of `ephemeral`: context that should outlive this turn rather than
265
+ * inform it once. The runner folds these blocks onto this turn's user message (so they ride into
266
+ * the stored history and every subsequent provider call) AND carries them live as a `robo-user`
267
+ * event, so a live draw and a reload render the same thing. They are LLM-visible (unlike
268
+ * `markers`) and machine-authored, so a caller marks them `origin: 'robo'` for presentation.
269
+ * Use when a fired hook produces context that genuinely updates the conversation, not a one-shot
270
+ * corrective for the turn about to run.
271
+ */
272
+ durable?: MessageContent[];
273
+ /**
274
+ * Durable `marker` blocks to append to this turn's session (LLM-invisible). The dispatcher both
275
+ * appends them to the persisted session AND carries them live on the turn's event stream, so a
276
+ * live draw and a reload render the same thing. Use instead of hand-appending to `session` when
277
+ * you just want to annotate (e.g. a fired trigger's silent tool recording what it did).
278
+ */
279
+ markers?: MessageContent[];
280
+ abort?: string;
281
+ }
282
+
283
+ export interface ContributeContext {
284
+ readonly outgoing: readonly Message[];
285
+ readonly session: Session;
286
+ config: RunConfig;
287
+ signal: AbortSignal;
288
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
289
+ removeHook(): void;
290
+ }
291
+
292
+ export interface ToolCallContext {
293
+ readonly session: Session;
294
+ readonly toolCall: { id: string; name: string; input: unknown };
295
+ readonly tool: Tool;
296
+ config: RunConfig;
297
+ signal: AbortSignal;
298
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
299
+ removeHook(): void;
300
+ }
301
+ export interface ToolCallResult {
302
+ rejectTool?: { message: string };
303
+ abort?: string;
304
+ }
305
+
306
+ export interface ToolResultContext {
307
+ readonly session: Session;
308
+ readonly toolCall: { id: string; name: string; input: unknown };
309
+ readonly tool: Tool;
310
+ readonly result: unknown;
311
+ readonly isError: boolean;
312
+ readonly durationMs: number;
313
+ config: RunConfig;
314
+ signal: AbortSignal;
315
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
316
+ removeHook(): void;
317
+ }
318
+ // The toolresult hook returns `{ result }` to replace the tool's result, or nothing to leave it
319
+ // (and just observe) — a trivial single-field return, inlined in the Hook union like `contribute`'s.
320
+
321
+ export interface FollowupContext {
322
+ readonly session: Session;
323
+ readonly resubmitDepth: number;
324
+ config: RunConfig;
325
+ signal: AbortSignal;
326
+ /** The turn's interactive prompt, when a frontend supplied one (a live user behind this turn).
327
+ * A hook that drives an interactive tool (e.g. a trigger invoking `ask_user` as a proactive
328
+ * follow-up question) forwards this into the tool's context; absent (cron/background/no frontend)
329
+ * the tool gets a rejecting prompt. Note this prompt fires *post-commit*, out of band from the
330
+ * turn's `done`, and blocks the pump until the human answers. */
331
+ prompt?: PromptFn;
332
+ /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
333
+ removeHook(): void;
334
+ }
335
+ export interface FollowupResult {
336
+ resubmit?: { content: MessageContent[] };
337
+ /**
338
+ * Retract-and-rerun: supersede the just-committed turn instead of following it. The pump pops the
339
+ * committed turn back to (and excluding) the last user message, stashes the popped content in a
340
+ * durable retraction marker (LLM-elided like every marker, so a frontend can render it
341
+ * struck-through and a post-mortem can audit it), then re-runs that same user turn with `context`
342
+ * injected EPHEMERALLY (tail-folded, never persisted) — agent-phase injection time-shifted onto a
343
+ * committed turn. This is the inverse of `resubmit`, which leaves the response in place and appends
344
+ * a new robo turn after it. Self-terminating by design: a well-formed trigger fires on a *curable*
345
+ * defect that the injected context dissolves on the redo, so it won't re-fire; `resubmitDepth` (a
346
+ * redo carries parent+1) caps an ill-formed one. `resubmit` and `retractAndRerun` are independent
347
+ * capabilities — a single turn returning both is not expected, but both head-enqueue if it does.
348
+ */
349
+ retractAndRerun?: { context: MessageContent[] };
350
+ /**
351
+ * Durable `marker` blocks to append to the just-committed session (LLM-invisible; for tracing /
352
+ * cross-references). The second durable-write capability after `screen` — safe here for the same
353
+ * reason: `followup` fires once per turn, so an append can't accumulate the way a per-call channel
354
+ * would. The pump persists them AND emits them live (post-commit, like a `queued` event) so a live
355
+ * draw matches a reload. Use for recording what a post-commit reaction (e.g. a fired trigger's
356
+ * silent tool) actually did.
357
+ */
358
+ markers?: MessageContent[];
359
+ }
360
+
361
+ export type Hook =
362
+ | { on: 'screen'; priority?: number; pluginName?: string; handler(ctx: ScreenContext): ScreenResult | void | Promise<ScreenResult | void> }
363
+ | { on: 'contribute'; priority?: number; pluginName?: string; handler(ctx: ContributeContext): Message[] | void | Promise<Message[] | void> }
364
+ | { on: 'toolcall'; priority?: number; pluginName?: string; handler(ctx: ToolCallContext): ToolCallResult | void | Promise<ToolCallResult | void> }
365
+ | { on: 'toolresult'; priority?: number; pluginName?: string; handler(ctx: ToolResultContext): { result: unknown } | void | Promise<{ result: unknown } | void> }
366
+ | { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> };
367
+
368
+ // ── Storage ───────────────────────────────────────────────────────────────────
369
+ // The query grammar (Filter AST, StoreQuery, QueryResult, StoreQueryError) lives in ./store-query.
370
+
371
+ export type CASResult<T> =
372
+ | { ok: true; doc: T }
373
+ | { ok: false; current: T | null };
374
+
375
+ export interface Store<T extends { id: string; version: string }> {
376
+ get(id: string): Promise<T | null>;
377
+ set(id: string, value: T): Promise<void>;
378
+ cas(id: string, expected: string, next: T): Promise<CASResult<T>>;
379
+ delete(id: string, expectedVersion?: string): Promise<boolean>;
380
+ query(q: StoreQuery): Promise<QueryResult<T>>;
381
+ }
382
+
383
+ // ── Knowledge index ───────────────────────────────────────────────────────────
384
+
385
+ export interface KnowledgeEntry {
386
+ id: string;
387
+ version: string;
388
+ entities: string[];
389
+ tags: string[];
390
+ summary: string;
391
+ content: string;
392
+ contentHash?: string;
393
+ source: { type: string; uuid: string };
394
+ confidence?: number;
395
+ createdAt: string;
396
+ updatedAt: string;
397
+ }
398
+
399
+ export interface KnowledgeIndex {
400
+ index(entry: KnowledgeEntry): Promise<void>;
401
+ search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
402
+ /** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
403
+ entries?(): Iterable<KnowledgeEntry>;
404
+ }
405
+
406
+ // ── Tools ─────────────────────────────────────────────────────────────────────
407
+
408
+ export type ToolEvent =
409
+ | { type: 'stdout'; chunk: string }
410
+ | { type: 'stderr'; chunk: string }
411
+ | { type: 'progress'; pct: number; message?: string }
412
+ | { type: 'result'; value: unknown }
413
+ | { type: 'file'; handle: FileHandle }
414
+ // A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
415
+ // side-effect). Persisted as a `marker`-role message; elided from provider submission like any
416
+ // marker. Independent of `result` — a tool may emit markers and no result (a silent side-effect,
417
+ // e.g. a trigger-fired tool), a result and no markers, or both.
418
+ | { type: 'marker'; creator: string; data: unknown }
419
+ | { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
420
+
421
+ /**
422
+ * Ask the user a question and resolve with their answer. The host supplies the
423
+ * implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
424
+ * matbot's single mechanism for eliciting user input at runtime; it is injected both into
425
+ * tool execution (`ToolContext.prompt`) and into plugin loading (collision resolution in setup).
426
+ *
427
+ * Two call forms:
428
+ * - `(question, defaultValue?)` — free text; resolves to the typed string (or the default).
429
+ * - `(field: FormField)` — a single structured field (`select`/`confirm`/`password`/`text`),
430
+ * letting rich frontends render real controls (buttons, masked input). Frontends that can't
431
+ * render it fall back to `field.label` as plain text. Resolves to the chosen/typed value.
432
+ *
433
+ * Cancellation is the "we can't proceed — give up" path (distinct from a graceful "decline", which
434
+ * is simply one of the offered `options`). The host rejects the promise with `PromptCancelledError`
435
+ * and abandons the current turn, returning to idle; a caller's surrounding try/catch turns the
436
+ * rejection into a tool error that closes the tool call cleanly.
437
+ * This reuses `FormField` rather than a parallel type; it is a one-shot request/response and
438
+ * deliberately does NOT engage the session-bound `form`/`form-response` flow.
439
+ */
440
+ export interface PromptFn {
441
+ (question: string, defaultValue?: string): Promise<string>;
442
+ (field: FormField): Promise<string>;
443
+ }
444
+
445
+ export interface ToolContext {
446
+ callId: string;
447
+ session: Session;
448
+ signal: AbortSignal;
449
+ vault: Vault;
450
+ /** The provider key driving the current turn (`RunConfig.provider`). A tool that spawns further
451
+ * work should default to this so the child inherits the same model rather than the config default. */
452
+ provider?: string;
453
+ workdir?: string;
454
+ configPath?: string;
455
+ files?: FileStore;
456
+ /** Prompt the user for input. The host provides a readline or form implementation. */
457
+ prompt: PromptFn;
458
+ /** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin. */
459
+ loadPlugin(specifier: string): Promise<MatbotPlugin>;
460
+ /**
461
+ * Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
462
+ * Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
463
+ * to unload. A failed `teardown()` (e.g. timeout) still throws — the plugin was resident in that case.
464
+ */
465
+ unloadPlugin(specifier: string): Promise<boolean>;
466
+ }
467
+
468
+ export interface ToolExecutor {
469
+ execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent>;
470
+ }
471
+
472
+ export interface Tool {
473
+ name: string;
474
+ description: string;
475
+ inputSchema: JSONSchema;
476
+ executor: ToolExecutor;
477
+ pluginName?: string;
478
+ }
479
+
480
+ // ── Files ─────────────────────────────────────────────────────────────────────
481
+
482
+ export interface FileMetaData {
483
+ id: string;
484
+ version: string;
485
+ name: string;
486
+ mimeType: MimeType;
487
+ size: number;
488
+ createdAt: ISODate;
489
+ sessionId?: string;
490
+ messageId?: string;
491
+ namespace?: string;
492
+ /** Whether this file may be served at a public URL. Default-deny: absent ⇒ not servable. The
493
+ * flag rides in the file's own persisted metadata (no separate allow-list), so the same read that
494
+ * resolves a request also gates it. Producers opt in per put (the workspace tool sets it true). */
495
+ allowed?: boolean;
496
+ }
497
+
498
+ export interface FileHandle extends FileMetaData {
499
+ stream(signal?: AbortSignal): AsyncIterable<Uint8Array>;
500
+ }
501
+
502
+ export type FileEvent = FileMetaData & { changed: Array<keyof FileMetaData> };
503
+
504
+ export interface FileFilter {
505
+ sessionId?: string;
506
+ mimeType?: string;
507
+ namespace?: string;
508
+ createdAfter?: ISODate;
509
+ createdBefore?: ISODate;
510
+ }
511
+
512
+ export interface FileStore {
513
+ /** Store a file. When `name` is provided, upserts by (name + namespace); otherwise always creates a new entry. */
514
+ put(
515
+ name: string | undefined,
516
+ mimeType: MimeType,
517
+ data: AsyncIterable<Uint8Array>,
518
+ meta?: { sessionId?: string; messageId?: string; namespace?: string; allowed?: boolean }
519
+ ): Promise<FileHandle>;
520
+ get(id: string): Promise<FileHandle | null>;
521
+ getByName(name: string, namespace?: string): Promise<FileHandle | null>;
522
+ delete(id: string): Promise<void>;
523
+ list(filter?: FileFilter): AsyncIterable<FileHandle>;
524
+ putTemp(name: string, mimeType: MimeType, data: AsyncIterable<Uint8Array>): Promise<FileHandle>;
525
+ /** Observe file changes. Implementations that cannot watch their backing store omit this. */
526
+ watch(signal?: AbortSignal): AsyncIterable<FileEvent>;
527
+ }
528
+
529
+ // ── Frontend ──────────────────────────────────────────────────────────────────
530
+
531
+ /**
532
+ * Passed to `services.registerFrontend()` by a plugin declaring itself a frontend. A frontend
533
+ * owns its own I/O (an HTTP server, a bot connection, a REPL); matbot only needs to know it
534
+ * exists. This object is the growth point for frontend-level capability advertisement
535
+ * (accepted/produced MIME types, size limits, …) as media support lands.
536
+ */
537
+ export interface FrontendInfo {
538
+ name: string;
539
+ }
540
+
541
+ // ── Vault ─────────────────────────────────────────────────────────────────────
542
+
543
+ /**
544
+ * The primitives a vault backend must implement. Resolution, redaction, and the three
545
+ * low-level store operations the smart `createSecret` policy composes. A backend implements
546
+ * `Vault` (which is `VaultSpec` plus that policy); plugins are handed a `Vault`.
547
+ */
548
+ export interface VaultSpec {
549
+ /** Resolve ${NAME} placeholders by looking up the named value; throws MissingSecretError for any miss. */
550
+ resolve(ref: string): Promise<string>;
551
+ scrub(text: string): string;
552
+ /** Store `value` under exactly `name`, overwriting. The literal write; no reference/dedup logic. */
553
+ writeSecret(name: string, value: string): Promise<void>;
554
+ /** Whether a secret is stored under this exact name. */
555
+ hasKey(name: string): boolean;
556
+ /**
557
+ * The name a value is already stored under, if any. Optional: backends that can't (or won't)
558
+ * reverse-index omit it, and `createSecret`'s dedup step is skipped.
559
+ */
560
+ findByValue?(value: string): string | undefined;
561
+ }
562
+
563
+ export interface Vault extends VaultSpec {
564
+ /**
565
+ * Store a secret coming (often) from a user via the LLM, where we cannot tell a real value
566
+ * from a key name they typed by mistake. Returns the key name callers MUST reference — not
567
+ * necessarily the `name` requested:
568
+ * - `value` is already a known key name → returns `value` (it was a reference, not a secret)
569
+ * - `value` already stored under another name → returns that name (dedup)
570
+ * - otherwise → writes under `name` and returns `name`
571
+ */
572
+ createSecret(name: string, value: string): Promise<string>;
573
+ }
574
+
575
+ // ── Health ────────────────────────────────────────────────────────────────────
576
+
577
+ export type HealthStatus =
578
+ | { status: 'ok'; latencyMs?: number }
579
+ | { status: 'degraded'; reason: string; latencyMs?: number }
580
+ | { status: 'down'; reason: string };
581
+
582
+ // ── Registries ────────────────────────────────────────────────────────────────
583
+
584
+ export type ToolRegistryEvent =
585
+ | { type: 'registered'; name: string; pluginName?: string }
586
+ | { type: 'removed'; name: string };
587
+
588
+ export interface ToolRegistry {
589
+ register(tool: Tool): void;
590
+ remove(name: string): void;
591
+ resolve(name: string): Tool | null;
592
+ list(): readonly Tool[];
593
+ removeByPlugin(pluginName: string): void;
594
+ /** Observe tool CRUD as it happens. Read-only — observers cannot veto a registration. One event
595
+ * per tool (removeByPlugin emits a `removed` per matched tool). The stream ends when `signal` aborts. */
596
+ watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent>;
597
+ }
598
+
599
+ export type PluginRegistryEvent =
600
+ | { type: 'loaded'; name: string }
601
+ | { type: 'unloaded'; name: string };
602
+
603
+ // ── Pipeline events ─────────────────────────────────────────────────────────────
604
+
605
+ /**
606
+ * The streaming output of a single turn. Every event carries the `traceId` of the turn that
607
+ * produced it — that is the correlation key a frontend uses to route events to the right
608
+ * message/bubble (see `SessionRunner`). A turn ends with exactly one terminal event:
609
+ * `done` (completed), `aborted` (interrupted mid-flight), or `error`. `cancelled` is emitted
610
+ * for a queued submission that was dropped before it ever ran (e.g. by `SessionRunner.abort`),
611
+ * so it carries no session — there is nothing to persist.
612
+ */
613
+ export type PipelineEvent =
614
+ | { type: 'text-delta'; delta: string; traceId: string }
615
+ | { type: 'thinking'; delta: string; traceId: string }
616
+ | { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
617
+ | { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
618
+ | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
619
+ | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
620
+ | { type: 'file'; handle: FileHandle; traceId: string }
621
+ | { type: 'usage'; inputTokens: number; outputTokens: number; costUsd?: number; cacheReadTokens?: number; cacheCreationTokens?: number; traceId: string }
622
+ | { type: 'done'; session: Session; traceId: string }
623
+ | { type: 'aborted'; reason: string; session: Session; traceId: string }
624
+ | { type: 'cancelled'; sessionId: string; traceId: string }
625
+ // Session-level (not turn-level, hence no traceId): the runner has fully drained its queue and is
626
+ // now idle. Emitted once per busy period, *after* `running` flips false, so a consumer can map it
627
+ // to an authoritative busy→idle transition without racing the internal state flip.
628
+ | { type: 'idle'; sessionId: string }
629
+ // A queued (not-yet-running) submission, carried on the stream so a frontend renders it as part
630
+ // of the live "delta" (everything after the last committed message), never from stored state.
631
+ // `queued` is the number of submissions ahead of it (0 ⇒ about to run). Emitted live on enqueue
632
+ // and replayed (in queue order) to anyone subscribing mid-flight.
633
+ | { type: 'queued'; content: MessageContent[]; queued: number; concatQueue: boolean; traceId: string; rootTraceId: string }
634
+ // Machine-authored content folded onto the running turn's user message (a `screen` hook's
635
+ // `durable` result — e.g. a fired `contextual` trigger), carried live so a frontend draws it
636
+ // immediately. The blocks are already persisted in that user message (origin: 'robo'); this is
637
+ // purely the live-delivery channel, so a live draw matches the reload (which splits the user
638
+ // turn's robo blocks into their own agent-side bubble).
639
+ | { type: 'robo-user'; content: MessageContent[]; traceId: string }
640
+ // Marker blocks appended to the session this turn (e.g. the dispatcher's record of a hook that
641
+ // threw), carried live so a frontend renders them without waiting for a session reload. The blocks
642
+ // are already persisted in the session; this event is purely the live-delivery channel.
643
+ | { type: 'marker'; content: MessageContent[]; traceId: string }
644
+ | { type: 'error'; error: string; traceId: string }
645
+ | { type: 'system-context'; text: string; traceId: string };
646
+
647
+ // ── Session runner ──────────────────────────────────────────────────────────────
648
+
649
+ /**
650
+ * A view onto a session returned by `SessionRunner.open`. `session` is the authoritative
651
+ * server-side state — committed messages plus an overlay of any queued-but-not-yet-run
652
+ * submissions (each carrying `metadata.pending: true`). `events` is a lazy, per-session live
653
+ * tap: accessing it subscribes to the turn event stream from now (replaying the in-flight
654
+ * turn, if any); never touching it costs nothing.
655
+ */
656
+ export interface SessionView {
657
+ session: Session;
658
+ /** Submissions waiting behind the current turn (does not count the running turn). */
659
+ queued: number;
660
+ /** Correlation id of the submission this call enqueued — present only when content was supplied. */
661
+ traceId?: string;
662
+ readonly events: AsyncIterable<PipelineEvent>;
663
+ }
664
+
665
+ /** Observe a session without submitting anything. */
666
+ export interface OpenOpts {
667
+ sessionId: string;
668
+ signal: AbortSignal;
669
+ /** Optional caller-supplied correlation id; one is generated when absent. */
670
+ traceId?: string;
671
+ }
672
+
673
+ /** Observe a session AND enqueue a submission. The compiler enforces provider/principal here;
674
+ * a remote frontend deserializing a request body must still validate the wire input itself. */
675
+ export interface SubmitOpenOpts extends OpenOpts {
676
+ content: MessageContent[];
677
+ provider: string;
678
+ principal: Principal;
679
+ /** When true, this submission may be merged with others drained in the same batch. Default false
680
+ * (queue mode: one turn per submission). */
681
+ concatQueue?: boolean;
682
+ /** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
683
+ * it must target the frontend's per-session client connections, not a single request. */
684
+ prompt?: PromptFn;
685
+ }
686
+
687
+ /**
688
+ * Serialises turns per session. A submission never executes concurrently with another for the
689
+ * same session; the in-memory queue (lost on process restart, by design) absorbs anything that
690
+ * arrives mid-turn. The server is the source of truth: a frontend renders whatever `open()`
691
+ * returns and treats the live `events` stream purely as an optimisation.
692
+ */
693
+ export interface SessionRunner {
694
+ open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView>;
695
+ /** Abort the running turn (if any) and drop all queued submissions, emitting `cancelled` for each. */
696
+ abort(sessionId: string): void;
697
+ /** Abandon the running turn (if any) WITHOUT touching the queue — `pump` advances to the next
698
+ * queued submission, or idles. The "give up on this turn" path (a prompt cancel); contrast
699
+ * `abort`, which also clears the queue. A no-op if nothing is running. */
700
+ cancelTurn(sessionId: string): void;
701
+ /** Snapshot of a session's live state: whether a turn is running and how many submissions wait
702
+ * behind it. `busy` is `running || queued > 0`. */
703
+ status(sessionId: string): { busy: boolean; running: boolean; queued: number };
704
+ }