@matatbread/matbot-core 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
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.3.3"
38
+ "@matatbread/matbot-plugin-api": "^0.3.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/registry.ts CHANGED
@@ -39,8 +39,9 @@ const state = {
39
39
  // not in `state` — it owns subscribers across the registry's lifetime, not per-plugin data.
40
40
  const pluginEvents = createBroadcaster<PluginRegistryEvent>();
41
41
 
42
- export function watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
43
- return pluginEvents.subscribe(signal);
42
+ export async function* watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
43
+ // Plugin CRUD is global — no origin — so unwrap the envelope to the bare event the panel expects.
44
+ for await (const { value } of pluginEvents.subscribe(signal)) yield value;
44
45
  }
45
46
 
46
47
  // Settings namespace + key under which the user's "overwrite all colliding tools" choice
@@ -306,13 +307,13 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
306
307
  // fires. Forward-referenced via `scoped`, assigned below; consume() only runs after setup.
307
308
  let scoped: MatbotMachine;
308
309
  const scopedMounted: Mounted = {
309
- consume(options, handler) {
310
+ observe(options, handler) {
310
311
  // Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
311
312
  // the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
312
313
  const forwarded = options.onUnmount !== undefined
313
314
  ? { ...options, onUnmount: () => options.onUnmount!(scoped) }
314
315
  : options;
315
- services.mounted.consume(forwarded, () => handler(scoped as never));
316
+ services.mounted.observe(forwarded, () => handler(scoped as never));
316
317
  },
317
318
  };
318
319
 
package/src/runner.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- Session, MessageContent, Usage, ProviderMeta,
2
+ Session, MessageContent, Usage, ProviderMeta, DeferredCorrection,
3
3
  PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
4
  Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
5
5
  } from './types.js';
@@ -10,6 +10,15 @@ import { HookRegistry } from './hooks.js';
10
10
  import { appendMessage, createMessage } from './session.js';
11
11
  import { addUsage } from './usage.js';
12
12
 
13
+ // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
14
+ // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
15
+ // internal token that reads as a real failure. A steer's continuation turn is the one place a model
16
+ // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
17
+ // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
18
+ const INTERRUPTED_TOOL_RESULT = {
19
+ error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
20
+ } as const;
21
+
13
22
  export interface RunSessionOpts {
14
23
  session: Session;
15
24
  config: RunConfig;
@@ -111,8 +120,42 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
111
120
  // drops non-result content from tool-role messages — a separate trailing message would break on
112
121
  // one or the other).
113
122
  // injectedEphemeral (a pump-supplied retract-redo's context) leads, then screen's own — both share
114
- // the identical tail-fold path below.
115
- const ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
123
+ // the identical tail-fold path below. Mutable: a raced screen verdict (below) folds its correction
124
+ // in here when it fires, and the loop re-runs with it.
125
+ let ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
126
+
127
+ // Raced screen verdicts (see DeferredScreen): polled — synchronously, never awaited — at each loop
128
+ // edge. `pollDeferred` claims any that have fired this poll (exactly once each) and merges their
129
+ // corrections; `applyCorrection` then folds them in and returns any durable blocks to emit live.
130
+ const deferred = screen.deferred;
131
+ const pollDeferred = (): DeferredCorrection | undefined => {
132
+ if (deferred.length === 0) return undefined;
133
+ const ephemeralC: MessageContent[] = [];
134
+ const durableC: MessageContent[] = [];
135
+ for (const d of deferred) {
136
+ const c = d.claim();
137
+ if (!c) continue;
138
+ if (c.ephemeral) ephemeralC.push(...c.ephemeral);
139
+ if (c.durable) durableC.push(...c.durable);
140
+ }
141
+ return (ephemeralC.length > 0 || durableC.length > 0) ? { ephemeral: ephemeralC, durable: durableC } : undefined;
142
+ };
143
+ // Apply a claimed correction: `ephemeral` leads the tail-fold for the re-run; `durable` is folded onto
144
+ // the turn's user message (persisted, re-run sees it in history) and RETURNED so the caller emits it
145
+ // live as a `robo-user` event — the durable twin, preserving a contextual fire's persistence even
146
+ // though the verdict landed mid-turn. Mutates `ephemeral`/`session` in place.
147
+ const applyCorrection = (c: DeferredCorrection): MessageContent[] => {
148
+ if (c.ephemeral && c.ephemeral.length > 0) ephemeral = [...c.ephemeral, ...ephemeral];
149
+ if (c.durable && c.durable.length > 0) {
150
+ const idx = session.messages.findLastIndex(m => m.role === 'user');
151
+ if (idx >= 0) {
152
+ session = { ...session, messages: session.messages.map((m, i) =>
153
+ i === idx ? { ...m, content: [...m.content, ...c.durable!] } : m) };
154
+ return c.durable;
155
+ }
156
+ }
157
+ return [];
158
+ };
116
159
 
117
160
  // ── 2. System context (built once per submit, never persisted) ─────────────
118
161
 
@@ -138,10 +181,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
138
181
  return;
139
182
  }
