@matatbread/matbot-plugin-api 0.3.7 → 0.4.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/plugin.ts CHANGED
@@ -1,13 +1,26 @@
1
1
  import type {
2
- FileStore, StoreChange, Principal, Vault, Message, ModelParameters,
2
+ FileStore, Principal, Vault, Message,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
- Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
4
+ Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
5
  TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
6
6
  } from './types.js';
7
- import type { Routed } from './broadcast.js';
8
- import type { HookRegistry } from './hooks.js';
7
+ import type { Notifier } from './notify.js';
9
8
 
10
- export const PLUGIN_API_VERSION = '0.1';
9
+ /**
10
+ * The plugin API contract version: this package's `major.minor`, and nothing else. It sat at `'0.1'`
11
+ * while the package shipped 0.3.x, so it conveyed no information and could not be reasoned about at a
12
+ * version boundary — the point at which it is the only thing every third-party plugin hardcodes.
13
+ *
14
+ * The gate (`checkApiVersion`, in core's registry) reads it as: **major must match exactly** — a
15
+ * mismatch is a hard load failure — and a plugin declaring a *newer minor* than the runtime warns and
16
+ * loads, since it may reach for something absent. Declare it as `apiVersion: PLUGIN_API_VERSION` and it
17
+ * stays right for free; that is what every plugin in this repo does.
18
+ *
19
+ * Bumping 0.1 → 0.4 breaks nothing: at 0.x the major is `'0'` either way, and an older declared minor
20
+ * does not warn. It matters at 1.0.0, where a plugin still declaring `'0.x'` will fail the major check
21
+ * loudly instead of loading against a contract it was never built for.
22
+ */
23
+ export const PLUGIN_API_VERSION = '0.4';
11
24
 
12
25
  // ── Sub-runner types ──────────────────────────────────────────────────────────
13
26
 
@@ -15,16 +28,6 @@ export interface CompletionRequest {
15
28
  provider: string;
16
29
  messages: Message[];
17
30
  system?: string;
18
- /**
19
- * @deprecated Per-call parameter overrides, shallow-merged over the provider config (request wins).
20
- * It DOES work — both hosts (CLI + web-bundle) honour it — but it is a speculative affordance whose
21
- * obvious use is an anti-pattern: a caller clamping tokens / disabling thinking for one call takes
22
- * control away from the provider config, where parameters belong and stay user-editable. Prefer a
23
- * dedicated provider profile (e.g. a `-classify` provider) instead of reaching for this. Kept, not
24
- * removed, only because the surface is tiny and a genuine per-call need may yet appear. If you found
25
- * this because you set it and it worked — reconsider whether a provider profile is the right home.
26
- */
27
- parameters?: Partial<ModelParameters>;
28
31
  signal?: AbortSignal;
29
32
  }
30
33
 
@@ -37,9 +40,6 @@ export interface SingleTurnRequest {
37
40
  provider: string;
38
41
  prompt: string;
39
42
  system?: string;
40
- /** @deprecated Forwarded verbatim to {@link CompletionRequest.parameters}; same speculative
41
- * anti-pattern caveat — prefer a dedicated provider profile over per-call overrides. */
42
- parameters?: Partial<ModelParameters>;
43
43
  signal?: AbortSignal;
44
44
  }
45
45
 
