@matatbread/matbot-core 0.4.17 → 0.4.19

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.4.17",
7
+ "version": "0.4.19",
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.4.17"
38
+ "@matatbread/matbot-plugin-api": "^0.4.19"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/assemble.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  MatbotMachine, MatbotServices, MatbotPlugin, Store, Session, FileStore, StorageBackend, Vault, Notifier,
3
- KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, Tool, Usage,
3
+ KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, SessionAppender, Tool, Usage,
4
4
  } from '@matatbread/matbot-plugin-api';
5
5
  import {
6
6
  forwardingProxy, makeSwappable, createMountTable, scheduleAtEdge, createNotifier, recordUsage, singleTurnRequest,
@@ -9,6 +9,7 @@ import {
9
9
  import type { SwapFn } from '@matatbread/matbot-plugin-api/host';
10
10
  import { notifyingStore, createMessage } from '@matatbread/matbot-plugin-api';
11
11
  import { mediumGuard } from './storage-base/medium-guard.js';
12
+ import { MemoryStore } from './storage-base/memory-store.js';
12
13
  import { unifyServices } from './plugin.js';
13
14
  import { instantiateProvider, recordServiceKey, getPluginNameForSpecifier, getRegisteredPlugins } from './registry.js';
14
15
  import { installSettingsDefaults, installSettingsNotifier, makePluginSettings, settingsDefaultNamespaces } from './settings.js';
@@ -19,6 +20,7 @@ import type { ProviderRegistryImpl } from './provider-registry.js';
19
20
  import { SystemContextRegistryImpl } from './system-context.js';
20
21
  import { LookupKnowledgeIndex } from './knowledge/index.js';
21
22
  import { createSessionRunner } from './session-runner.js';
23
+ import { createSessionAppender } from './session-append.js';
22
24
  import { createSingleTurnTool } from './single-turn.js';
23
25
  import { createAboutMatbotTool } from './about.js';
24
26
  import { addUsage } from './usage.js';
@@ -81,7 +83,7 @@ export interface AssembleOptions {
81
83
  export interface AssembledMachine {
82
84
  services: MatbotMachine;
83
85
  /** A session runner over any store — the shared one is `services.run`. */
84
- makeRunner(store: Store<Session>): SessionRunner;
86
+ makeRunner(store: Store<Session>, opts?: { appender?: SessionAppender }): SessionRunner;
85
87
  /** Call once the configured plugins have loaded. */
86
88
  loaded(): void;
87
89
  }
@@ -176,7 +178,21 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
176
178
  // The host's file area doubles as the media store, so attachments work with no plugin. Seeded into the
177
179
  // registry rather than spelled on the base object, because `unifyServices` resolves an own property
178
180
  // first — a member spelled there is one `register()` could never reach.
179
- const seed: Partial<MatbotServices> = { MediaStore: fileStore, ...opts.seed };
181
+ //
182
+ // The session appender is seeded the same way, in every process: it refuses in a background job, which
183
+ // registers its own (forwarding to its parent) over it — and unloading that reverts to refusing, which
184
+ // is the honest answer for a job with no channel. Late-bound, as `services` is built below. It writes
185
+ // through the shared runner, the one writer of the sessions in `services.sessions`.
186
+ const seed: Partial<MatbotServices> = {
187
+ MediaStore: fileStore,
188
+ SessionAppender: createSessionAppender({
189
+ sessions: () => services.sessions,
190
+ run: () => services.run,
191
+ notifier: () => services.Notifier,
192
+ isSubAgent: () => services.isSubAgent(),
193
+ }),
194
+ ...opts.seed,
195
+ };
180
196
  const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
181
197
  // The validator lookup is late-bound and read per call: one is registered by a plugin long after the
182
198
  // builtins are seeded, and may be unloaded again. It wraps the executor, so every door — the runner,
@@ -306,6 +322,10 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
306
322
  sessions,
307
323
  files: fileStore,
308
324
  get run() { return runner; },
325
+ ephemeral(opts) {
326
+ const store = new MemoryStore<Session>();
327
+ return { sessions: store, run: makeRunner(store, opts) };
328
+ },
309
329
  hooks,
310
330
  tools,
311
331
  systemContext,
@@ -328,8 +348,9 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
328
348
  };
329
349
 
330
350
  // Services a plugin registers after boot are resolved live, per turn.
331
- const makeRunner = (store: Store<Session>): SessionRunner => createSessionRunner({
351
+ const makeRunner = (store: Store<Session>, extra?: { appender?: SessionAppender }): SessionRunner => createSessionRunner({
332
352
  store,
353
+ ...(extra?.appender !== undefined ? { appender: extra.appender } : {}),
333
354
  resolveProvider,
334
355
  tools,
335
356
  hooks,
@@ -0,0 +1,67 @@
1
+ import type { EphemeralRun, PipelineEvent, Principal, PromptFn, Session, UserContent } from '@matatbread/matbot-plugin-api';
2
+
3
+ /**
4
+ * One turn on an ephemeral run, from a fresh session to its committed transcript: what a background job
5
+ * and a skill demonstration each do, and each did for itself.
6
+ *
7
+ * Yields the turn's own events, up to and including its terminal, and returns the transcript.
8
+ *
9
+ * Three details are why this is shared rather than rewritten:
10
+ * - `signal` is wired to the run's `abort`. On `open` it only ends this view of the session, while the
11
+ * turn runs on the runner's own controller. Without the wiring, a cancelled job or compile left its
12
+ * turn calling tools after its caller had gone.
13
+ * - A caller that stops reading before the terminal aborts the turn for the same reason.
14
+ * - So does reaching the terminal, which is NOT the end of the run: `followup` is post-commit, so a hook
15
+ * that resubmits or retracts enqueues a further turn on this runner AFTER the terminal this returns on.
16
+ * Left armed, it ran on unobserved — calling tools, and appending through the run's appender — past the
17
+ * transcript its caller was handed and past a cancel that no longer reached it. One turn means one.
18
+ * - An `error` terminal carries no session, so the transcript is then recovered from the run's store.
19
+ *
20
+ * `sessionId` is for a caller that names the session before the turn starts, to log or check it.
21
+ */
22
+ export async function* runEphemeralTurn(
23
+ ephemeral: EphemeralRun,
24
+ opts: {
25
+ content: UserContent[];
26
+ provider: string;
27
+ principal: Principal;
28
+ signal: AbortSignal;
29
+ prompt?: PromptFn;
30
+ sessionId?: string;
31
+ },
32
+ ): AsyncGenerator<PipelineEvent, Session | undefined> {
33
+ const now = new Date().toISOString();
34
+ const id = opts.sessionId ?? crypto.randomUUID();
35
+ await ephemeral.sessions.set(id, { id, version: crypto.randomUUID(), status: 'active', messages: [], createdAt: now, updatedAt: now });
36
+
37
+ const view = await ephemeral.run.open({
38
+ sessionId: id, signal: opts.signal, provider: opts.provider, principal: opts.principal, content: opts.content,
39
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
40
+ });
41
+ const stop = (): void => { ephemeral.run.abort(id); };
42
+ opts.signal.addEventListener('abort', stop, { once: true });
43
+ if (opts.signal.aborted) stop();
44
+
45
+ let final: Session | undefined;
46
+ try {
47
+ for await (const ev of view.events) {
48
+ // To `idle` — the run quiescing — not to this turn's terminal. `followup` is post-commit, so a hook
49
+ // that resubmits or retracts enqueues a FURTHER turn on this runner after the terminal, and stopping
50
+ // at the terminal left it running with nobody watching: appending through the run's appender, calling
51
+ // tools, past the transcript the caller was handed and past a cancel that no longer reached it.
52
+ // Aborting at the terminal does not close that either — the pop happens before the hook enqueues, so
53
+ // the resubmission is queued after the drain and runs anyway. Waiting is what makes the run's work
54
+ // the caller's: bounded by MAX_RESUBMIT_DEPTH, cancellable throughout, and counted.
55
+ if (ev.type === 'idle') break;
56
+ if (!('traceId' in ev) || ev.traceId !== view.traceId) continue;
57
+ if (ev.type === 'done' || ev.type === 'aborted') final = ev.session;
58
+ yield ev;
59
+ }
60
+ } finally {
61
+ opts.signal.removeEventListener('abort', stop);
62
+ stop();
63
+ }
64
+ // The store, not the terminal's session: a followup turn commits after it, and the transcript is meant to
65
+ // be everything the run did. `final` covers a store that has gone from under it.
66
+ return (await ephemeral.sessions.get(id)) ?? final;
67
+ }
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE
23
23
  // that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
24
24
  // plugin-api dependency to get it.
25
25
  export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
26
- export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
26
+ export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
27
27
  // The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
28
28
  // PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
29
29
  export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
@@ -57,6 +57,8 @@ export * from './usage.js';
57
57
  export * from './hooks.js';
58
58
  export * from './runner.js';
59
59
  export * from './session-runner.js';
60
+ export * from './session-append.js';
61
+ export * from './ephemeral-turn.js';
60
62
  export * from './plugin.js';
61
63
  export * from './registry.js';
62
64
  export * from './settings.js';
@@ -1 +1,2 @@
1
1
  export { parseSSE } from './sse.js';
2
+ export { textDocument } from './text-document.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A text-typed `document` arm, rendered as a plain text block.
3
+ *
4
+ * Every adapter needs this and none should use its protocol's document block for it. A document block
5
+ * buys a title and citation support on the one endpoint that natively implements it, and is DISCARDED
6
+ * by the Anthropic- and OpenAI-compatible shims most vendors front their models with: DeepSeek
7
+ * substitutes the literal "[Unsupported Document]" server-side, so the model is told a file is attached
8
+ * and shown nothing — strictly worse than either sending the bytes or refusing them at the boundary.
9
+ * A text block is the one content shape every endpoint implements, and for text it is lossless: the
10
+ * model reads the same characters either way.
11
+ *
12
+ * The name is folded into the framing because the alternative is a bare CSV dump with nothing saying
13
+ * it is a file, which is the other half of what went wrong — a model that cannot tell content from
14
+ * attachment goes looking for the attachment.
15
+ */
16
+
17
+ /**
18
+ * Structured-text types that are text without saying `text/`. A `+json`/`+xml` suffix (RFC 6839) is
19
+ * matched too, so `application/vnd.api+json` needs no entry.
20
+ *
21
+ * An unlisted type is NOT guessed at, `application/octet-stream` above all: the browser hands that over
22
+ * for any extension the OS could not place (`.yaml` on some installs), and sniffing it would mean
23
+ * deciding that binary is text on the strength of it happening to decode.
24
+ */
25
+ const TEXTUAL: ReadonlySet<string> = new Set([
26
+ 'application/json', 'application/ld+json', 'application/x-ndjson',
27
+ 'application/xml', 'application/yaml', 'application/x-yaml',
28
+ 'application/toml', 'application/csv', 'application/sql',
29
+ 'application/javascript', 'application/x-javascript', 'application/ecmascript',
30
+ 'application/x-sh', 'application/x-httpd-php', 'application/graphql',
31
+ ]);
32
+
33
+ function isTextual(mimeType: string): boolean {
34
+ const base = mimeType.split(';')[0]!.trim().toLowerCase();
35
+ return base.startsWith('text/') || base.endsWith('+json') || base.endsWith('+xml') || TEXTUAL.has(base);
36
+ }
37
+
38
+ /**
39
+ * The text of a document arm that should be inlined as text, or null to leave the adapter's own
40
+ * degradation in place (a PDF, an unrecognised type, or bytes that are not valid UTF-8 — a mislabelled
41
+ * binary must not reach a prompt as mojibake, so the decode is `fatal`).
42
+ *
43
+ * `atob` yields one byte per char, so re-widen through TextDecoder rather than trusting it for anything
44
+ * outside ASCII.
45
+ */
46
+ export function textDocument(doc: { mimeType: string; data: string; name?: string | undefined }): string | null {
47
+ if (!isTextual(doc.mimeType)) return null;
48
+ let text: string;
49
+ try {
50
+ text = new TextDecoder('utf-8', { fatal: true })
51
+ .decode(Uint8Array.from(atob(doc.data), ch => ch.charCodeAt(0)));
52
+ } catch { return null; }
53
+ const label = doc.name ?? doc.mimeType;
54
+ return `--- ${label} ---\n${text}\n--- end of ${label} ---`;
55
+ }
package/src/runner.ts CHANGED
@@ -5,7 +5,7 @@ import type {
5
5
  PermissionGate,
6
6
  } from './types.js';
7
7
  import type { MatbotPlugin } from './plugin.js';
8
- import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
8
+ import type { ToolPresenter, SessionAppender, TurnToolSource } from '@matatbread/matbot-plugin-api';
9
9
  import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
10
10
  import { HookRegistry } from './hooks.js';
11
11
  import { appendMessage, createMessage } from './session.js';
@@ -83,6 +83,38 @@ const INTERRUPTED_TOOL_RESULT = {
83
83
  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.',
84
84
  } as const;
85
85
 
86
+ /**
87
+ * The turn tools `screen` offered (see {@link TurnToolSource}), as they stand for this round. Asked every
88
+ * round because the turn itself changes the session they are derived from. A name the registry (or the
89
+ * turn-start snapshot) already holds is dropped: the registered tool keeps it, since shadowing one is a
90
+ * gated decision and a hook must not be a way round it. `warned` keeps that to one line per name a turn.
91
+ * A source that throws is skipped, like a throwing hook — it must not take the turn down with it.
92
+ */
93
+ async function resolveTurnTools(
94
+ sources: readonly TurnToolSource[],
95
+ session: Session,
96
+ taken: (name: string) => boolean,
97
+ warned: Set<string>,
98
+ ): Promise<Map<string, Tool>> {
99
+ const out = new Map<string, Tool>();
100
+ for (const source of sources) {
101
+ let offered: readonly Tool[];
102
+ try { offered = await source(session); }
103
+ catch (e) { console.warn(`[runner] a turn tool source failed: ${e instanceof Error ? e.message : String(e)}`); continue; }
104
+ for (const tool of offered) {
105
+ if (taken(tool.name)) {
106
+ if (!warned.has(tool.name)) {
107
+ warned.add(tool.name);
108
+ console.warn(`[runner] turn tool "${tool.name}" dropped: a registered tool has that name.`);
109
+ }
110
+ continue;
111
+ }
112
+ if (!out.has(tool.name)) out.set(tool.name, tool);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
86
118
  export interface RunSessionOpts {
87
119
  session: Session;
88
120
  config: RunConfig;
@@ -115,6 +147,9 @@ export interface RunSessionOpts {
115
147
  workdir?: string;
116
148
  configPath?: string;
117
149
  files?: FileStore;
150
+ /** Handed to tools as `ToolContext.appender`: set only by a runner whose sessions the machine's own
151
+ * appender cannot reach (an ephemeral one). */
152
+ appender?: SessionAppender;
118
153
  /** Where user-attached session media lives. Resolved once per turn into the outgoing copy, newest-first
119
154
  * and inside {@link MEDIA_RESIDENCY_BYTES}; absent ⇒ every `file-ref` stays a ref and the converters
120
155
  * degrade it. Never read for anything else — the runner does not otherwise touch a file. */
@@ -133,6 +168,13 @@ export interface RunSessionOpts {
133
168
  * re-run of the originating user turn. Empty/absent for an ordinary turn.
134
169
  */
135
170
  injectedEphemeral?: MessageContent[];
171
+ /**
172
+ * Consulted at the top of every round — the one point in a turn where the history is protocol-whole:
173
+ * every `tool_use` so far is answered, and no provider call is in flight. Returns the session with
174
+ * messages from outside the turn placed into it (the pump's parallel replies), and the traceIds to
175
+ * announce as `merged`; `undefined` ⇒ nothing arrived. Where they go is the caller's decision.
176
+ */
177
+ interject?: (session: Session) => { session: Session; merged: string[] } | undefined;
136
178
  loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
137
179
  unloadPlugin: (specifier: string) => Promise<boolean>;
138
180
  }
@@ -165,7 +207,15 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
165
207
  // Nothing is written mid-turn, so "commit" here means the whole turn or none of it.
166
208
  // Closes over the reassigned `session` deliberately, so no exit can commit a stale copy.
167
209
  async function* end(terminal: TurnEvent): AsyncIterable<TurnEvent> {
210
+ // One last pull, because this is the funnel every exit passes through. A parallel reply that settled
211
+ // since the previous round boundary may already be in the store (the session runner places one as it
212
+ // settles, so a turn parked in a long tool call does not hide it) — and the write below is the whole
213
+ // document, so a reply this turn never took would be erased by it. Reachable: a turn aborted or
214
+ // thrown during that same tool call never reaches another round top.
215
+ const late = opts.interject?.(session);
216
+ if (late !== undefined) session = late.session;
168
217
  await store.set(session.id, session);
218
+ if (late !== undefined) for (const from of late.merged) yield { type: 'merged', traceId: from, into: traceId };
169
219
  yield terminal;
170
220
  }
171
221
 
@@ -220,6 +270,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
220
270
  // Returns whether anything was claimed. What the caller does with that is the part that legitimately
221
271
  // differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
222
272
  const deferred = screen.deferred;
273
+
274
+ // Re-resolved at the top of every round (below), so a tool defined in one round is callable in the next.
275
+ const turnToolSources = screen.tools;
276
+ const turnToolWarned = new Set<string>();
277
+ let turnTools = new Map<string, Tool>();
223
278
  async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
224
279
  if (deferred.length === 0) return false;
225
280
  const ephemeralC: MessageContent[] = [];
@@ -296,11 +351,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
296
351
  }
297
352
  round += 1;
298
353
 
354
+ const interjected = opts.interject?.(session);
355
+ if (interjected !== undefined) {
356
+ session = interjected.session;
357
+ for (const from of interjected.merged) yield { type: 'merged', traceId: from, into: traceId };
358
+ }
359
+
299
360
  // A raced screen verdict that already fired (settled during setup, or while a previous tool round
300
361
  // ran): fold its correction in before generating, so this call is informed directly rather than
301
362
  // discarded. This is the no-waste path — the verdict landed before we spent any tokens.
302
363
  yield* claimVerdicts();
303
364
 
365
+ if (turnToolSources.length > 0) {
366
+ turnTools = await resolveTurnTools(turnToolSources, session,
367
+ name => tools.has(name) || (opts.toolRegistry?.resolve(name) ?? null) !== null, turnToolWarned);
368
+ }
369
+
304
370
  const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
305
371
  truncated?: { bytes: number; stopReason?: string } }> = [];
306
372
  const assistantParts: MessageContent[] = [];
@@ -357,10 +423,13 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
357
423
  // tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
358
424
  // Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
359
425
  // that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
360
- const advertised = opts.toolPresenter
426
+ // Turn tools go AFTER the presenter, never through it: they are few, made for this session, and
427
+ // absent from the registry a presenter's own search reads — one it windowed out could not come back.
428
+ const presented = opts.toolPresenter
361
429
  ? await withUsageSite({ kind: 'round', round },
362
430
  () => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
363
431
  : [...tools.values()];
432
+ const advertised = turnTools.size > 0 ? [...presented, ...turnTools.values()] : presented;
364
433
  // Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
365
434
  // to cancel the in-flight request and stop backend generation, without aborting the whole turn.
366
435
  const callAc = new AbortController();
@@ -501,7 +570,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
501
570
  for (const [callIdx, tc] of pendingCalls.entries()) {
502
571
  yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
503
572
 
504
- const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name);
573
+ const tool = (opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name))
574
+ ?? turnTools.get(tc.name);
505
575
  if (!tool) {
506
576
  const err = { error: `Unknown tool: ${tc.name}` };
507
577
  toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
@@ -602,6 +672,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
602
672
  ...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
603
673
  ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
604
674
  ...(opts.files !== undefined ? { files: opts.files } : {}),
675
+ ...(opts.appender !== undefined ? { appender: opts.appender } : {}),
676
+ ...(turnTools.size > 0 ? { turnTools } : {}),
605
677
  };
606
678
 
607
679
  // Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
@@ -0,0 +1,77 @@
1
+ import type { AppendMessage, AppendResult, Notifier, Session, SessionAppender, SessionRunner, Store } from './types.js';
2
+ import { tryCurrentPrincipal, isReadOnlyError, SessionAppendKind } from '@matatbread/matbot-plugin-api';
3
+ import { appendMessage, createMessage } from './session.js';
4
+
5
+ /**
6
+ * The host's {@link SessionAppender}: an append is checked now, and written by the session's runner once no
7
+ * turn holds the session (`SessionRunner.write`).
8
+ *
9
+ * Checked now so that a session that does not exist is reported to a caller who can still act on it. When
10
+ * no turn holds the session the write runs at once and the append settles once it has, so a write that
11
+ * cannot land (the session went, or it is shared in read-only) is reported too. When a turn holds it, the
12
+ * append settles on acceptance: the caller may be that very turn, which the write is waiting for.
13
+ *
14
+ * Seeded in every process, and refuses in a background job — whose store is the parent's medium with none
15
+ * of the parent's turns behind it — until the job registers one that forwards to its parent.
16
+ */
17
+ export function createSessionAppender(deps: {
18
+ sessions: () => Store<Session> | undefined;
19
+ run: () => SessionRunner | undefined;
20
+ notifier: () => Notifier;
21
+ isSubAgent: () => boolean;
22
+ }): SessionAppender {
23
+ return {
24
+ async append(sessionId, messages): Promise<AppendResult> {
25
+ if (deps.isSubAgent()) {
26
+ throw new Error('This background job has no way to reach its parent\'s sessions, so it cannot append to one.');
27
+ }
28
+ if (sessionId === undefined) throw new Error('No session was named to append to.');
29
+ if (messages.length === 0) throw new Error('Nothing to append.');
30
+ const store = deps.sessions();
31
+ if (store === undefined) throw new Error('No session store is available.');
32
+ const run = deps.run();
33
+ if (run === undefined) throw new Error('No session runner is available to write the session.');
34
+ if (await store.get(sessionId) === null) throw new Error(`Session "${sessionId}" not found.`);
35
+
36
+ const principal = tryCurrentPrincipal();
37
+ const traceId = crypto.randomUUID();
38
+ const built = messages.map((m: AppendMessage) => createMessage({
39
+ role: m.role, content: m.content, traceId,
40
+ ...(m.metadata !== undefined ? { metadata: m.metadata } : {}),
41
+ }));
42
+ const messageIds = built.map(m => m.id);
43
+
44
+ let landed = false;
45
+ let dropped: string | undefined;
46
+ const write = run.write(sessionId, async () => {
47
+ const current = await store.get(sessionId);
48
+ if (current === null) {
49
+ dropped = `Session "${sessionId}" no longer exists.`;
50
+ console.error(`[matbot] append to session "${sessionId}" dropped: the session no longer exists.`);
51
+ return true;
52
+ }
53
+ try {
54
+ if (!(await store.cas(sessionId, current.version, built.reduce(appendMessage, current))).ok) return false;
55
+ } catch (e) {
56
+ // Readable, so accepted; not writable — a session shared in read-only.
57
+ if (!isReadOnlyError(e)) throw e;
58
+ dropped = e.message;
59
+ console.error(`[matbot] append to session "${sessionId}" dropped: ${e.message}`);
60
+ return true;
61
+ }
62
+ deps.notifier().notify({
63
+ kind: SessionAppendKind, source: 'append', sessionId, messageIds,
64
+ ...(principal !== undefined ? { principal } : {}),
65
+ });
66
+ landed = true;
67
+ return true;
68
+ }, `[matbot] append to session "${sessionId}" lost to repeated concurrent writes.`);
69
+
70
+ if (!write.deferred) {
71
+ await write.done;
72
+ if (!landed) throw new Error(`The append was not written: ${dropped ?? 'it failed, or lost to repeated concurrent writes (see the log).'}`);
73
+ }
74
+ return { sessionId, messageIds, ...(write.deferred ? { deferred: true as const } : {}) };
75
+ },
76
+ };
77
+ }