140
183
 
184
+ // A raced screen verdict that already fired (settled during setup, or while a previous tool round
185
+ // ran): fold its correction in before generating, so this call is informed directly rather than
186
+ // discarded. This is the no-waste path — the verdict landed before we spent any tokens.
187
+ const early = pollDeferred();
188
+ if (early) { const dur = applyCorrection(early); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; }
189
+
141
190
  const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta }> = [];
142
191
  const assistantParts: MessageContent[] = [];
143
192
  let textAcc = '';
144
193
  let turnUsage: Usage | undefined;
194
+ // Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
195
+ let restart = false;
145
196
 
146
197
  // One provider call. The outgoing array (system context + history, with screen's ephemeral
147
198
  // blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
@@ -164,8 +215,21 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
164
215
  const advertised = opts.toolPresenter
165
216
  ? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
166
217
  : [...tools.values()];
218
+ // Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
219
+ // to cancel the in-flight request and stop backend generation, without aborting the whole turn.
220
+ const callAc = new AbortController();
221
+ const callSignal = AbortSignal.any([signal, callAc.signal]);
167
222
  try {
168
- for await (const ev of provider.complete(outgoing, providerConfig, advertised, signal)) {
223
+ for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
224
+ // A raced verdict firing mid-generation changes the premise: discard this doomed response and
225
+ // restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
226
+ // classifier faster than time-to-first-token is caught before any token reaches the frontend.
227
+ const hit = pollDeferred();
228
+ if (hit) {
229
+ const dur = applyCorrection(hit);
230
+ if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId };
231
+ restart = true; callAc.abort('screen-restart'); break;
232
+ }
169
233
  switch (ev.type) {
170
234
  case 'text-delta':
171
235
  textAcc += ev.delta;
@@ -202,7 +266,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
202
266
  }
203
267
  }
