@matatbread/matbot-plugin-api 0.3.4 → 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.4",
7
+ "version": "0.3.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
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,8 +1,8 @@
1
1
  import type {
2
- FileStore, FileEvent, Principal, 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
7
  import type { Routed } from './broadcast.js';
8
8
  import type { HookRegistry } from './hooks.js';
@@ -15,6 +15,15 @@ export interface CompletionRequest {
15
15
  provider: string;
16
16
  messages: Message[];
17
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
+ */
18
27
  parameters?: Partial<ModelParameters>;
19
28
  signal?: AbortSignal;
20
29
  }
@@ -28,6 +37,9 @@ export interface SingleTurnRequest {
28
37
  provider: string;
29
38
  prompt: string;
30
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>;
31
43
  signal?: AbortSignal;
32
44
  }
33
45
 
@@ -111,6 +123,11 @@ export interface MatbotServices {
111
123
  * registered by a partitioning storage backend. Absent ⇒ frontends use the plain `fileStore.watch()`.
112
124
  * See {@link WatchVisibility}. */
113
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;
114
131
  }
115
132
 
116
133
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -450,8 +467,9 @@ export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
450
467
  id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
451
468
  content: [{ type: 'text', text: req.prompt }],
452
469
  }],
453
- ...(req.system !== undefined ? { system: req.system } : {}),
454
- ...(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 } : {}),
455
473
  };
456
474
  }
457
475
 
@@ -494,18 +512,23 @@ export interface StorageBackend {
494
512
  * The origin/visibility split is generic (files today; the same shape serves skills/stores later).
495
513
  */
496
514
  export interface WatchVisibility {
497
- /** Every partition's file events, merged into one stream, each tagged with its origin partition. */
498
- watchFiles(signal?: AbortSignal): AsyncIterable<Routed<FileEvent>>;
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>>;
499
519
  /**
500
520
  * The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
501
- * connection owned by `viewer` see an event in `namespace` produced by `origin`? Defined as
502
- * `route(viewer, namespace) === route(origin, namespace)` — the two see it iff they route that namespace
503
- * to the same partition. This yields the intended behavior uniformly: a viewer sees global/base events
504
- * for namespaces it does NOT isolate, and only its own partition's events for namespaces it does.
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.
505
527
  * `origin` may be a partition principal (files, tagged by the merge) or the acting principal (skills,
506
- * stamped at write) — routing both sides makes either correct. `undefined` origin ⇒ base.
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.
507
530
  */
508
- visible(viewer: Principal, namespace: string, origin: Principal | undefined): boolean;
531
+ visible(viewer: Principal, namespace: string, id: string, origin: Principal | undefined): boolean;
509
532
  }
510
533
 
511
534
  // ── Plugin interface ──────────────────────────────────────────────────────────
package/src/types.ts CHANGED
@@ -310,8 +310,47 @@ export interface ScreenContext {
310
310
  /** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
311
311
  removeHook(): void;
312
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
+ }
313
345
  export interface ScreenResult {
314
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;
315
354
  /** Turn-scoped context appended onto the tail of this turn's outgoing messages (the freshest
316
355
  * input the model reads), never persisted. At the tail, not a system prefix, so a directive
317
356
  * keeps its salience and the cached system/history prefix stays stable across turns. */
@@ -401,8 +440,13 @@ export interface FollowupResult {
401
440
  * defect that the injected context dissolves on the redo, so it won't re-fire; `resubmitDepth` (a
402
441
  * redo carries parent+1) caps an ill-formed one. `resubmit` and `retractAndRerun` are independent
403
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.
404
448
  */
405
- retractAndRerun?: { context: MessageContent[] };
449
+ retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
406
450
  /**
407
451
  * Durable `marker` blocks to append to the just-committed session (LLM-invisible; for tracing /
408
452
  * cross-references). The second durable-write capability after `screen` — safe here for the same
@@ -738,6 +782,26 @@ export interface FileHandle extends FileMetaData {
738
782
 
739
783
  export type FileEvent = FileMetaData & { changed: Array<keyof FileMetaData> };
740
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
+
741
805
  export interface FileFilter {
742
806
  sessionId?: string;
743
807
  mimeType?: string;
@@ -930,6 +994,13 @@ export type PipelineEvent =
930
994
  // threw), carried live so a frontend renders them without waiting for a session reload. The blocks
931
995
  // are already persisted in the session; this event is purely the live-delivery channel.
932
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 }
933
1004
  | { type: 'error'; error: string; traceId: string }
934
1005
  | { type: 'system-context'; text: string; traceId: string };
935
1006
 
@@ -968,6 +1039,11 @@ export interface SubmitOpenOpts extends OpenOpts {
968
1039
  /** When true, this submission may be merged with others drained in the same batch. Default false
969
1040
  * (queue mode: one turn per submission). */
970
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;
971
1047
  /** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
972
1048
  * it must target the frontend's per-session client connections, not a single request. */
973
1049
  prompt?: PromptFn;
@@ -991,3 +1067,35 @@ export interface SessionRunner {
991
1067
  * behind it. `busy` is `running || queued > 0`. */
992
1068
  status(sessionId: string): { busy: boolean; running: boolean; queued: number };
993
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
+ }