@matatbread/matbot-plugin-api 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.3",
7
+ "version": "0.3.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/broadcast.ts CHANGED
@@ -1,27 +1,47 @@
1
1
  // A minimal multi-subscriber fan-out for AsyncIterable observation streams (tool/plugin/skill watch,
2
- // and any future plugin-owned CRUD stream). Each subscriber gets its own unbounded queue, so a slow
3
- // consumer never blocks emit() or its peers. Acceptable because these events are rare and small
2
+ // file changes, and any future plugin-owned CRUD stream). Each subscriber gets its own unbounded queue,
3
+ // so a slow consumer never blocks emit() or its peers. Acceptable because these events are rare and small
4
4
  // (registry/document CRUD); this is not a high-throughput data path. emit() is synchronous and never throws.
5
+ //
6
+ // Every event flows as a {@link Routed} envelope carrying an optional `origin` principal — the partition
7
+ // or actor that produced it. This is the one attribution channel shared by every stream (files, skills,
8
+ // future partitioned stores); a global stream (tool/plugin registry) simply never sets it. Per-subscriber
9
+ // filtering (`subscribe(signal, filter)`) is applied at emit time, so a subscriber that only wants events
10
+ // visible to one principal never even queues the rest — the single, common place per-principal visibility
11
+ // lives, whatever the terminal (SSE→frontend, an internal consumer, or a cache).
12
+
13
+ import type { Principal } from './types.js';
14
+
15
+ /** A broadcast event plus its optional origin. `origin` undefined ⇒ global/base — visible to all. */
16
+ export interface Routed<T> {
17
+ readonly value: T;
18
+ readonly origin?: Principal;
19
+ }
20
+
21
+ /** Per-subscriber predicate: keep only the envelopes it returns true for. */
22
+ export type RoutedFilter<T> = (event: Routed<T>) => boolean;
5
23
 
6
24
  interface Subscriber<T> {
7
- queue: T[];
8
- wake: (() => void) | undefined;
9
- done: boolean;
25
+ queue: Routed<T>[];
26
+ wake: (() => void) | undefined;
27
+ done: boolean;
28
+ filter: RoutedFilter<T> | undefined;
10
29
  }
11
30
 
12
31
  // The consumer facet — what a stream hands out. `emit` is the producer's, kept off this so a
13
32
  // surface that only exposes observation (e.g. services.mounted) cannot forge events.
14
33
  export interface Subscribable<T> {
15
- subscribe(signal?: AbortSignal): AsyncIterable<T>;
34
+ subscribe(signal?: AbortSignal, filter?: RoutedFilter<T>): AsyncIterable<Routed<T>>;
16
35
  // Detached observation loop: awaits each handler before pulling the next (no overlapping runs),
17
36
  // isolates a throwing handler (logged, never propagated — a bad observer must not kill the stream),
18
37
  // and ends when the source ends or `signal` aborts. The fire-and-forget form of the for-await loop
19
- // every watch call site otherwise hand-writes.
20
- consume(handler: (value: T) => void | Promise<void>, signal?: AbortSignal): void;
38
+ // every watch call site otherwise hand-writes. An internal cache is exactly this: a `consume` that
39
+ // folds each `Routed<T>` into per-origin state.
40
+ consume(handler: (event: Routed<T>) => void | Promise<void>, signal?: AbortSignal, filter?: RoutedFilter<T>): void;
21
41
  }
22
42
 
23
43
  export interface Broadcaster<T> extends Subscribable<T> {
24
- emit(value: T): void;
44
+ emit(value: T, origin?: Principal): void;
25
45
  }
26
46
 
27
47
  /**
@@ -29,11 +49,13 @@ export interface Broadcaster<T> extends Subscribable<T> {
29
49
  * `consume` loop. Use for a *derived* stream — e.g. a per-plugin scoped view of a shared broadcaster —
30
50
  * that owns its own subscribe generator but wants the same consume ergonomics as a real broadcaster.
31
51
  */