204
268
  } catch (e: any) {
205
- if (signal.aborted) {
269
+ // A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
270
+ // through to the restart below rather than surfacing it as a turn abort or an error.
271
+ if (restart || (callAc.signal.aborted && !signal.aborted)) {
272
+ restart = true;
273
+ } else if (signal.aborted) {
206
274
  // Save whatever the LLM streamed before the abort hit.
207
275
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
208
276
  if (assistantParts.length > 0) {
@@ -214,11 +282,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
214
282
  await store.set(session.id, session);
215
283
  yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
216
284
  return;
285
+ } else {
286
+ yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
287
+ return;
217
288
  }
218
- yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
219
- return;
220
289
  }
221
290
 
291
+ // In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
292
+ // raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
293
+ // counterpart to a post-commit `followup` retract.
294
+ if (restart) continue;
295
+
296
+ // Pre-commit poll: the verdict may have settled exactly as the stream ended. Discard and re-run
297
+ // rather than commit-then-retract, keeping the correction on the cheaper in-situ path.
298
+ const late = pollDeferred();
299
+ if (late) { const dur = applyCorrection(late); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; continue; }
300
+
222
301
  // Build and store assistant message
223
302
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
224
303
  for (const tc of pendingCalls) {
@@ -318,6 +397,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
318
397
  isError = true;
319
398
  }
320
399
 
400
+ // A tool that errored under an aborted signal did so because it was cut off (a steer interrupt,
401
+ // a user cancel), not a genuine fault — reframe it before it is recorded, so the raw abort reason
402
+ // never reaches the model or the transcript. Covers both the throw and the yielded-`error` paths;
403
+ // a successful result is left untouched.
404
+ if (signal.aborted && isError) {
405
+ result = INTERRUPTED_TOOL_RESULT;
406
+ }
407
+
321
408
  // toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
322
409
  // or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
323
410
  result = await hookReg.runToolResult({
@@ -2,12 +2,13 @@ import type {
2
2
  Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
3
3
  Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
4
4
  ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
5
+ SteeringPolicy, SteeringDecision,
5
6
  } from './types.js';
6
7
  import type { MatbotPlugin } from './plugin.js';
7
8
  import type { HookRegistry } from './hooks.js';
8
9
  import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
9
10
  import { appendMessage, createMessage } from './session.js';
10
- import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
11
+ import { contextSwitch, withUsageScope, isReadOnlyError } from '@matatbread/matbot-plugin-api';
11
12
  import { runSession } from './runner.js';
12
13
 
13
14
  export interface SessionRunnerDeps {
@@ -22,6 +23,10 @@ export interface SessionRunnerDeps {
22
23
  // Resolved live (registers after boot): picks which tools are advertised to the model per provider
23
24
  // call. Absent ⇒ the whole turn snapshot is advertised. Threaded into runSession per turn.
24
25
  toolPresenter?: () => ToolPresenter | undefined;
26
+ // Resolved live (registers after boot): decides how a mid-turn submission under `mode: 'auto'` is
27
+ // disposed (queue vs interrupt) and supplies an interrupt's continuation nudge. Absent ⇒ the runner's
28
+ // own defaults (DEFAULT_STEERING_POLICY / DEFAULT_STEER_NUDGE).
29
+ steeringPolicy?: () => SteeringPolicy | undefined;
25
30
  hooks?: HookRegistry;
26
31
  systemContext?: SystemContextRegistry;
27
32
  vault?: Vault;
@@ -50,11 +55,30 @@ interface QueuedItem {
50
55
  // message already exists) and hands `ephemeral` (the trigger tool's output) to runSession, which
51
56
  // tail-folds it for this run only. `content` is empty for such an item — there is no new bubble.
52
57
  redo?: { ephemeral: MessageContent[] };
58
+ // Turn-scoped ephemeral context to fold onto this item's turn (tail-folded, never persisted) — used
59
+ // to carry an interrupt's "keep going, noting the above" nudge. Feeds runSession's injectedEphemeral,
60
+ // the same path `redo` uses; the two are mutually exclusive in practice (redo re-runs an existing
61
+ // turn with no new message, a steer introduces a new user message).
62
+ injectEphemeral?: MessageContent[];
53
63
  prompt?: PromptFn;
54
64
  }
55
65
 
56
66
  const MAX_RESUBMIT_DEPTH = 8;
57
67
 
68
+ // Disposition for a `mode: 'auto'` mid-turn submission when no SteeringPolicy is registered (or it
69
+ // declares no `classify`). Interrupt-by-default: a message sent while the agent works stops the
70
+ // running turn (its committed partial work is preserved) and runs next. Flip to 'queue' to make the
71
+ // conservative "wait for the turn boundary" the default — this const is the single knob.
72
+ const DEFAULT_STEERING_POLICY: SteeringDecision = 'interrupt';
73
+
74
+ // The ephemeral nudge folded onto an interrupt's continuation when no SteeringPolicy supplies one. It
75
+ // biases the model to continue the interrupted task while accounting for the steer, rather than pivot
76
+ // wholesale — the dial between "I'll get to that in a minute" and a full reprioritisation.
77
+ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
78
+ type: 'text',
79
+ text: 'The user sent the message above while you were working. Take it into account and keep going — continue the task you were on, incorporating this new input rather than discarding your progress.',
80
+ }];
81
+
58
82
  // Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
59
83
  // messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
60
84
  // because the pop is a pump operation, not a plugin's.
@@ -69,6 +93,10 @@ interface SessionState {
69
93
  // submissions interleaved among them (see the batch-building loop in pump). Concat and queued mix freely.
70
94
  queue: QueuedItem[];
71
95
  running: boolean;
96
+ // traceId of the turn currently running (set at pump loop top, cleared when the turn ends). Lets an
97
+ // interrupt name the turn it is yielding, so a frontend correlates the `steer` event's
98
+ // `interruptedTraceId` with the `aborted` (reason 'steer') it is about to see.
99
+ runningTraceId: string | undefined;
72
100
  ac: AbortController | undefined;
73
101
  subscribers: Set<Sink>;
74
102
  // Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
@@ -134,7 +162,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
134
162
  const stateFor = (id: string): SessionState => {
135
163
  let s = states.get(id);
136
164
  if (s === undefined) {
137
- s = { queue: [], running: false, ac: undefined, subscribers: new Set(), replay: [] };
165
+ s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
138
166
  states.set(id, s);
139
167
  }
140
168
  return s;
@@ -176,6 +204,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
176
204
  while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
177
205
  }
178
206
  const content = batch.flatMap(i => i.content);
207
+ s.runningTraceId = head.traceId;
179
208
  s.replay = [];
180
209
  // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
181
210
  // message persisted just below. notify() (in open()) reaches only subscribers that are live at
@@ -211,7 +240,17 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
211
240
  // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
212
241
  // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
213
242
  session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
214
- await deps.store.set(session.id, session);
243
+ try {
244
+ await deps.store.set(session.id, session);
245
+ } catch (e) {
246
+ // A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
247
+ // from another profile's partition). That is a per-turn condition, not a host fault: surface
248
+ // it as a turn error and drop this submission rather than let it escape the detached pump and
249
+ // crash the process. Any other write failure stays fatal.
250
+ if (!isReadOnlyError(e)) throw e;
251
+ emit(s, { type: 'error', error: e.message, traceId: head.traceId });
252
+ continue;
253
+ }
215
254
  }
216
255
 
217
256
  const resolved = await deps.resolveProvider(head.provider);
@@ -220,6 +259,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
220
259
  continue;
221
260
  }
222
261
 
262
+ // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
263
+ // two never coincide on one item, so either supplies runSession's injectedEphemeral.
264
+ const inject = head.redo?.ephemeral ?? head.injectEphemeral;
265
+
223
266
  const ac = new AbortController();
224
267
  s.ac = ac;
225
268
  // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
@@ -266,7 +309,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
266
309
  ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
267
310
  ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
268
311
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
269
- ...(head.redo !== undefined ? { injectedEphemeral: head.redo.ephemeral } : {}),
312
+ ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
270
313
  })) {
271
314
  emit(s, ev);
272
315
  }