@@ -98,11 +98,15 @@ export interface PluginSelf {
98
98
  // ── Services container ────────────────────────────────────────────────────────
99
99
 
100
100
  /**
101
+ * One of matbot's five open-registry augmentation points — same technique at each; see
102
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
103
+ *
101
104
  * The registry bucket: the swappable, registerable services, keyed by interface name. This is the
102
105
  * `keyof` domain of {@link MatbotRuntime.register}/`get`, and the surface third-party plugins augment
103
106
  * (`declare module '@matatbread/matbot-plugin-api' { interface MatbotServices { Foo?: Foo } }`). The
104
- * core three carry a host boot default and revert to it when unregistered; an augmented service is
105
- * optional and simply drops. Read each as a member (`services.KnowledgeIndex`); swap with register().
107
+ * four swap-members (`StorageBackend`, `KnowledgeIndex`, `Vault`, `Notifier`) carry a host boot default
108
+ * and revert to it when unregistered; an augmented service is optional and simply drops. Read each as
109
+ * a member (`services.KnowledgeIndex`); swap with register().
106
110
  */
107
111
  export interface MatbotServices {
108
112
  readonly StorageBackend?: StorageBackend | undefined;
@@ -114,6 +118,12 @@ export interface MatbotServices {
114
118
  * reference held across a swap keeps resolving to the live backend. Always present (boot default). */
115
119
  readonly Vault: Vault;
116
120
  readonly KnowledgeIndex: KnowledgeIndex;
121
+ /** The notification bus — every "something changed" fact, one fan-out. Also the `register('Notifier',
122
+ * impl)` swap key: the host boots an in-process broadcaster, a plugin may swap in a distributed one
123
+ * (and unloading it reverts to the boot default). Capture-safe behind a proxy. Always present.
124
+ * Within a plugin's `setup()` this is scoped, so published notifications carry that plugin's name.
125
+ * See {@link Notifier}. */
126
+ readonly Notifier: Notifier;
117
127
  /** Optional tool-presentation policy: chooses which tools are advertised to the model per provider
118
128
  * call (a large-library search/deferral plugin registers one). Absent ⇒ the runner advertises the
119
129
  * whole turn snapshot. A plain registered service (not a swap-member); consumed as a member. See
@@ -248,7 +258,7 @@ export interface MatbotRuntime {
248
258
  * runSession directly, so concurrent submits queue instead of clobbering the session. */
249
259
  readonly run?: SessionRunner | undefined;
250
260
  readonly files?: FileStore;
251
- readonly hooks: HookRegistry;
261
+ readonly hooks: HookRegistrar;
252
262
  readonly tools: ToolRegistry;
253
263
  readonly systemContext: SystemContextRegistry;
254
264
  /** Default working directory for tool execution. Plugins that create servers should forward this to tool contexts. */
@@ -267,74 +277,6 @@ export interface MatbotRuntime {
267
277
  isSubAgent(): boolean;
268
278
  }
269
279
 
270
- /**
271
- * Wrap a services object so every registered service is reachable as a member (`services.InterfaceName`),
272
- * not only via `services.get('InterfaceName')` — one access surface for base members and plugin-registered
273
- * services alike. A member read of a key the object doesn't carry falls back to its own `get()` (the
274
- * registry), so the augmentation that declares `InterfaceName?: InterfaceName` is also the access path;
275
- * the optional `?` is the single call-site signal of "may be absent, null-check it". Assignment throws,
276
- * directing callers to `register()` (the swap-aware write path). Applied to both the host services object
277
- * and the per-plugin scoped object, so plugins see the same surface the host does.
278
- */
279
- export function unifyServices(services: MatbotMachine): MatbotMachine {
280
- return new Proxy(services, {
281
- get(target, key, receiver) {
282
- if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
283
- return typeof key === 'string' ? target.get(key as keyof MatbotServices) : undefined;
284
- },
285
- has(target, key) {
286
- if (Reflect.has(target, key)) return true;
287
- return typeof key === 'string' && target.get(key as keyof MatbotServices) !== undefined;
288
- },
289
- set(_target, key) {
290
- throw new Error(
291
- `Cannot assign services.${String(key)} directly — use services.register('${String(key)}', impl) to register or replace a service.`,
292
- );
293
- },
294
- });
295
- }
296
-
297
- // ── Capture-safe swap proxies ───────────────────────────────────────────────────
298
-
299
- export type SwapFn<T extends object> = (next: T) => void;
300
-
301
- /**
302
- * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
303
- * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
304
- * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
305
- * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
306
- * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
307
- */
308
- export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
309
- return new Proxy({} as T, {
310
- get(_t, prop) {
311
- const cur = getCurrent();
312
- if (cur === undefined) return undefined;
313
- const val = Reflect.get(cur, prop, cur);
314
- return typeof val === 'function' ? (val as (...a: unknown[]) => unknown).bind(cur) : val;
315
- },
316
- has(_t, prop) { const cur = getCurrent(); return cur !== undefined && Reflect.has(cur, prop); },
317
- getPrototypeOf() { const cur = getCurrent(); return cur === undefined ? null : Reflect.getPrototypeOf(cur); },
318
- ownKeys() { const cur = getCurrent(); return cur === undefined ? [] : Reflect.ownKeys(cur); },
319
- getOwnPropertyDescriptor(_t, prop) {
320
- const cur = getCurrent();
321
- if (cur === undefined) return undefined;
322
- const d = Reflect.getOwnPropertyDescriptor(cur, prop);
323
- if (d !== undefined) d.configurable = true; // Proxy invariant: props absent from the {} target must be configurable.
324
- return d;
325
- },
326
- });
327
- }
328
-
329
- /**
330
- * Returns [proxy, swap]: the Store/FileStore handle plugins capture, plus the fn register() calls to
331
- * repoint it at a new backend's store. Built on forwardingProxy so capture-safety is uniform.
332
- */
333
- export function makeSwappable<T extends object>(initial: T): [T, SwapFn<T>] {
334
- let current = initial;
335
- return [forwardingProxy<T>(() => current), (next: T) => { current = next; }];
336
- }
337
-
338
280
  // ── Mount table ─────────────────────────────────────────────────────────────────
339
281
 
340
282
  /** The machine with one registry key narrowed to present — what a keyed mount handler receives. */
@@ -348,7 +290,10 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
348
290
  * (then on each later remount). Off by default — a cacher that did its initial load in setup() wants
349
291
  * only future transitions; a deferred dependency wants the latch. */
350
292
  readonly replay?: boolean;
351
- /** Ends the subscription (the consumer's own teardown). */
293
+ /** Ends the subscription *earlier* than the observing plugin's unload. Optional because it is a
294
+ * narrowing convenience, not the cleanup path: the host binds every plugin-scoped observe() to that
295
+ * plugin's load extent, so an interest can never outlive its owner. Pass one only when the
296
+ * subscription's life is shorter than the plugin's (a per-session cache, a one-shot latch). */
352
297
  readonly signal?: AbortSignal;
353
298
  /** The *dependency's* teardown: fired when `key` is committed-unloaded (removed and not replaced by
354
299
  * the quiescent edge) while this consumer lives on. The stream continues — a later remount re-fires
@@ -366,112 +311,7 @@ export interface Mounted {
366
311
  ): void;
367
312
  }
368
313
 
369
- /** The host-side mount table: {@link Mounted} for plugins, plus the producer half the host drives from
370
- * its register/unregister and quiescent-edge flush. */
371
- export interface MountTable {
372
- readonly mounted: Mounted;
373
- /** Record that a key's presence may have changed since the last edge (called by register/unregister). */
374
- markDirty(key: keyof MatbotServices): void;
375
- /** At a quiescent edge, compute each dirty key's net presence transition and multicast it. */
376
- flush(): void;
377
- }
378
-
379
- interface MountInterest {
380
- readonly handler: (machine: MatbotMachine) => void | Promise<void>;
381
- readonly onUnmount: ((machine: MatbotMachine) => void | Promise<void>) | undefined;
382
- readonly signal: AbortSignal | undefined;
383
- }
384
-
385
- function reportMountHandlerError(e: unknown): void {
386
- console.error('[matbot] mounted handler threw:', e instanceof Error ? e.message : e);
387
- }
388
-
389
- /**
390
- * Build a {@link MountTable} over a lazily-read machine. Notifications batch to the quiescent edge
391
- * ({@link flush}), where each dirty key's net presence (absent→present = mount, present→present =
392
- * remount, present→absent = committed unload) is multicast to that key's subscribers. The clock holds
393
- * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
394
- * well-defined. Presence is read by member access on the unified machine, which resolves both the core
395
- * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
396
- */
397
- export function createMountTable(getMachine: () => MatbotMachine): MountTable {
398
- const interests = new Map<string, Set<MountInterest>>();
399
- const committed = new Map<string, boolean>(); // last-committed presence per key (the clock)
400
- const dirty = new Set<string>();
401
-
402
- const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
403
-
404
- const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
405
- try {
406
- const r = fn(machine);
407
- if (r instanceof Promise) r.catch(reportMountHandlerError);
408
- } catch (e) { reportMountHandlerError(e); }
409
- };
410
-
411
- const mounted: Mounted = {
412
- observe(options, handler) {
413
- const { key, replay, signal, onUnmount } = options;
414
- if (signal?.aborted === true) return;
415
- const interest: MountInterest = {
416
- handler: handler as MountInterest['handler'],
417
- onUnmount: onUnmount as MountInterest['onUnmount'],
418
- signal,
419
- };
420
- let set = interests.get(key);
421
- if (set === undefined) { set = new Set(); interests.set(key, set); }
422
- const subs = set;
423
- subs.add(interest);
424
- signal?.addEventListener('abort', () => { subs.delete(interest); }, { once: true });
425
- // Replay on the next microtask (async-iterator parity — never inline in the consume() frame).
426
- // Reads the live machine, not `committed`: replay is "current state", separate from the clock.
427
- if (replay === true) queueMicrotask(() => {
428
- if (signal?.aborted === true) return;
429
- if (present(key as string)) run(interest.handler, getMachine());
430
- });
431
- },
432
- };
433
-
434
- return {
435
- mounted,
436
- markDirty(key) { dirty.add(key as string); },
437
- flush() {
438
- if (dirty.size === 0) return;
439
- const keys = [...dirty];
440
- dirty.clear();
441
- const machine = getMachine();
442
- for (const key of keys) {
443
- const before = committed.get(key) ?? false;
444
- const after = present(key);
445
- committed.set(key, after);
446
- const subs = interests.get(key);
447
- if (subs === undefined) continue;
448
- if (after) {
449
- for (const i of subs) run(i.handler, machine); // mount / remount
450
- } else if (before) {
451
- for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
452
- }
453
- }
454
- },
455
- };
456
- }
457
314
 
458
- /**
459
- * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
460
- * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
461
- * has no use for. Pure; the host invokes its own complete() with the result.
462
- */
463
- export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
464
- return {
465
- provider: req.provider,
466
- messages: [{
467
- id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
468
- content: [{ type: 'text', text: req.prompt }],
469
- }],
470
- ...(req.system !== undefined ? { system: req.system } : {}),
471
- ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
472
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
473
- };
474
- }
475
315
 