32
- export function subscribable<T>(subscribe: (signal?: AbortSignal) => AsyncIterable<T>): Subscribable<T> {
33
- const consume = (handler: (value: T) => void | Promise<void>, signal?: AbortSignal): void => {
52
+ export function subscribable<T>(
53
+ subscribe: (signal?: AbortSignal, filter?: RoutedFilter<T>) => AsyncIterable<Routed<T>>,
54
+ ): Subscribable<T> {
55
+ const consume = (handler: (event: Routed<T>) => void | Promise<void>, signal?: AbortSignal, filter?: RoutedFilter<T>): void => {
34
56
  void (async () => {
35
- for await (const v of subscribe(signal)) {
36
- try { await handler(v); }
57
+ for await (const event of subscribe(signal, filter)) {
58
+ try { await handler(event); }
37
59
  catch (e) { console.error('[matbot] consume handler threw:', e instanceof Error ? e.message : e); }
38
60
  }
39
61
  })();
@@ -44,15 +66,17 @@ export function subscribable<T>(subscribe: (signal?: AbortSignal) => AsyncIterab
44
66
  export function createBroadcaster<T>(): Broadcaster<T> {
45
67
  const subs = new Set<Subscriber<T>>();
46
68
 
47
- const emit = (value: T): void => {
69
+ const emit = (value: T, origin?: Principal): void => {
70
+ const event: Routed<T> = origin !== undefined ? { value, origin } : { value };
48
71
  for (const sub of subs) {
49
- sub.queue.push(value);
72
+ if (sub.filter !== undefined && !sub.filter(event)) continue;
73
+ sub.queue.push(event);
50
74
  sub.wake?.();
51
75
  }
52
76
  };
53
77
 
54
- async function* subscribe(signal?: AbortSignal): AsyncIterable<T> {
55
- const sub: Subscriber<T> = { queue: [], wake: undefined, done: false };
78
+ async function* subscribe(signal?: AbortSignal, filter?: RoutedFilter<T>): AsyncIterable<Routed<T>> {
79
+ const sub: Subscriber<T> = { queue: [], wake: undefined, done: false, filter };
56
80
  subs.add(sub);
57
81
  const onAbort = () => { sub.done = true; sub.wake?.(); };
58
82
  signal?.addEventListener('abort', onAbort, { once: true });
package/src/errors.ts CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  const BRAND = 'matbot';
18
18
 
19
- export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled';
19
+ export type MatbotErrorKind = 'MissingSecret' | 'IncompatibleRuntime' | 'NotAPlugin' | 'PromptCancelled' | 'ReadOnly';
20
20
 
21
21
  function isKind(e: unknown, kind: MatbotErrorKind): boolean {
22
22
  return typeof e === 'object' && e !== null && (e as Record<string, unknown>)[BRAND] === kind;
@@ -102,3 +102,26 @@ export function promptCancelledError(message = 'User cancelled — cannot procee
102
102
  export function isPromptCancelledError(e: unknown): e is PromptCancelledError {
103
103
  return isKind(e, 'PromptCancelled');
104
104
  }
105
+
106
+ /**
107
+ * Thrown by a `Store` write when the target item is not owned by the current principal — e.g. a session
108
+ * shared read-only into another profile's partition (see `@matatbread/matbot-storage-profiles`). This is
109
+ * a per-operation condition, **not** a process fault: the turn pump catches it (via {@link isReadOnlyError},
110
+ * never `instanceof`) and surfaces it as a turn error rather than letting it escape the detached pump and
111
+ * crash the host. `namespace`/`id` name the item; `owner` is the owning principal id (`''` for the shared
112
+ * base/global partition).
113
+ */
114
+ export interface ReadOnlyError extends Error {
115
+ matbot: 'ReadOnly';
116
+ readonly namespace: string;
117
+ readonly id: string;
118
+ readonly owner: string;
119
+ }
120
+ export function readOnlyError(namespace: string, id: string, owner: string): ReadOnlyError {
121
+ const e = new Error(`${namespace} "${id}" was shared by "${owner || 'global'}" and is read-only.`);
122
+ e.name = 'ReadOnlyError';
123
+ return Object.assign(e, { matbot: 'ReadOnly' as const, namespace, id, owner });
124
+ }
125
+ export function isReadOnlyError(e: unknown): e is ReadOnlyError {
126
+ return isKind(e, 'ReadOnly');
127
+ }
package/src/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- Hook, HookPoint, Message, MessageContent, Session,
2
+ Hook, HookPoint, Message, MessageContent, Session, DeferredScreen,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
5
 
@@ -88,9 +88,12 @@ export class HookRegistry {
88
88
 
89
89
  // screen folds across hooks: each sees the session as shaped so far, accumulates ephemeral, and
90
90
  // the first `abort` short-circuits (the partial session is returned so the caller can persist it).
91
- async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; abort?: string }> {
91
+ async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; abort?: string }> {
92
92
  let session = ctx.session;
93
93
  const ephemeral: MessageContent[] = [];
94
+ // Raced verdicts handed back by hooks: the runner polls each without gating the turn (see
95
+ // DeferredScreen). Usually zero or one; an array so several racing hooks compose.
96
+ const deferred: DeferredScreen[] = [];
94
97
  // Handler-returned markers: appended to the session here (so they persist) and accumulated so the
95
98
  // runner can also emit them live — keeping a live draw and a reload identical.
96
99
  const handlerMarkers: MessageContent[] = [];
@@ -122,13 +125,14 @@ export class HookRegistry {
122
125
  if (r.ephemeral) ephemeral.push(...r.ephemeral);
123
126
  if (r.durable && r.durable.length) foldDurable(r.durable);
124
127
  if (r.markers && r.markers.length) appendMarkers(r.markers);
128
+ if (r.deferred) deferred.push(r.deferred);
125
129
  if (r.abort) {
126
130
  const drained = this.drainFailureMarkers(session);
127
- return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], abort: r.abort };
131
+ return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, abort: r.abort };
128
132
  }
129
133
  }
130
134
  const drained = this.drainFailureMarkers(session);
131
- return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers] };
135
+ return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred };
132
136
  }
133
137
 
134
138
  // contribute folds the outgoing array through each hook — a pure transform pipeline; the stored
@@ -169,17 +173,22 @@ export class HookRegistry {
169
173
  // turn), any durable markers (appended to the committed session by the pump), and any
170
174
  // retract-and-rerun requests. A turn can be popped only once, so multiple hooks' retraction
171
175
  // contexts merge into a single redo (in practice only one fires); `retract` is omitted when none did.
172
- async runFollowup(ctx: Omit<FollowupContext, 'removeHook'>): Promise<{ resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } }> {
173
- const resubmits: MessageContent[][] = [];
174
- const markers: MessageContent[] = [];
175
- const retract: MessageContent[] = [];
176
+ async runFollowup(ctx: Omit<FollowupContext, 'removeHook'>): Promise<{ resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } }> {
177
+ const resubmits: MessageContent[][] = [];
178
+ const markers: MessageContent[] = [];
179
+ const retractCtx: MessageContent[] = [];
180
+ const retractDur: MessageContent[] = [];
176
181
  for (const hook of this.hooks.get('followup') ?? []) {
177
182
  if (hook.on !== 'followup') continue;
178
183
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
179
184
  if (r?.resubmit) resubmits.push(r.resubmit.content);
180
- if (r?.retractAndRerun) retract.push(...r.retractAndRerun.context);
185
+ if (r?.retractAndRerun) {
186
+ if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
187
+ if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
188
+ }
181
189
  if (r?.markers) markers.push(...r.markers);
182
190
  }
183
- return { resubmits, markers, ...(retract.length > 0 ? { retract: { context: retract } } : {}) };
191
+ const retracting = retractCtx.length > 0 || retractDur.length > 0;
192
+ return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
184
193
  }
185
194
  }
package/src/plugin.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type {
2
- FileStore, Vault, Message, ModelParameters,
2
+ FileStore, StoreChange, Principal, Vault, Message, ModelParameters,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
5
- TypeScriptStripper, ToolTypeIndex, ToolPresenter,
5
+ TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
6
6
  } from './types.js';
7
+ import type { Routed } from './broadcast.js';
7
8
  import type { HookRegistry } from './hooks.js';
8
9
 
9
10
  export const PLUGIN_API_VERSION = '0.1';
@@ -14,6 +15,15 @@ export interface CompletionRequest {
14
15
  provider: string;
15
16
  messages: Message[];
16
17
  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
+ */
17
27
  parameters?: Partial<ModelParameters>;
18
28
  signal?: AbortSignal;
19
29
  }
@@ -27,6 +37,9 @@ export interface SingleTurnRequest {
27
37
  provider: string;
28
38
  prompt: string;
29
39
  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>;
30
43
  signal?: AbortSignal;
31
44
  }
32
45
 
@@ -106,6 +119,15 @@ export interface MatbotServices {
106
119
  * whole turn snapshot. A plain registered service (not a swap-member); consumed as a member. See
107
120
  * {@link ToolPresenter}. */
108
121
  readonly ToolPresenter?: ToolPresenter | undefined;
122
+ /** Optional partition-aware file-watch layer (cross-partition observation + per-connection visibility),
123
+ * registered by a partitioning storage backend. Absent ⇒ frontends use the plain `fileStore.watch()`.
124
+ * See {@link WatchVisibility}. */
125
+ readonly WatchVisibility?: WatchVisibility | undefined;
126
+ /** Optional steering policy: decides how a mid-turn submission under `mode: 'auto'` is disposed
127
+ * (queue vs interrupt) and supplies the nudge folded onto an interrupt's continuation. Absent ⇒ the
128
+ * runner uses its own defaults. A plain registered service, consumed by the runner as a member.
129
+ * See {@link SteeringPolicy}. */
130
+ readonly SteeringPolicy?: SteeringPolicy | undefined;
109
131
  }
110
132
 
111
133
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -166,10 +188,10 @@ export interface MatbotRuntime {
166
188
  * per-invocation through its proxy/member instead.
167
189
  *
168
190
  * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
169
- * services.mounted.consume({ key: 'StorageBackend', signal }, () => this.load());
191
+ * services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
170
192
  *
171
193
  * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
172
- * services.mounted.consume({ key: 'SkillManager', replay: true, signal }, m => seed(m));
194
+ * services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
173
195
  *
174
196
  * Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
175
197
  * says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
@@ -334,9 +356,11 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
334
356
  readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
335
357
  }
336
358
 
337
- /** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. */
359
+ /** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. `observe` (not `consume`)
360
+ * so the verb `consume` means exactly one thing repo-wide — the detached stream drain on
361
+ * {@link Subscribable}; this keyed, replay/onUnmount, edge-batched delivery is its own paradigm. */
338
362
  export interface Mounted {
339
- consume<K extends keyof MatbotServices>(
363
+ observe<K extends keyof MatbotServices>(
340
364
  options: MountConsumeOptions<K>,
341
365
  handler: (machine: MountedMachine<K>) => void | Promise<void>,
342
366
  ): void;
@@ -385,7 +409,7 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
385
409
  };
386
410
 
387
411
  const mounted: Mounted = {
388
- consume(options, handler) {
412
+ observe(options, handler) {
389
413
  const { key, replay, signal, onUnmount } = options;
390
414
  if (signal?.aborted === true) return;
391
415
  const interest: MountInterest = {
@@ -443,8 +467,9 @@ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
443
467
  id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
444
468
  content: [{ type: 'text', text: req.prompt }],
445
469
  }],
446
- ...(req.system !== undefined ? { system: req.system } : {}),
447
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
470
+ ...(req.system !== undefined ? { system: req.system } : {}),
471
+ ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
472
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
448
473
  };
449
474
  }
450
475
 
@@ -478,6 +503,34 @@ export interface StorageBackend {
478
503
  close?(): Promise<void>;
479
504
  }
480
505
 
506
+ /**
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).
513
+ */
514
+ 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
+ /**
520
+ * The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
521
+ * connection owned by `viewer` see the change to (`namespace`, `id`) produced by `origin`? True iff the
522
+ * viewer routes that namespace to the same partition as `origin` — `route(viewer, namespace) ===
523
+ * route(origin, namespace)` — OR the item is shared INTO the viewer's partition (the owner edits a
524
+ * shared-in item: origin=owner ≠ the viewer's route, yet the viewer holds a live link to it). This yields
525
+ * the intended behavior uniformly: global/base events for namespaces the viewer has NOT isolated,
526
+ * own-partition events for those it has, plus live updates to items shared in from another partition.
527
+ * `origin` may be a partition principal (files, tagged by the merge) or the acting principal (skills,
528
+ * stamped at write) — routing both sides makes either correct. `undefined` origin ⇒ base. `id` is the
529
+ * store id of the changed item; it drives the shared-in check.
530
+ */
531
+ visible(viewer: Principal, namespace: string, id: string, origin: Principal | undefined): boolean;
532
+ }
533
+
481
534
  // ── Plugin interface ──────────────────────────────────────────────────────────
482
535
 
483
536
  /**
package/src/types.ts CHANGED
@@ -223,9 +223,9 @@ export type SessionStatus = 'active' | 'archived' | 'pinned';
223
223
  export interface Session {
224
224
  id: string;
225
225
  version: string;
226
- ownerPrincipalId: string;
227
- actorPrincipalId?: string;
228
- persona?: string;
226
+ // Legacy: persisted sessions may still carry `ownerPrincipalId` / `actorPrincipalId` / `persona`.
227
+ // They were never read — ownership-at-rest is structural (the storage partition), resolved via the
228
+ // backend, not a field. Left undeclared; old data keeps them harmlessly as excess properties.
229
229
  title?: string;
230
230
  status: SessionStatus;
231
231
  contexts: string[];
@@ -254,7 +254,6 @@ export interface SystemContextRegistry {
254
254
 
255
255
  export interface RunConfig {
256
256
  provider: string;
257
- persona?: string;
258
257
  sessionId?: string;
259
258
  traceId?: string;
260
259
  }
@@ -311,8 +310,47 @@ export interface ScreenContext {
311
310
  /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
312
311
  removeHook(): void;
313
312
  }
313
+ /**
314
+ * A raced screen verdict the runner folds in WITHOUT gating the turn on it. A `screen` hook that starts
315
+ * expensive work concurrently (e.g. a triggers classifier judging the user message) returns immediately
316
+ * and hands back one of these instead of blocking on it. The runner polls `claim()` — synchronously,
317
+ * never awaiting — at each turn-loop edge (before a provider call, on every stream event, and just
318
+ * before committing). The first time `claim()` returns blocks (the work has settled WITH a correction),
319
+ * the runner DISCARDS the uncommitted in-progress response and re-runs the loop with those blocks
320
+ * tail-folded as ephemeral context — an in-situ redo: no store pop, no retraction marker, cleaner than a
321
+ * post-commit retract. Because the mid-stream poll runs before each event is emitted, a verdict faster
322
+ * than time-to-first-token is caught before any token reaches the frontend (the clean path); a slower
323
+ * one aborts the in-flight provider request, saving the unstreamed remainder. If it never fires before
324
+ * the turn commits, the turn commits normally and any correction is left to a post-commit `followup`.
325
+ *
326
+ * `claim()` MUST be exactly-once: return the correction on the first successful poll and `undefined`
327
+ * forever after (not settled, no correction, or already claimed). The hook uses that single delivery to
328
+ * coordinate with its own post-commit path — a claimed verdict is never also delivered by `followup`.
329
+ */
330
+ export interface DeferredScreen {
331
+ claim(): DeferredCorrection | undefined;
332
+ }
333
+ /**
334
+ * A claimed raced-verdict correction. `ephemeral` is tail-folded onto the re-run's outgoing messages
335
+ * and never persisted (the transient "for this answer only" twin); `durable` is folded onto the turn's
336
+ * user message — persisted, visible (the hook marks it `origin: 'robo'`), and carried live as a
337
+ * `robo-user` event — so it updates the conversation rather than informing one answer (a `contextual`
338
+ * trigger's semantics, preserved even though the verdict now lands mid-turn instead of before it). At
339
+ * least one is non-empty when returned.
340
+ */
341
+ export interface DeferredCorrection {
342
+ ephemeral?: MessageContent[];
343
+ durable?: MessageContent[];
344
+ }
314
345
  export interface ScreenResult {
315
346
  session?: Session;
347
+ /**
348
+ * A raced verdict the runner folds in without gating on it (see {@link DeferredScreen}). Lets a
349
+ * screen hook race expensive work — a classifier — against generation: return immediately, hand this
350
+ * back, and the runner restarts the turn in-situ if the verdict fires before commit. A hook that
351
+ * would rather gate the turn (block until the verdict) just returns `ephemeral` as usual instead.
352
+ */
353
+ deferred?: DeferredScreen;
316
354
  /** Turn-scoped context appended onto the tail of this turn's outgoing messages (the freshest
317
355
  * input the model reads), never persisted. At the tail, not a system prefix, so a directive
318
356
  * keeps its salience and the cached system/history prefix stays stable across turns. */
@@ -402,8 +440,13 @@ export interface FollowupResult {
402
440
  * defect that the injected context dissolves on the redo, so it won't re-fire; `resubmitDepth` (a
403
441
  * redo carries parent+1) caps an ill-formed one. `resubmit` and `retractAndRerun` are independent
404
442
  * capabilities — a single turn returning both is not expected, but both head-enqueue if it does.
443
+ *
444
+ * `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
445
+ * onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
446
+ * `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
447
+ * lands post-commit. At least one of the two is present.
405
448
  */
406
- retractAndRerun?: { context: MessageContent[] };
449
+ retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
407
450
  /**
408
451
  * Durable `marker` blocks to append to the just-committed session (LLM-invisible; for tracing /
409
452
  * cross-references). The second durable-write capability after `screen` — safe here for the same
@@ -739,6 +782,26 @@ export interface FileHandle extends FileMetaData {
739
782
 
740
783
  export type FileEvent = FileMetaData & { changed: Array<keyof FileMetaData> };
741
784
 
785
+ /**
786
+ * The generic, self-describing change envelope every partitioned CRUD stream emits (files, skills, and
787
+ * future partitioned stores) — the payload half of a {@link Routed} event, whose `origin` carries the
788
+ * acting principal/partition. It supersedes the per-stream bespoke shapes (the old `SkillEvent`, the raw
789
+ * `FileEvent` on the wire) so a frontend firehose can filter and route every stream through ONE predicate.
790
+ *
791
+ * `namespace` is the ROUTING namespace the change lives under — `'files'`, `'skills'`, or a document
792
+ * namespace — NOT a content sub-namespace (a file's own `metadata.namespace`, e.g. `'workspace'`, rides in
793
+ * `detail`). Pairing `namespace` + `id` is exactly what `WatchVisibility.visible` needs to decide, per
794
+ * connection, whether the viewer routes that item to the same partition (or has it shared in). `detail`
795
+ * carries an optional rich payload for in-place UI updates — a file change ships its {@link FileMetaData}
796
+ * so a sidebar row can update its size without a re-fetch; a skill change needs none (a refresh ping).
797
+ */
798
+ export interface StoreChange {
799
+ readonly operation: 'saved' | 'deleted';
800
+ readonly namespace: string;
801
+ readonly id: string;
802
+ readonly detail?: unknown;
803
+ }
804
+
742
805
  export interface FileFilter {
743
806
  sessionId?: string;
744
807
  mimeType?: string;
@@ -931,6 +994,13 @@ export type PipelineEvent =
931
994
  // threw), carried live so a frontend renders them without waiting for a session reload. The blocks
932
995
  // are already persisted in the session; this event is purely the live-delivery channel.
933
996
  | { type: 'marker'; content: MessageContent[]; traceId: string }
997
+ // A mid-turn steer that INTERRUPTED the running turn (see `SteeringPolicy`). Emitted synchronously
998
+ // when the steer is accepted, so the new user bubble lands in correct stream order and a frontend
999
+ // knows the imminent `aborted` (reason 'steer') on `interruptedTraceId` is a yield, not a dead-end:
1000
+ // keep that turn's partial work rendered and expect a continuation. `traceId`/`rootTraceId` identify
1001
+ // the steer submission itself (the continuation turn). Late subscribers reconstruct the bubble from
1002
+ // the pump's per-turn `queued` replay seed / committed store order — this is purely live delivery.
1003
+ | { type: 'steer'; content: MessageContent[]; interruptedTraceId: string; traceId: string; rootTraceId: string }
934
1004
  | { type: 'error'; error: string; traceId: string }
935
1005
  | { type: 'system-context'; text: string; traceId: string };
936
1006
 
@@ -969,6 +1039,11 @@ export interface SubmitOpenOpts extends OpenOpts {
969
1039
  /** When true, this submission may be merged with others drained in the same batch. Default false
970
1040
  * (queue mode: one turn per submission). */
971
1041
  concatQueue?: boolean;
1042
+ /** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
1043
+ * 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
1044
+ * committed partial work — and runs this next, 'auto' defers to the registered SteeringPolicy (else
1045
+ * the host default). Meaningless when nothing is running (degrades to a plain enqueue). */
1046
+ mode?: SteeringMode;
972
1047
  /** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
973
1048
  * it must target the frontend's per-session client connections, not a single request. */
974
1049
  prompt?: PromptFn;
@@ -992,3 +1067,35 @@ export interface SessionRunner {
992
1067
  * behind it. `busy` is `running || queued > 0`. */
993
1068
  status(sessionId: string): { busy: boolean; running: boolean; queued: number };
994
1069
  }
1070
+
1071
+ // ── Steering ────────────────────────────────────────────────────────────────────
1072
+
1073
+ /** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
1074
+ * turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
1075
+ * turn boundary. */
1076
+ export type SteeringDecision = 'queue' | 'interrupt';
1077
+ /** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
1078
+ * {@link SteeringPolicy} (else the host default). */
1079
+ export type SteeringMode = SteeringDecision | 'auto';
1080
+
1081
+ /**
1082
+ * How the runner disposes of a mid-turn submission under `mode: 'auto'`, and how an interrupt's
1083
+ * continuation is nudged. An optional, registerable service ({@link MatbotServices}); absent ⇒ the
1084
+ * runner uses its own defaults. Both members are optional so a plugin may override one, the other, or
1085
+ * both. `classify` is deliberately NOT assumed to be an LLM — a regex or a semantic classifier is a
1086
+ * first-class implementation (return synchronously); an LLM `singleTurn` returns a promise.
1087
+ */
1088
+ export interface SteeringPolicy {
1089
+ /**
1090
+ * Consulted only for `mode: 'auto'` while a turn is running. `session` is the COMMITTED session
1091
+ * (history up to the running turn's start — the in-flight partial is not reachable at submit time);
1092
+ * `steer` is the incoming submission. Return synchronously (regex) or async (semantic / singleTurn).
1093
+ * Absent ⇒ the host default disposition.
1094
+ */
1095
+ classify?(ctx: { session: Session; steer: MessageContent[] }): SteeringDecision | Promise<SteeringDecision>;
1096
+ /**
1097
+ * The ephemeral "keep going, noting the above" context folded onto an interrupt's continuation turn
1098
+ * (never persisted). Absent ⇒ the host default nudge.
1099
+ */
1100
+ nudge?(ctx: { session: Session; steer: MessageContent[] }): MessageContent[];
1101
+ }