@@ -279,7 +322,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
279
322
  if (!ac.signal.aborted && hooks) {
280
323
  const committed = await deps.store.get(id);
281
324
  if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
282
- let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } } = { resubmits: [], markers: [] };
325
+ let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
283
326
  await contextSwitch(head.principal, async () => {
284
327
  followup = await hooks.runFollowup({
285
328
  session: committed,
@@ -338,7 +381,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
338
381
  if (followup.retract) {
339
382
  const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
340
383
  const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
341
- const kept = lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages;
384
+ // `durable` correction (a contextual fire that landed post-commit) folds onto the user
385
+ // message we keep — persisted, so the redo AND every later turn see it in history, the
386
+ // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
387
+ const durable = followup.retract.durable ?? [];
388
+ const kept = (lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages)
389
+ .map((m, i) => (durable.length > 0 && i === lastUserIdx ? { ...m, content: [...m.content, ...durable] } : m));
342
390
  const retractionMsg: Message = {
343
391
  id: crypto.randomUUID(),
344
392
  role: 'marker',
@@ -347,7 +395,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
347
395
  // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
348
396
  // context fed to the redo — neither is otherwise persisted, so the pair fully traces
349
397
  // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
350
- content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context, traceId: head.traceId } }],
398
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
351
399
  };
352
400
  // Any followup markers ride along as a trailing marker message (not folded into the
353
401
  // retraction marker) so each creator's trace stays its own block.
@@ -355,6 +403,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
355
403
  ? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
356
404
  : [];
357
405
  await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
406
+ // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
407
+ // marker, mirroring the screen-time durable path's ordering.
408
+ if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
358
409
  notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
359
410
  if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
360
411
 
@@ -367,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
367
418
  principal: head.principal,
368
419
  concatQueue: false,
369
420
  resubmitDepth: head.resubmitDepth + 1,
370
- redo: { ephemeral: followup.retract.context },
421
+ redo: { ephemeral: followup.retract.context ?? [] },
371
422
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
372
423
  });