476
316
  // ── Factory types ─────────────────────────────────────────────────────────────
477
317
 
@@ -504,18 +344,13 @@ export interface StorageBackend {
504
344
  }
505
345
 
506
346
  /**
507
- * The partition-aware file-watch layer, registered by a storage backend that partitions files per
508
- * principal (the profiles backend). Consumed by a frontend firehose so it can (a) observe file events
509
- * across *every* partition, each stamped with the {@link Routed} origin that produced it, and (b) decide,
510
- * per SSE connection, whether a given event is visible to that connection's principal. Absent ⇒ no
511
- * partitioning: the frontend falls back to the plain single-stream `fileStore.watch()` with no filter.
512
- * The origin/visibility split is generic (files today; the same shape serves skills/stores later).
347
+ * The per-connection visibility layer, registered by a storage backend that partitions per principal (the
348
+ * profiles backend). Consumed by a frontend firehose to decide, per SSE connection, whether a given
349
+ * notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
350
+ * sees everything. Generic across kinds: it answers on (`namespace`, `id`, `origin`), so files, skills and
351
+ * stores all route through the one predicate.
513
352
  */
514
353
  export interface WatchVisibility {
515
- /** Every partition's file events, merged into one stream, each a self-describing {@link StoreChange}
516
- * (routing namespace `'files'`, the file id, and the {@link FileMetaData} in `detail`) tagged with the
517
- * origin partition — so the firehose filters and routes files through the same predicate as any stream. */
518
- watchFiles(signal?: AbortSignal): AsyncIterable<Routed<StoreChange>>;
519
354
  /**
520
355
  * The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
521
356
  * connection owned by `viewer` see the change to (`namespace`, `id`) produced by `origin`? True iff the
package/src/session.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Session } from './types.js';
1
+ import type { MessageContent, Session } from './types.js';
2
2
 
3
3
  /**
4
4
  * A session's `updatedAt` invariant: the timestamp of its last message, or its own `createdAt` when it
@@ -15,3 +15,32 @@ export function lastActivityAt(session: Session): string {
15
15
  const last = session.messages[session.messages.length - 1];
16
16
  return last ? last.createdAt : session.createdAt;
17
17
  }
18
+
19
+ /**
20
+ * Index of the turn's user message — the message a durable fold attaches to, and the point a retraction
21
+ * pops back to. `-1` when the session has none.
22
+ */
23
+ export function lastUserIndex(session: Session): number {
24
+ return session.messages.findLastIndex(m => m.role === 'user');
25
+ }
26
+
27
+ /**
28
+ * Append `blocks` to the turn's user message, returning a new session. This is what "durable" means
29
+ * everywhere it appears: unlike `ephemeral` (tail-folded into the outgoing copy and discarded), the blocks
30
+ * become part of the user turn — persisted, visible to a reader, and carried by every subsequent provider
31
+ * call in the turn and after it.
32
+ *
33
+ * With no user message the blocks are **dropped rather than orphaned**: there is nothing they could belong
34
+ * to, and appending a message of their own would change the role sequence the adapters depend on.
35
+ *
36
+ * Shared because the three callers must agree: the `screen` hook fold, the runner's raced-verdict
37
+ * correction, and the pump's retract-and-rerun rebase. They were three independent copies of one
38
+ * `findLastIndex` + splice, in two different packages.
39
+ */
40
+ export function foldOntoUserTurn(session: Session, blocks: readonly MessageContent[]): Session {
41
+ if (blocks.length === 0) return session;
42
+ const idx = lastUserIndex(session);
43
+ if (idx < 0) return session;
44
+ return { ...session, messages: session.messages.map((m, i) =>
45
+ i === idx ? { ...m, content: [...m.content, ...blocks] } : m) };
46
+ }
@@ -39,6 +39,18 @@ export interface StoreQuery {
39
39
  sort?: SortSpec[];
40
40
  limit?: number;
41
41
  cursor?: string | undefined;
42
+ /**
43
+ * The caller's promise not to mutate the returned documents, freeing a backend to hand back shared
44
+ * instances instead of freshly-materialised ones. A pure optimisation hint: a backend may ignore it
45
+ * and nothing changes, so it is never load-bearing for correctness — but a backend that honours it
46
+ * can skip re-materialising documents that have not changed since the last query, which for a
47
+ * summary listing (read every document, use four fields) is the entire cost of the call.
48
+ *
49
+ * Set it only where the promise is actually kept. A read-modify-write path — pull a page, edit a
50
+ * document, `cas` it back — must NOT set it: the instance it edits may be one another caller is
51
+ * still reading, and a backend caching it would then be caching an edit that never reached disk.
52
+ */
53
+ immutable?: boolean;
42
54
  }
43
55
 
44
56
  export interface QueryResult<T> {
@@ -0,0 +1,81 @@
1
+ import type { FileHandle } from './files.js';
2
+ import type { MessageContent, Session } from './messages.js';
3
+ import type { Usage } from './provider.js';
4
+
5
+ // ── Pipeline events ─────────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * A turn's own events. `traceId` — the turn that produced it — is the correlation key a frontend routes
9
+ * on (see `SessionRunner`), and every arm here carries it. Grouped by what a consumer does with them:
10
+ * streamed model output, tool progress, live delivery of something already persisted, and the terminals.
11
+ *
12
+ * A turn ends with **exactly one** terminal: `done`, `aborted` (interrupted mid-flight), or `error`.
13
+ */
14
+ export type TurnEvent =
15
+ // ── streamed model output ──
16
+ | { type: 'text-delta'; delta: string; traceId: string }
17
+ | { type: 'thinking'; delta: string; traceId: string }
18
+ | { type: 'system-context'; text: string; traceId: string }
19
+ | ({ type: 'usage'; traceId: string } & Usage)
20
+
21
+ // ── tool execution ──
22
+ | { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
23
+ | { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
24
+ | { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
25
+ | { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
26
+ | { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
27
+
28
+ // ── live delivery of state that is ALREADY persisted; never the source of truth ──
29
+ | { type: 'file'; handle: FileHandle; traceId: string }
30
+ // Machine-authored content folded onto the running turn's user message (a `screen` hook's
31
+ // `durable` result — e.g. a fired `contextual` trigger), carried live so a frontend draws it
32
+ // immediately. The blocks are already persisted in that user message (origin: 'robo'); this is
33
+ // purely the live-delivery channel, so a live draw matches the reload (which splits the user
34
+ // turn's robo blocks into their own agent-side bubble).
35
+ | { type: 'robo-user'; content: MessageContent[]; traceId: string }
36
+ // Marker blocks appended to the session this turn (e.g. the dispatcher's record of a hook that
37
+ // threw), carried live so a frontend renders them without waiting for a session reload. The blocks
38
+ // are already persisted in the session; this event is purely the live-delivery channel.
39
+ | { type: 'marker'; content: MessageContent[]; traceId: string }
40
+
41
+ // ── queue disposition ──
42
+ // A queued (not-yet-running) submission, carried on the stream so a frontend renders it as part
43
+ // of the live "delta" (everything after the last committed message), never from stored state.
44
+ // `queued` is the number of submissions ahead of it (0 ⇒ about to run). Emitted live on enqueue
45
+ // and replayed (in queue order) to anyone subscribing mid-flight.
46
+ | { type: 'queued'; content: MessageContent[]; queued: number; concatQueue: boolean; traceId: string; rootTraceId: string }
47
+ // A mid-turn steer that INTERRUPTED the running turn (see `SteeringPolicy`). Emitted synchronously
48
+ // when the steer is accepted, so the new user bubble lands in correct stream order and a frontend
49
+ // knows the imminent `aborted` (reason 'steer') on `interruptedTraceId` is a yield, not a dead-end:
50
+ // keep that turn's partial work rendered and expect a continuation. `traceId`/`rootTraceId` identify
51
+ // the steer submission itself (the continuation turn). Late subscribers reconstruct the bubble from
52
+ // the pump's per-turn `queued` replay seed / committed store order — this is purely live delivery.
53
+ | { type: 'steer'; content: MessageContent[]; interruptedTraceId: string; traceId: string; rootTraceId: string }
54
+
55
+ // ── terminals: exactly one per turn ──
56
+ | { type: 'done'; session: Session; traceId: string }
57
+ | { type: 'aborted'; reason: string; session: Session; traceId: string }
58
+ | { type: 'error'; error: string; traceId: string }
59
+ // A queued submission dropped before it ever ran (e.g. by `SessionRunner.abort`). It carries no
60
+ // session because nothing ran and so nothing was persisted — but it does have a traceId: the
61
+ // submission it cancels was assigned one at enqueue, and a frontend needs it to retire that bubble.
62
+ | { type: 'cancelled'; sessionId: string; traceId: string };
63
+
64
+ /**
65
+ * Events about the *session*, not any one turn — hence keyed on `sessionId` with no `traceId`. Split from
66
+ * {@link TurnEvent} so that absence is type-checkable rather than a comment: `idle` was the single arm of
67
+ * a 20-arm union without a `traceId`, which every consumer had to know by having read the note saying so.
68
+ */
69
+ export type SessionEvent =
70
+ // The runner has fully drained its queue. Emitted once per busy period, *after* `running` flips false,
71
+ // so a consumer can map it to an authoritative busy→idle transition without racing the internal flip.
72
+ | { type: 'idle'; sessionId: string };
73
+
74
+ /**
75
+ * The streaming output of a session: its turns' events, plus the session-level ones.
76
+ *
77
+ * Consumers that only ever see one turn (a tool reporting progress, a per-turn frontend) should narrow to
78
+ * {@link TurnEvent} and get `traceId` unconditionally; a subscriber to a whole session takes this union
79
+ * and must handle `idle`. Switching on `type` works the same either way.
80
+ */
81
+ export type PipelineEvent = TurnEvent | SessionEvent;
@@ -0,0 +1,46 @@
1
+ import type { ISODate, MimeType } from './primitives.js';
2
+
3
+ // ── Files ─────────────────────────────────────────────────────────────────────
4
+
5
+ export interface FileMetaData {
6
+ id: string;
7
+ version: string;
8
+ name: string;
9
+ mimeType: MimeType;
10
+ size: number;
11
+ createdAt: ISODate;
12
+ sessionId?: string;
13
+ messageId?: string;
14
+ namespace?: string;
15
+ /** Whether this file may be served at a public URL. Default-deny: absent ⇒ not servable. The
16
+ * flag rides in the file's own persisted metadata (no separate allow-list), so the same read that
17
+ * resolves a request also gates it. Producers opt in per put (the workspace tool sets it true). */
18
+ allowed?: boolean;
19
+ }
20
+
21
+ export interface FileHandle extends FileMetaData {
22
+ stream(signal?: AbortSignal): AsyncIterable<Uint8Array>;
23
+ }
24
+
25
+ export interface FileFilter {
26
+ sessionId?: string;
27
+ mimeType?: string;
28
+ namespace?: string;
29
+ createdAfter?: ISODate;
30
+ createdBefore?: ISODate;
31
+ }
32
+
33
+ export interface FileStore {
34
+ /** Store a file. When `name` is provided, upserts by (name + namespace); otherwise always creates a new entry. */
35
+ put(
36
+ name: string | undefined,
37
+ mimeType: MimeType,
38
+ data: AsyncIterable<Uint8Array>,
39
+ meta?: { sessionId?: string; messageId?: string; namespace?: string; allowed?: boolean }
40
+ ): Promise<FileHandle>;
41
+ get(id: string): Promise<FileHandle | null>;
42
+ getByName(name: string, namespace?: string): Promise<FileHandle | null>;
43
+ delete(id: string): Promise<void>;
44
+ list(filter?: FileFilter): AsyncIterable<FileHandle>;
45
+ putTemp(name: string, mimeType: MimeType, data: AsyncIterable<Uint8Array>): Promise<FileHandle>;
46
+ }
@@ -0,0 +1,11 @@
1
+ // ── Frontend ──────────────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Passed to `services.registerFrontend()` by a plugin declaring itself a frontend. A frontend
5
+ * owns its own I/O (an HTTP server, a bot connection, a REPL); matbot only needs to know it
6
+ * exists. This object is the growth point for frontend-level capability advertisement
7
+ * (accepted/produced MIME types, size limits, …) as media support lands.
8
+ */
9
+ export interface FrontendInfo {
10
+ name: string;
11
+ }
@@ -0,0 +1,6 @@
1
+ // ── Health ────────────────────────────────────────────────────────────────────
2
+
3
+ export type HealthStatus =
4
+ | { status: 'ok'; latencyMs?: number }
5
+ | { status: 'degraded'; reason: string; latencyMs?: number }
6
+ | { status: 'down'; reason: string };