@matatbread/matbot-plugin-api 0.3.8 → 0.4.1

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,14 +1,26 @@
1
1
  import type {
2
2
  FileStore, Principal, Vault, Message, ModelParameters,
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
7
  import type { Notifier } from './notify.js';
9
- import type { HookRegistry } from './hooks.js';
10
8
 
11
- 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';
12
24
 
13
25
  // ── Sub-runner types ──────────────────────────────────────────────────────────
14
26
 
@@ -17,13 +29,18 @@ export interface CompletionRequest {
17
29
  messages: Message[];
18
30
  system?: string;
19
31
  /**
20
- * @deprecated Per-call parameter overrides, shallow-merged over the provider config (request wins).
21
- * It DOES work — both hosts (CLI + web-bundle) honour it — but it is a speculative affordance whose
22
- * obvious use is an anti-pattern: a caller clamping tokens / disabling thinking for one call takes
23
- * control away from the provider config, where parameters belong and stay user-editable. Prefer a
24
- * dedicated provider profile (e.g. a `-classify` provider) instead of reaching for this. Kept, not
25
- * removed, only because the surface is tiny and a genuine per-call need may yet appear. If you found
26
- * this because you set it and it worked — reconsider whether a provider profile is the right home.
32
+ * Per-call parameter overrides, shallow-merged over the named provider's own `parameters` (request
33
+ * wins). For *transient* model behaviour — a lower `temperature` to classify, `thinking` off for a
34
+ * cheap sub-call, a tighter `maxTokens` on a call whose output is parsed rather than shown.
35
+ *
36
+ * The alternative is a near-duplicate provider profile per behaviour, identical to its sibling but
37
+ * for one field, which pushes a caller's transient concern into global, user-editable config and
38
+ * multiplies the profiles a user has to understand. So: **durable properties of a model belong in
39
+ * the provider profile; a single call's behaviour belongs here.** If the same override appears at
40
+ * every call site of a provider, that is the signal it was a profile all along.
41
+ *
42
+ * Honoured by both hosts (CLI + web-bundle), which resolve the merge before handing the config to
43
+ * the adapter; an unrecognised key is the adapter's business, exactly as in a profile.
27
44
  */
28
45
  parameters?: Partial<ModelParameters>;
29
46
  signal?: AbortSignal;
@@ -38,8 +55,7 @@ export interface SingleTurnRequest {
38
55
  provider: string;
39
56
  prompt: string;
40
57
  system?: string;
41
- /** @deprecated Forwarded verbatim to {@link CompletionRequest.parameters}; same speculative
42
- * anti-pattern caveat — prefer a dedicated provider profile over per-call overrides. */
58
+ /** Forwarded verbatim to {@link CompletionRequest.parameters} — same merge, same guidance. */
43
59
  parameters?: Partial<ModelParameters>;
44
60
  signal?: AbortSignal;
45
61
  }
@@ -99,11 +115,15 @@ export interface PluginSelf {
99
115
  // ── Services container ────────────────────────────────────────────────────────
100
116
 
101
117
  /**
118
+ * One of matbot's five open-registry augmentation points — same technique at each; see
119
+ * docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
120
+ *
102
121
  * The registry bucket: the swappable, registerable services, keyed by interface name. This is the
103
122
  * `keyof` domain of {@link MatbotRuntime.register}/`get`, and the surface third-party plugins augment
104
123
  * (`declare module '@matatbread/matbot-plugin-api' { interface MatbotServices { Foo?: Foo } }`). The
105
- * core three carry a host boot default and revert to it when unregistered; an augmented service is
106
- * optional and simply drops. Read each as a member (`services.KnowledgeIndex`); swap with register().
124
+ * four swap-members (`StorageBackend`, `KnowledgeIndex`, `Vault`, `Notifier`) carry a host boot default
125
+ * and revert to it when unregistered; an augmented service is optional and simply drops. Read each as
126
+ * a member (`services.KnowledgeIndex`); swap with register().
107
127
  */
108
128
  export interface MatbotServices {
109
129
  readonly StorageBackend?: StorageBackend | undefined;
@@ -255,7 +275,7 @@ export interface MatbotRuntime {
255
275
  * runSession directly, so concurrent submits queue instead of clobbering the session. */
256
276
  readonly run?: SessionRunner | undefined;
257
277
  readonly files?: FileStore;
258
- readonly hooks: HookRegistry;
278
+ readonly hooks: HookRegistrar;
259
279
  readonly tools: ToolRegistry;
260
280
  readonly systemContext: SystemContextRegistry;
261
281
  /** Default working directory for tool execution. Plugins that create servers should forward this to tool contexts. */
@@ -274,74 +294,6 @@ export interface MatbotRuntime {
274
294
  isSubAgent(): boolean;
275
295
  }
276
296
 
277
- /**
278
- * Wrap a services object so every registered service is reachable as a member (`services.InterfaceName`),
279
- * not only via `services.get('InterfaceName')` — one access surface for base members and plugin-registered
280
- * services alike. A member read of a key the object doesn't carry falls back to its own `get()` (the
281
- * registry), so the augmentation that declares `InterfaceName?: InterfaceName` is also the access path;
282
- * the optional `?` is the single call-site signal of "may be absent, null-check it". Assignment throws,
283
- * directing callers to `register()` (the swap-aware write path). Applied to both the host services object
284
- * and the per-plugin scoped object, so plugins see the same surface the host does.
285
- */
286
- export function unifyServices(services: MatbotMachine): MatbotMachine {
287
- return new Proxy(services, {
288
- get(target, key, receiver) {
289
- if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
290
- return typeof key === 'string' ? target.get(key as keyof MatbotServices) : undefined;
291
- },
292
- has(target, key) {
293
- if (Reflect.has(target, key)) return true;
294
- return typeof key === 'string' && target.get(key as keyof MatbotServices) !== undefined;
295
- },
296
- set(_target, key) {
297
- throw new Error(
298
- `Cannot assign services.${String(key)} directly — use services.register('${String(key)}', impl) to register or replace a service.`,
299
- );
300
- },
301
- });
302
- }
303
-
304
- // ── Capture-safe swap proxies ───────────────────────────────────────────────────
305
-
306
- export type SwapFn<T extends object> = (next: T) => void;
307
-
308
- /**
309
- * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
310
- * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
311
- * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
312
- * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
313
- * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
314
- */
315
- export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
316
- return new Proxy({} as T, {
317
- get(_t, prop) {
318
- const cur = getCurrent();
319
- if (cur === undefined) return undefined;
320
- const val = Reflect.get(cur, prop, cur);
321
- return typeof val === 'function' ? (val as (...a: unknown[]) => unknown).bind(cur) : val;
322
- },
323
- has(_t, prop) { const cur = getCurrent(); return cur !== undefined && Reflect.has(cur, prop); },
324
- getPrototypeOf() { const cur = getCurrent(); return cur === undefined ? null : Reflect.getPrototypeOf(cur); },
325
- ownKeys() { const cur = getCurrent(); return cur === undefined ? [] : Reflect.ownKeys(cur); },
326
- getOwnPropertyDescriptor(_t, prop) {
327
- const cur = getCurrent();
328
- if (cur === undefined) return undefined;
329
- const d = Reflect.getOwnPropertyDescriptor(cur, prop);
330
- if (d !== undefined) d.configurable = true; // Proxy invariant: props absent from the {} target must be configurable.
331
- return d;
332
- },
333
- });
334
- }
335
-
336
- /**
337
- * Returns [proxy, swap]: the Store/FileStore handle plugins capture, plus the fn register() calls to
338
- * repoint it at a new backend's store. Built on forwardingProxy so capture-safety is uniform.
339
- */
340
- export function makeSwappable<T extends object>(initial: T): [T, SwapFn<T>] {
341
- let current = initial;
342
- return [forwardingProxy<T>(() => current), (next: T) => { current = next; }];
343
- }
344
-
345
297
  // ── Mount table ─────────────────────────────────────────────────────────────────
346
298
 
347
299
  /** The machine with one registry key narrowed to present — what a keyed mount handler receives. */
@@ -355,7 +307,10 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
355
307
  * (then on each later remount). Off by default — a cacher that did its initial load in setup() wants
356
308
  * only future transitions; a deferred dependency wants the latch. */
357
309
  readonly replay?: boolean;
358
- /** Ends the subscription (the consumer's own teardown). */
310
+ /** Ends the subscription *earlier* than the observing plugin's unload. Optional because it is a
311
+ * narrowing convenience, not the cleanup path: the host binds every plugin-scoped observe() to that
312
+ * plugin's load extent, so an interest can never outlive its owner. Pass one only when the
313
+ * subscription's life is shorter than the plugin's (a per-session cache, a one-shot latch). */
359
314
  readonly signal?: AbortSignal;
360
315
  /** The *dependency's* teardown: fired when `key` is committed-unloaded (removed and not replaced by
361
316
  * the quiescent edge) while this consumer lives on. The stream continues — a later remount re-fires
@@ -373,112 +328,7 @@ export interface Mounted {
373
328
  ): void;
374
329
  }
375
330
 
376
- /** The host-side mount table: {@link Mounted} for plugins, plus the producer half the host drives from
377
- * its register/unregister and quiescent-edge flush. */
378
- export interface MountTable {
379
- readonly mounted: Mounted;
380
- /** Record that a key's presence may have changed since the last edge (called by register/unregister). */
381
- markDirty(key: keyof MatbotServices): void;
382
- /** At a quiescent edge, compute each dirty key's net presence transition and multicast it. */
383
- flush(): void;
384
- }
385
-
386
- interface MountInterest {
387
- readonly handler: (machine: MatbotMachine) => void | Promise<void>;
388
- readonly onUnmount: ((machine: MatbotMachine) => void | Promise<void>) | undefined;
389
- readonly signal: AbortSignal | undefined;
390
- }
391
-
392
- function reportMountHandlerError(e: unknown): void {
393
- console.error('[matbot] mounted handler threw:', e instanceof Error ? e.message : e);
394
- }
395
-
396
- /**
397
- * Build a {@link MountTable} over a lazily-read machine. Notifications batch to the quiescent edge
398
- * ({@link flush}), where each dirty key's net presence (absent→present = mount, present→present =
399
- * remount, present→absent = committed unload) is multicast to that key's subscribers. The clock holds
400
- * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
401
- * well-defined. Presence is read by member access on the unified machine, which resolves both the core
402
- * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
403
- */
404
- export function createMountTable(getMachine: () => MatbotMachine): MountTable {
405
- const interests = new Map<string, Set<MountInterest>>();
406
- const committed = new Map<string, boolean>(); // last-committed presence per key (the clock)
407
- const dirty = new Set<string>();
408
-
409
- const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
410
-
411
- const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
412
- try {
413
- const r = fn(machine);
414
- if (r instanceof Promise) r.catch(reportMountHandlerError);
415
- } catch (e) { reportMountHandlerError(e); }
416
- };
417
-
418
- const mounted: Mounted = {
419
- observe(options, handler) {
420
- const { key, replay, signal, onUnmount } = options;
421
- if (signal?.aborted === true) return;
422
- const interest: MountInterest = {
423
- handler: handler as MountInterest['handler'],
424
- onUnmount: onUnmount as MountInterest['onUnmount'],
425
- signal,
426
- };
427
- let set = interests.get(key);
428
- if (set === undefined) { set = new Set(); interests.set(key, set); }
429
- const subs = set;
430
- subs.add(interest);
431
- signal?.addEventListener('abort', () => { subs.delete(interest); }, { once: true });
432
- // Replay on the next microtask (async-iterator parity — never inline in the consume() frame).
433
- // Reads the live machine, not `committed`: replay is "current state", separate from the clock.
434
- if (replay === true) queueMicrotask(() => {
435
- if (signal?.aborted === true) return;
436
- if (present(key as string)) run(interest.handler, getMachine());
437
- });
438
- },
439
- };
440
-
441
- return {
442
- mounted,
443
- markDirty(key) { dirty.add(key as string); },
444
- flush() {
445
- if (dirty.size === 0) return;
446
- const keys = [...dirty];
447
- dirty.clear();
448
- const machine = getMachine();
449
- for (const key of keys) {
450
- const before = committed.get(key) ?? false;
451
- const after = present(key);
452
- committed.set(key, after);
453
- const subs = interests.get(key);
454
- if (subs === undefined) continue;
455
- if (after) {
456
- for (const i of subs) run(i.handler, machine); // mount / remount
457
- } else if (before) {
458
- for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
459
- }
460
- }
461
- },
462
- };
463
- }
464
331
 
465
- /**
466
- * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
467
- * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
468
- * has no use for. Pure; the host invokes its own complete() with the result.
469
- */
470
- export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
471
- return {
472
- provider: req.provider,
473
- messages: [{
474
- id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
475
- content: [{ type: 'text', text: req.prompt }],
476
- }],
477
- ...(req.system !== undefined ? { system: req.system } : {}),
478
- ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
479
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
480
- };
481
- }
482
332
 
483
333
  // ── Factory types ─────────────────────────────────────────────────────────────
484
334
 
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
+ }
@@ -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 };