373
424
  }
@@ -381,6 +432,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
381
432
  }
382
433
  } finally {
383
434
  s.running = false;
435
+ s.runningTraceId = undefined;
384
436
  s.replay = [];
385
437
  // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
386
438
  // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
@@ -406,22 +458,75 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
406
458
  }
407
459
  s = stateFor(opts.sessionId);
408
460
  traceId = opts.traceId ?? crypto.randomUUID();
409
- const concatQueue = opts.concatQueue ?? false;
410
- s.queue.push({
411
- traceId,
412
- rootTraceId: traceId,
413
- content: opts.content,
414
- provider: opts.provider,
415
- principal: opts.principal,
416
- concatQueue,
417
- resubmitDepth: 0,
418
- ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
419
- });
420
- // Announce the submission on the stream as part of the live delta, before its turn's events.
421
- // If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
422
- // tells a frontend whether this submission will merge into the running batch (so it can fold the
423
- // bubble) or run as its own turn.
424
- notify(s, { type: 'queued', content: opts.content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
461
+ const { content, provider, principal } = opts;
462
+ const mode = opts.mode ?? 'queue';
463
+
464
+ // Decide interrupt vs queue for a submission arriving mid-turn. Steering is only meaningful
465
+ // while a turn is running; with nothing running it enqueues and runs next regardless of mode.
466
+ // The decision — and, on interrupt, the abort+unshift — happen here synchronously against
467
+ // s.running (re-checked after any classify/nudge await) so an interrupt can never land on the
468
+ // wrong (a later) turn: the fatal race of deciding across a wire is closed by deciding in-runner.
469
+ let interrupt = false;
470
+ if (mode !== 'queue' && s.running) {
471
+ const policy = deps.steeringPolicy?.();
472
+ // Fetch the committed session once (history up to the running turn's start) only when a policy
473
+ // member actually needs it: classify under `auto`, and nudge on any interrupt. The common
474
+ // no-policy interrupt path (e.g. the web default) does no extra read.
475
+ const wantClassify = mode === 'auto' && policy?.classify !== undefined;
476
+ const wantNudge = policy?.nudge !== undefined;
477
+ const committed = (wantClassify || wantNudge) ? await deps.store.get(opts.sessionId) : null;
478
+
479
+ const decision: SteeringDecision = mode === 'interrupt'
480
+ ? 'interrupt'
481
+ : policy?.classify !== undefined
482
+ ? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
483
+ : DEFAULT_STEERING_POLICY;
484
+
485
+ // Any await above may have outlived the running turn; only interrupt one still live.
486
+ if (decision === 'interrupt' && s.running) {
487
+ const nudge = policy?.nudge !== undefined && committed
488
+ ? policy.nudge({ session: committed, steer: content })
489
+ : DEFAULT_STEER_NUDGE;
490
+ const interruptedTraceId = s.runningTraceId ?? '';
491
+ // Head of the queue (ahead of anything else already queued): the steer runs immediately
492
+ // after the interrupted turn commits its partial work. Own turn (never concat).
493
+ s.queue.unshift({
494
+ traceId,
495
+ rootTraceId: traceId,
496
+ content,
497
+ provider,
498
+ principal,
499
+ concatQueue: false,
500
+ resubmitDepth: 0,
501
+ injectEphemeral: nudge,
502
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
503
+ });
504
+ // Announce the steer before aborting so a subscriber places the new bubble and reads the
505
+ // imminent `aborted` (reason 'steer') as a yield, not a dead-end.
506
+ notify(s, { type: 'steer', content, interruptedTraceId, traceId, rootTraceId: traceId });
507
+ s.ac?.abort('steer');
508
+ interrupt = true;
509
+ }
510
+ }
511
+
512
+ if (!interrupt) {
513
+ const concatQueue = opts.concatQueue ?? false;
514
+ s.queue.push({
515
+ traceId,
516
+ rootTraceId: traceId,
517
+ content,
518
+ provider,
519
+ principal,
520
+ concatQueue,
521
+ resubmitDepth: 0,
522
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
523
+ });
524
+ // Announce the submission on the stream as part of the live delta, before its turn's events.
525
+ // If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
526
+ // tells a frontend whether this submission will merge into the running batch (so it can fold the
527
+ // bubble) or run as its own turn.
528
+ notify(s, { type: 'queued', content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
529
+ }
425
530
  void pump(opts.sessionId, s);
426
531
  }
427
532
 
package/src/session.ts CHANGED
@@ -1,25 +1,17 @@
1
- import type { Session, Message, MessageContent, MessageRole, Principal, Usage } from './types.js';
1
+ import type { Session, Message, MessageContent, MessageRole, Usage } from './types.js';
2
2
 
3
3
  export interface CreateSessionOpts {
4
- ownerPrincipal: Principal;
5
- actorPrincipal?: Principal;
6
- persona?: string;
7
4
  title?: string;
8
5
  contexts?: string[];
9
6
  parentSessionId?: string;
10
7
  branchPointMessageId?: string;
11
8
  }
12
9
 
13
- export function createSession(opts: CreateSessionOpts): Session {
10
+ export function createSession(opts: CreateSessionOpts = {}): Session {
14
11
  const now = new Date().toISOString();
15
12
  return {
16
13
  id: crypto.randomUUID(),
17
14
  version: crypto.randomUUID(),
18
- ownerPrincipalId: opts.ownerPrincipal.id,
19
- ...(opts.actorPrincipal && opts.actorPrincipal.id !== opts.ownerPrincipal.id
20
- ? { actorPrincipalId: opts.actorPrincipal.id }
21
- : {}),
22
- ...(opts.persona !== undefined ? { persona: opts.persona } : {}),
23
15
  ...(opts.title !== undefined ? { title: opts.title } : {}),
24
16
  ...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
25
17
  ...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
@@ -0,0 +1,166 @@
1
+ import type {
2
+ Store, StorageBackend, FileStore, StoreQuery, QueryResult, CASResult,
3
+ } from '@matatbread/matbot-plugin-api';
4
+ import { executeQuery } from './query/execute.js';
5
+
6
+ export interface CachingOptions {
7
+ /**
8
+ * Bounded-staleness ceiling for FOREIGN writes — another process/tab/host mutating the same backing
9
+ * store behind our back. When set, a namespace's in-memory image is treated as stale this many ms
10
+ * after it was warmed and re-loaded on the next read. Own writes are always coherent regardless
11
+ * (write-through keeps the image in step), so a strictly single-writer deployment can leave this
12
+ * unset. Undefined ⇒ warm once and never expire on time.
13
+ *
14
+ * TTL is the *portable* invalidation tier — every backing store supports it. A backing store that
15
+ * also exposes a change feed (Node `fs.watch`, the Drive Changes API, Postgres `LISTEN/NOTIFY`)
16
+ * could drive sharper foreign-write invalidation by evicting `image` on a remote change event; that
17
+ * is a later, opt-in refinement layered on top of this and deliberately NOT required here.
18
+ */
19
+ ttlMs?: number;
20
+ }
21
+
22
+ /** Per-namespace cache counters, for answering "is the cache actually saving reads?" — see
23
+ * {@link CachingStorageBackend.stats}. `reads` is get()+query() calls; `hits` is the subset served
24
+ * from the warm image without touching the backing store; `loads` is full-namespace warms/refreshes
25
+ * (the expensive round-trips); `lastLoadMs` times the most recent one; `docs` is the current image
26
+ * size. A healthy cache shows `hits` climbing while `loads` stays flat (typically 1). */
27
+ export interface CacheNamespaceStats {
28
+ docs: number;
29
+ reads: number;
30
+ hits: number;
31
+ loads: number;
32
+ lastLoadMs: number;
33
+ }
34
+
35
+ /**
36
+ * Cache-aside, write-through caching Store. Reads serve from a full in-memory image of the namespace
37
+ * (`Map<id, T>`), warmed lazily by a single `backing.query({})`; every write goes to the backing store
38
+ * first (the system of record — CAS and durability stay there) and then updates the image on success.
39
+ *
40
+ * Coherence is honest: always coherent with THIS store's own writes; foreign writes only as sharp as
41
+ * the {@link CachingOptions.ttlMs} tier (never, if unset). That is the single-writer assumption made
42
+ * explicit at one layer — a backend that cannot assume it simply isn't wrapped, or wraps with a short
43
+ * TTL.
44
+ */
45
+ class CachingStore<T extends { id: string; version: string }> implements Store<T> {
46
+ private image: Map<string, T> | undefined;
47
+ private warmedAt = 0;
48
+ private warming: Promise<Map<string, T>> | undefined;
49
+
50
+ private readonly backing: Store<T>;
51
+ private readonly ttlMs: number | undefined;
52
+
53
+ private reads = 0;
54
+ private hits = 0;
55
+ private loads = 0;
56
+ private lastLoadMs = 0;
57
+
58
+ constructor(backing: Store<T>, ttlMs: number | undefined) {
59
+ this.backing = backing;
60
+ this.ttlMs = ttlMs;
61
+ }
62
+
63
+ get stats(): CacheNamespaceStats {
64
+ return { docs: this.image?.size ?? 0, reads: this.reads, hits: this.hits, loads: this.loads, lastLoadMs: this.lastLoadMs };
65
+ }
66
+
67
+ private fresh(): boolean {
68
+ if (this.image === undefined) return false;
69
+ if (this.ttlMs === undefined) return true;
70
+ return Date.now() - this.warmedAt < this.ttlMs;
71
+ }
72
+
73
+ // Serve the whole-namespace image, (re)loading it when cold or TTL-stale. Concurrent callers share
74
+ // one in-flight load rather than each firing their own `query({})`.
75
+ private warm(): Promise<Map<string, T>> {
76
+ if (this.image !== undefined && this.fresh()) { this.hits++; return Promise.resolve(this.image); }
77
+ return (this.warming ??= this.load());
78
+ }
79
+
80
+ private async load(): Promise<Map<string, T>> {
81
+ const started = Date.now();
82
+ try {
83
+ const { items } = await this.backing.query({});
84
+ const map = new Map<string, T>(items.map(d => [d.id, d]));
85
+ this.image = map;
86
+ this.warmedAt = Date.now();
87
+ this.loads++;
88
+ this.lastLoadMs = Date.now() - started;
89
+ return map;
90
+ } finally {
91
+ this.warming = undefined;
92
+ }
93
+ }
94
+
95
+ async get(id: string): Promise<T | null> {
96
+ this.reads++;
97
+ return (await this.warm()).get(id) ?? null;
98
+ }
99
+
100
+ async query(q: StoreQuery): Promise<QueryResult<T>> {
101
+ this.reads++;
102
+ return executeQuery([...(await this.warm()).values()], q);
103
+ }
104
+
105
+ async set(id: string, value: T): Promise<void> {
106
+ await this.backing.set(id, value);
107
+ this.image?.set(id, value);
108
+ }
109
+
110
+ async cas(id: string, expected: string, next: T): Promise<CASResult<T>> {
111
+ const result = await this.backing.cas(id, expected, next);
112
+ if (this.image !== undefined) {
113
+ if (result.ok) this.image.set(id, next);
114
+ // Conflict: the backing store hands back the authoritative current doc — reconcile the image so a
115
+ // retry (and the caller inspecting `current`) sees truth, not our stale copy.
116
+ else if (result.current !== null) this.image.set(id, result.current);
117
+ else this.image.delete(id);
118
+ }
119
+ return result;
120
+ }
121
+
122
+ async delete(id: string, expectedVersion?: string): Promise<boolean> {
123
+ const ok = await this.backing.delete(id, expectedVersion);
124
+ if (ok) this.image?.delete(id);
125
+ return ok;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Wraps any {@link StorageBackend} in a per-namespace read-through cache — see {@link CachingStore}.
131
+ * Compose it BELOW any principal-partitioning router (e.g. the profiles backend) so each partition
132
+ * gets its own cache and the cache never sees a blended, principal-blind view; a slow backend
133
+ * (browser Google Drive) wraps its own backend before registering. File blobs pass straight through
134
+ * uncached (large, and read far less often than documents).
135
+ */
136
+ export class CachingStorageBackend implements StorageBackend {
137
+ readonly inner: StorageBackend;
138
+ private readonly ttlMs: number | undefined;
139
+ private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
140
+
141
+ constructor(inner: StorageBackend, options: CachingOptions = {}) {
142
+ this.inner = inner;
143
+ this.ttlMs = options.ttlMs;
144
+ }
145
+
146
+ createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
147
+ let store = this.stores.get(namespace);
148
+ if (store === undefined) {
149
+ store = new CachingStore(this.inner.createStore(namespace), this.ttlMs);
150
+ this.stores.set(namespace, store);
151
+ }
152
+ return store as unknown as Store<T>;
153
+ }
154
+
155
+ /** Per-namespace cache counters (only namespaces touched so far). A diagnostic surface — read it to
156
+ * confirm the cache is serving reads (`hits` up, `loads` ~1) rather than re-reading the backend. */
157
+ stats(): Record<string, CacheNamespaceStats> {
158
+ const out: Record<string, CacheNamespaceStats> = {};
159
+ for (const [ns, store] of this.stores) out[ns] = store.stats;
160
+ return out;
161
+ }
162
+
163
+ get fileStore(): FileStore { return this.inner.fileStore; }
164
+
165
+ close(): Promise<void> { return this.inner.close?.() ?? Promise.resolve(); }
166
+ }
@@ -1 +1,2 @@
1
1
  export * from './query/index.js';
2
+ export { CachingStorageBackend, type CachingOptions, type CacheNamespaceStats } from './caching-backend.js';
@@ -32,7 +32,8 @@ export class ToolRegistryImpl implements ToolRegistry {
32
32
  return [...this.tools.values()];
33
33
  }
34
34
 
35
- watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
36
- return this.events.subscribe(signal);
35
+ async *watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
36
+ // Tool CRUD is global — no origin — so unwrap the envelope to the bare event.
37
+ for await (const { value } of this.events.subscribe(signal)) yield value;
37
38
  }
38
39
  }