@matatbread/matbot-core 0.4.6 → 0.4.8

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.6",
7
+ "version": "0.4.8",
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.6"
38
+ "@matatbread/matbot-plugin-api": "^0.4.8"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/about.ts CHANGED
@@ -1,24 +1,45 @@
1
- import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext } from '@matatbread/matbot-plugin-api';
1
+ import type {
2
+ Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
3
+ } from '@matatbread/matbot-plugin-api';
2
4
 
3
5
  declare module '@matatbread/matbot-plugin-api' {
4
6
  interface ToolContracts {
5
- about_matbot: ToolContract<{ version: string; about: string, currentProvider: string | undefined }, Record<string, never>>;
7
+ about_matbot: ToolContract<{
8
+ version: string;
9
+ about: string;
10
+ currentProvider: string | undefined;
11
+ /** The system prompt in force, exactly as the model receives it — the joined `systemContext`
12
+ * texts. `null` when no contributor produced anything (there is then no system message at all). */
13
+ systemPrompt: string | null;
14
+ /** The same text kept apart and attributed, so a contribution can be traced to its plugin. */
15
+ systemContext: SystemContextPart[];
16
+ }, Record<string, never>>;
6
17
  }
7
18
  }
8
19
 
9
20
  const ABOUT = 'Matbot composable LLM harness';
10
21
 
11
22
  /**
12
- * Reports what the model is running: the harness `version` and a one-line description. The harness is
13
- * not a plugin (so it has no row in `plugin list`), and its version is a singleton fact rather than a
14
- * per-plugin one — hence a dedicated tool. The factory lives in core (like `single_turn`) because it is
15
- * the one cross-runtime home both the node app and the browser bundle register it from, each passing its
16
- * own app package version.
23
+ * Reports what the model is running: the harness `version`, a one-line description, the provider
24
+ * driving this turn, and the system prompt in force. The harness is not a plugin (so it has no row in
25
+ * `plugin list`), and its version is a singleton fact rather than a per-plugin one — hence a dedicated
26
+ * tool. The factory lives in core (like `single_turn`) because it is the one cross-runtime home both the
27
+ * node app and the browser bundle register it from, each passing its own app package version.
28
+ *
29
+ * The system prompt is REBUILT here rather than read back: it is assembled once per submit and never
30
+ * persisted (see `runner`'s `system-context` event), so there is nothing on the session to read. Rebuilt
31
+ * against this turn's session it is the same text the turn was given, unless a contributor's own source
32
+ * changed mid-turn — a skill added, a plugin loaded — in which case it correctly reports what the NEXT
33
+ * call will carry.
17
34
  */
18
- export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about_matbot'>> {
35
+ export function createAboutMatbotTool(version: string, services: MatbotMachine): Tool<ToolResultOf<'about_matbot'>> {
19
36
  const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
20
37
  async *execute(_input: unknown, ctx: ToolContext) {
21
- yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider } };
38
+ const systemContext = await services.systemContext.parts({ session: ctx.session, signal: ctx.signal });
39
+ // Joined exactly as the registry joins it for the wire — one source, so the reported prompt and the
40
+ // sent prompt cannot differ in their separators.
41
+ const systemPrompt = systemContext.length > 0 ? systemContext.map(p => p.text).join('\n\n') : null;
42
+ yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider, systemPrompt, systemContext } };
22
43
  },
23
44
  };
24
45
 
@@ -26,9 +47,12 @@ export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about
26
47
  name: 'about_matbot',
27
48
  description:
28
49
  'Report what you are running right now: the current model / LLM and provider profile powering THIS ' +
29
- 'conversation, plus the matbot harness version and a one-line description. Use it whenever asked ' +
30
- '"what model / LLM are you using?", "which provider is this?", what version of matbot this is, or for ' +
31
- 'an "about" of the harness itself — distinct from the `provider` tool (which lists all configured ' +
50
+ 'conversation, the matbot harness version and a one-line description, and the SYSTEM PROMPT in force ' +
51
+ 'for this conversation — both as one string (`systemPrompt`, exactly what you were given) and broken ' +
52
+ 'down per contributing plugin (`systemContext`), which is how to answer "why are you being told that?" ' +
53
+ 'or "what is in your system prompt?". Use it whenever asked "what model / LLM are you using?", "which ' +
54
+ 'provider is this?", what version of matbot this is, what your instructions or system prompt are, or ' +
55
+ 'for an "about" of the harness itself — distinct from the `provider` tool (which lists all configured ' +
32
56
  'provider profiles) and the `plugin` tool (which lists loaded plugins).',
33
57
  inputSchema: { type: 'object', properties: {} },
34
58
  executor,
package/src/index.ts CHANGED
@@ -10,11 +10,12 @@ export {
10
10
  notAPluginError, isNotAPluginError,
11
11
  promptCancelledError, isPromptCancelledError,
12
12
  readOnlyError, isReadOnlyError,
13
+ mediaRejectedError, isMediaRejectedError,
13
14
  StoreQueryError,
14
15
  } from '@matatbread/matbot-plugin-api';
15
16
  export type {
16
17
  MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
17
- MatbotErrorKind,
18
+ MediaRejectedError, MatbotErrorKind,
18
19
  } from '@matatbread/matbot-plugin-api';
19
20
  export { applyCreateSecret } from '@matatbread/matbot-plugin-api';
20
21
  export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
@@ -49,6 +50,7 @@ export * from './tool-registry.js';
49
50
  export * from './provider-registry.js';
50
51
  export * from './system-context.js';
51
52
  export * from './single-turn.js';
53
+ export * from './media.js';
52
54
  export * from './about.js';
53
55
  export * from './config/index.js';
54
56
  export * from './security/index.js';
package/src/media.ts ADDED
@@ -0,0 +1,266 @@
1
+ import type { MediaStore, Message, MessageContent, MimeType, UserContent } from './types.js';
2
+ import { collectBytes, decodeBase64, encodeBase64, mediaRejectedError } from '@matatbread/matbot-plugin-api';
3
+
4
+ /**
5
+ * User-supplied session media: the two ends of the one path. Bytes arrive by value at the submission
6
+ * boundary, are written through the `MediaStore` and replaced by a `file-ref` before anything is
7
+ * enqueued ({@link ingestMedia}); the runner resolves those refs back to inline bytes on the *outgoing
8
+ * copy only*, newest-first and inside a byte budget ({@link resolveSessionMedia}).
9
+ *
10
+ * Contrast the tool-media (`model-content`) path, which is wire-only in both directions and never had a
11
+ * store: there the model pulls, here a person pushed. What the two share is the invariant — no base64
12
+ * ever reaches a session document.
13
+ */
14
+
15
+ /** Namespace session media is written under, so it is distinguishable from workspace files sharing a store. */
16
+ export const MEDIA_NAMESPACE = 'session-media';
17
+
18
+ /**
19
+ * How many bytes of media the outgoing copy may carry. Walked newest-first, so the freshest attachment
20
+ * always makes it and an old one falls out; beyond the budget the `file-ref` is left alone and the
21
+ * converters degrade it to `[Attached file: x]` — honest, already written, and the file is still there.
22
+ *
23
+ * A byte budget rather than a turn count because the cost being bounded is denominated in bytes: three
24
+ * turns is a fine window for a thumbnail and a ruinous one for a 40MB PDF, and a count cannot tell them
25
+ * apart. One knob; measure before adding a second.
26
+ */
27
+ export const MEDIA_RESIDENCY_BYTES = 8 * 1024 * 1024;
28
+
29
+ /** Per-file ceiling at the submission boundary. Refusing here — naming the file — is the point: the
30
+ * alternative is a provider 400 part-way through a turn the user already believes was sent.
31
+ *
32
+ * It IS the residency budget rather than a second, larger number, because a file bigger than the
33
+ * window can never be resident: `spent + size > budget` is true on the very first item with
34
+ * `spent === 0`, so such a file would be stored, charged to the session quota, and then degraded to
35
+ * `[Attached file: x]` on every turn for the rest of the session — accepted, permanently invisible,
36
+ * and with no boundary having said a word. Deriving it makes that state unrepresentable rather than a
37
+ * second check to remember; raise the window and the cap follows. */
38
+ export const MAX_MEDIA_BYTES_PER_FILE = MEDIA_RESIDENCY_BYTES;
39
+
40
+ /** Per-session total, derived by summing what the store already holds for the session rather than kept
41
+ * as a counter: a counter is wrong after a restart, a swap, or anything deleting a file behind it. */
42
+ export const MAX_MEDIA_BYTES_PER_SESSION = 50 * 1024 * 1024;
43
+
44
+ const INLINE_ARMS = new Set(['image', 'document', 'audio']);
45
+
46
+ /**
47
+ * Image types a vision endpoint actually decodes.
48
+ *
49
+ * `image/*` is the one prefix that cannot be admitted by prefix alone: a provider TRIES to decode
50
+ * whatever the image arm carries and 400s on what it cannot, where an unrecognised document or audio
51
+ * type degrades to a text note instead. So HEIC (what an iPhone camera roll hands back, and what a
52
+ * `<input type="file">` with no `accept` will happily offer), SVG (XML — `read` gives the source, which
53
+ * is the better answer anyway), BMP and TIFF have to be refused at the boundary rather than routed to
54
+ * an arm that will fail: by the time the 400 arrives the `file-ref` is in persisted history, where it
55
+ * resolves into every subsequent outgoing copy and fails the session for good.
56
+ *
57
+ * Same list and same reasoning as `workspace`'s `showArm`. The two stay separate because they answer
58
+ * for separate stores; if a third ever needs it, this is the one to hoist.
59
+ */
60
+ const DECODABLE_IMAGE: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
61
+
62
+ /**
63
+ * Which inline arm a stored file comes back as, or null for a type that must not be sent at all. What
64
+ * each adapter then does with an arm differs: google inlines all three; anthropic inlines images and
65
+ * PDF/text documents and degrades the rest to a note; openai-compat inlines images only. A degraded arm
66
+ * becomes a text note, never nothing — which is precisely why only `image/*` needs gating.
67
+ *
68
+ * Exported because `single_turn` resolves attachments through the same mapping: a mime-routing rule
69
+ * with two copies is one that gets fixed in one of them.
70
+ */
71
+ export function armFor(mimeType: string): 'image' | 'audio' | 'document' | null {
72
+ const base = mimeType.split(';')[0]!.trim().toLowerCase();
73
+ if (base.startsWith('image/')) return DECODABLE_IMAGE.has(base) ? 'image' : null;
74
+ if (base.startsWith('audio/')) return 'audio';
75
+ return 'document';
76
+ }
77
+
78
+ /**
79
+ * Leading bytes that unambiguously identify a format. Only the types where a mismatch is a GUARANTEED
80
+ * provider rejection — a model endpoint decodes these itself and 400s on anything it cannot parse.
81
+ * Nothing else is sniffed: for `text/*`, audio, or an octet-stream we genuinely do not know, and
82
+ * guessing would refuse valid files.
83
+ */
84
+ const MAGIC: ReadonlyArray<{ mime: string; at: number; sig: readonly number[] }> = [
85
+ { mime: 'image/png', at: 0, sig: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
86
+ { mime: 'image/jpeg', at: 0, sig: [0xff, 0xd8, 0xff] },
87
+ { mime: 'image/gif', at: 0, sig: [0x47, 0x49, 0x46, 0x38] }, // "GIF8"
88
+ { mime: 'image/webp', at: 0, sig: [0x52, 0x49, 0x46, 0x46] }, // "RIFF" (…WEBP at 8)
89
+ { mime: 'image/webp', at: 8, sig: [0x57, 0x45, 0x42, 0x50] }, // "WEBP"
90
+ { mime: 'application/pdf', at: 0, sig: [0x25, 0x50, 0x44, 0x46] }, // "%PDF"
91
+ ];
92
+
93
+ /**
94
+ * Does this file's content match the type it claims? Returns false only when we KNOW it does not.
95
+ *
96
+ * This is the boundary doing the one check it can: a provider decodes these formats itself and 400s
97
+ * on anything it cannot parse, and a 400 arrives mid-turn — after the message is persisted, with the
98
+ * `file-ref` now in history where it resolves into EVERY subsequent outgoing copy and fails the
99
+ * session for good. A renamed file, a truncated upload or a placeholder is caught here instead, while
100
+ * refusing still costs the user nothing but re-picking the file.
101
+ *
102
+ * It is not a validity check: a well-formed header on a structurally broken document still passes,
103
+ * because only the provider can know that.
104
+ */
105
+ function contentMatchesType(bytes: Uint8Array, mimeType: string): boolean {
106
+ const base = mimeType.split(';')[0]!.trim().toLowerCase();
107
+ const rules = MAGIC.filter(m => m.mime === base);
108
+ if (rules.length === 0) return true; // nothing known about this type — don't guess
109
+ return rules.every(r =>
110
+ bytes.length >= r.at + r.sig.length && r.sig.every((b, i) => bytes[r.at + i] === b));
111
+ }
112
+
113
+ function describe(name: string | undefined, mimeType: string): string {
114
+ return name ?? `attachment.${mimeType.split('/')[1] ?? 'bin'}`;
115
+ }
116
+
117
+ function humanBytes(n: number): string {
118
+ if (n < 1024) return `${n} B`;
119
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
120
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
121
+ }
122
+
123
+ /**
124
+ * The submission boundary, in one place so every frontend inherits it: rewrite each by-value media arm
125
+ * to a `file-ref` against the `MediaStore`, leave text untouched, and VET each ref a caller supplied
126
+ * itself.
127
+ *
128
+ * A text-only submission never touches the store, so a deployment with no `MediaStore` registered is
129
+ * completely unaffected — it only hears about one when someone actually attaches something, and then it
130
+ * is told why rather than silently dropping it.
131
+ *
132
+ * Vetting the refs belongs here and nowhere else. A submission crosses a wire boundary, so a `fileId`
133
+ * is the client's word about which bytes to send, and `resolveSessionMedia` inlines whatever it is
134
+ * handed — no session, no namespace, no `allowed` consulted — which made "describe this" a read of any
135
+ * file in the store. This is the only point that knows the session AND has a channel to say no: the
136
+ * runner could only drop the block silently, mid-turn, for something the user watched itself attach.
137
+ */
138
+ export async function ingestMedia(
139
+ content: readonly UserContent[],
140
+ sessionId: string,
141
+ store: MediaStore | undefined,
142
+ ): Promise<MessageContent[]> {
143
+ // A ref counts: it is the arm that needs CHECKING rather than rewriting, and one cannot be honoured
144
+ // without a store any more than a by-value arm can be stored without one.
145
+ if (!content.some(c => INLINE_ARMS.has(c.type) || c.type === 'file-ref')) return content as MessageContent[];
146
+
147
+ if (store === undefined) {
148
+ throw mediaRejectedError('no-store',
149
+ 'This deployment cannot accept attachments: no MediaStore is registered, so there is nowhere to ' +
150
+ 'put the bytes. Send the message as text, or register a media store.');
151
+ }
152
+
153
+ // Derived, not counted — see MAX_MEDIA_BYTES_PER_SESSION. One listing for the whole batch.
154
+ let sessionBytes = 0;
155
+ for await (const held of store.list({ sessionId })) sessionBytes += held.size;
156
+
157
+ const out: MessageContent[] = [];
158
+ for (const c of content) {
159
+ if (c.type === 'file-ref') {
160
+ // Ownership, not `allowed`: every put below sets `allowed: true`, so the flag the HTTP read route
161
+ // gates on cannot separate this session's media from another's. `sessionId` + namespace can, and
162
+ // together they are strictly the stronger check. Reported as unknown rather than forbidden, for
163
+ // the reason `GET /media/:id` gives: a refusal must not confirm that an id exists.
164
+ const held = await store.get(c.fileId).catch(() => null);
165
+ if (held === null || held.sessionId !== sessionId || held.namespace !== MEDIA_NAMESPACE) {
166
+ throw mediaRejectedError('unknown-ref',
167
+ `"${c.name}" is not media attached to this session. Attach the file itself, rather than a ` +
168
+ 'reference to one.', c.name);
169
+ }
170
+ out.push(c as MessageContent);
171
+ continue;
172
+ }
173
+ if (!INLINE_ARMS.has(c.type)) { out.push(c as MessageContent); continue; }
174
+ const arm = c as Extract<UserContent, { data: string }>;
175
+ const name = describe(arm.name, arm.mimeType);
176
+
177
+ // Before the decode, because it needs no bytes and this is the cheapest of the three refusals.
178
+ if (armFor(arm.mimeType) === null) {
179
+ throw mediaRejectedError('unsupported-type',
180
+ `"${name}" is ${arm.mimeType}, which no model endpoint decodes — sending it would fail the ` +
181
+ 'whole conversation, not just this message. Convert it to PNG or JPEG and attach that.', name);
182
+ }
183
+
184
+ let bytes: Uint8Array;
185
+ try { bytes = decodeBase64(arm.data); }
186
+ catch { throw mediaRejectedError('unreadable', `"${name}" is not valid base64 and could not be read.`, name); }
187
+
188
+ if (!contentMatchesType(bytes, arm.mimeType)) {
189
+ throw mediaRejectedError('unreadable',
190
+ `"${name}" does not contain valid ${arm.mimeType} data — the file may be corrupt, truncated, or ` +
191
+ `misnamed. Sending it would fail the whole conversation, not just this message.`, name);
192
+ }
193
+ if (bytes.byteLength > MAX_MEDIA_BYTES_PER_FILE) {
194
+ throw mediaRejectedError('too-large',
195
+ `"${name}" is ${humanBytes(bytes.byteLength)}, over the ${humanBytes(MAX_MEDIA_BYTES_PER_FILE)} per-file limit.`, name);
196
+ }
197
+ sessionBytes += bytes.byteLength;
198
+ if (sessionBytes > MAX_MEDIA_BYTES_PER_SESSION) {
199
+ throw mediaRejectedError('session-quota',
200
+ `Attaching "${name}" (${humanBytes(bytes.byteLength)}) would take this session over its ` +
201
+ `${humanBytes(MAX_MEDIA_BYTES_PER_SESSION)} media limit. Start a new session, or attach less.`, name);
202
+ }
203
+
204
+ // Anonymous put: a UUID id, so two uploads of "photo.jpg" in one session are two files rather than
205
+ // an upsert over each other. The display name rides on the `file-ref` block instead, which every
206
+ // FileStore therefore preserves without having to.
207
+ const handle = await store.put(undefined, arm.mimeType, (async function *() { yield bytes; })(),
208
+ { sessionId, namespace: MEDIA_NAMESPACE, allowed: true });
209
+ out.push({ type: 'file-ref', fileId: handle.id, name, mimeType: arm.mimeType });
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Turn-side resolution: which `file-ref`s get swapped for inline bytes on the outgoing copy. Returns
216
+ * message-id → (file-id → the inline arm to stand in for that ref); a message with nothing resolvable
217
+ * is absent from it, and so is a ref left outside the budget.
218
+ *
219
+ * Keyed per REF rather than returning each message's finished content array, because the array is not
220
+ * final when this runs: a raced `screen` verdict folds durable blocks onto the user message *inside* the
221
+ * round loop, and a caller splicing a whole array resolved at turn start would drop them from every
222
+ * later round — persisted and on screen, but never sent. Handing back the substitutions instead leaves
223
+ * the caller mapping blocks it still owns.
224
+ *
225
+ * Computed ONCE per turn rather than per round: the bytes cannot change mid-turn, and re-deciding each
226
+ * round would let a message drop out of the window between two provider calls — busting the prompt
227
+ * cache and leaving the model referring to something no longer there.
228
+ */
229
+ export async function resolveSessionMedia(
230
+ messages: readonly Message[],
231
+ store: MediaStore | undefined,
232
+ budget = MEDIA_RESIDENCY_BYTES,
233
+ signal?: AbortSignal,
234
+ ): Promise<Map<string, Map<string, MessageContent>>> {
235
+ const resolved = new Map<string, Map<string, MessageContent>>();
236
+ if (store === undefined) return resolved;
237
+
238
+ let spent = 0;
239
+ // Newest-first: the attachment the user is talking about right now is the one that must survive.
240
+ for (let i = messages.length - 1; i >= 0 && spent < budget; i--) {
241
+ const msg = messages[i]!;
242
+ if (!msg.content.some(c => c.type === 'file-ref')) continue;
243
+
244
+ const byFileId = new Map<string, MessageContent>();
245
+ for (const c of msg.content) {
246
+ if (c.type !== 'file-ref') continue;
247
+ // Left out of the map, the ref stays exactly as it is — the converters already render it as
248
+ // `[Attached file: x]`, which is true and the file is still fetchable by name. Three ways to
249
+ // land there: a type no endpoint decodes, a ref the store no longer has, and the budget.
250
+ const arm = armFor(c.mimeType);
251
+ if (arm === null) continue;
252
+ // One resolution per id: the same ref twice in a message must not be paid for twice, and both
253
+ // blocks render from the one entry.
254
+ if (byFileId.has(c.fileId)) continue;
255
+ const handle = spent < budget ? await store.get(c.fileId).catch(() => null) : null;
256
+ if (handle === null || spent + handle.size > budget) continue;
257
+ spent += handle.size;
258
+ const data = encodeBase64(await collectBytes(handle.stream(signal)));
259
+ byFileId.set(c.fileId, arm === 'document'
260
+ ? { type: 'document', data, mimeType: c.mimeType as MimeType, name: c.name }
261
+ : { type: arm, data, mimeType: c.mimeType as MimeType });
262
+ }
263
+ if (byFileId.size > 0) resolved.set(msg.id, byFileId);
264
+ }
265
+ return resolved;
266
+ }
package/src/registry.ts CHANGED
@@ -377,6 +377,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
377
377
  },
378
378
  removeByPlugin: (name: string) => services.systemContext.removeByPlugin(name),
379
379
  build: (ctx) => services.systemContext.build(ctx),
380
+ parts: (ctx) => services.systemContext.parts(ctx),
380
381
  },
381
382
  async register(key, svc) {
382
383
  const keys = state.serviceKeys.get(plugin.name) ?? [];
package/src/runner.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
3
3
  TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
- Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
4
+ Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn, FormField,
5
5
  } from './types.js';
6
6
  import type { MatbotPlugin } from './plugin.js';
7
7
  import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
@@ -10,6 +10,7 @@ import { HookRegistry } from './hooks.js';
10
10
  import { appendMessage, createMessage } from './session.js';
11
11
  import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
12
12
  import { addUsage } from './usage.js';
13
+ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
13
14
 
14
15
  // Establish a usage call site across an async iteration. Wrapping the *loop* would not work: this
15
16
  // runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
@@ -79,6 +80,10 @@ export interface RunSessionOpts {
79
80
  workdir?: string;
80
81
  configPath?: string;
81
82
  files?: FileStore;
83
+ /** Where user-attached session media lives. Resolved once per turn into the outgoing copy, newest-first
84
+ * and inside {@link MEDIA_RESIDENCY_BYTES}; absent ⇒ every `file-ref` stays a ref and the converters
85
+ * degrade it. Never read for anything else — the runner does not otherwise touch a file. */
86
+ mediaStore?: MediaStore;
82
87
  /** Supply a prompt implementation to allow tools to ask interactive questions. */
83
88
  prompt?: PromptFn;
84
89
  /**
@@ -228,6 +233,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
228
233
  // Turn-scoped and wire-only: it is spliced into the outgoing copy below and dies with this call.
229
234
  const attachments = new Map<string, Message>();
230
235
 
236
+ // User-attached media, keyed by the message whose `file-ref` blocks it replaces. The mirror of
237
+ // `attachments` — same splice, different source and different placement: tool media is *pinned after*
238
+ // the tool message it answers, session media is swapped *in place*, so the model reads the image where
239
+ // the person actually put it. Resolved once here, not per round: the bytes cannot change mid-turn, and
240
+ // re-deciding each round could drop a message out of the byte budget between two calls — busting the
241
+ // prompt cache and leaving the model referring to something no longer there.
242
+ const sessionMedia = await resolveSessionMedia(session.messages, opts.mediaStore, MEDIA_RESIDENCY_BYTES, signal);
243
+
231
244
  for (;;) {
232
245
  // Respect an abort that arrived between turns (e.g. during tool execution).
233
246
  if (signal.aborted) {
@@ -283,10 +296,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
283
296
  // message can be a marker (e.g. a retract-redo leaves the retraction marker as the tail) — folding
284
297
  // there would drop the ephemeral with it. -1 ⇒ nothing to fold onto (no non-marker history).
285
298
  const foldIdx = ephemeral.length > 0 ? session.messages.findLastIndex(m => m.role !== 'marker') : -1;
286
- const history = foldIdx >= 0 || attachments.size > 0
299
+ const history = foldIdx >= 0 || attachments.size > 0 || sessionMedia.size > 0
287
300
  ? session.messages.flatMap((m, i) => {
288
- const folded = i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m;
289
- const media = attachments.get(m.id);
301
+ // Per BLOCK, never the whole array: `sessionMedia` was resolved once before this loop, and a
302
+ // raced verdict's `foldOntoUserTurn` (claimVerdicts, above) extends this same user message
303
+ // mid-turn. Substituting the array would put back the pre-fold copy on every later round —
304
+ // dropping durable robo-user blocks from the wire while leaving them persisted and on screen.
305
+ const refs = sessionMedia.get(m.id);
306
+ const inlined = refs
307
+ ? { ...m, content: m.content.map(c => (c.type === 'file-ref' ? refs.get(c.fileId) ?? c : c)) }
308
+ : m;
309
+ const folded = i === foldIdx ? { ...inlined, content: [...inlined.content, ...ephemeral] } : inlined;
310
+ const media = attachments.get(m.id);
290
311
  return media ? [folded, media] : [folded];
291
312
  })
292
313
  : session.messages;
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
3
- Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
3
+ Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, MediaStore, UserContent, Tool,
4
4
  ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
5
5
  SteeringPolicy, SteeringDecision, TurnEntry,
6
6
  } from './types.js';
@@ -11,6 +11,7 @@ import { appendMessage, createMessage } from './session.js';
11
11
  import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
12
12
  import { machineBusy, withUsageScope } from '@matatbread/matbot-plugin-api/host';
13
13
  import { runSession } from './runner.js';
14
+ import { ingestMedia } from './media.js';
14
15
 
15
16
  export interface SessionRunnerDeps {
16
17
  store: Store<Session>;
@@ -28,6 +29,10 @@ export interface SessionRunnerDeps {
28
29
  // disposed (queue vs interrupt) and supplies an interrupt's continuation nudge. Absent ⇒ the runner's
29
30
  // own defaults (DEFAULT_STEERING_POLICY / DEFAULT_STEER_NUDGE).
30
31
  steeringPolicy?: () => SteeringPolicy | undefined;
32
+ // Resolved live (a host registers its boot default, a plugin may replace it): where a submission's
33
+ // by-value media is written at the boundary, and where the runner reads it back from. Absent ⇒ an
34
+ // attachment is refused with a reason and a stored `file-ref` degrades to the converters' text note.
35
+ mediaStore?: () => MediaStore | undefined;
31
36
  hooks?: HookRegistry;
32
37
  systemContext?: SystemContextRegistry;
33
38
  vault?: Vault;
@@ -306,9 +311,15 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
306
311
  // A redo re-runs the existing committed user turn — no title derivation, no new user message.
307
312
  if (head.redo === undefined) {
308
313
  if (!session.title && !session.messages.some(m => m.role === 'user')) {
314
+ // Falls back to the attachment names when the turn has no words of its own: an image-only
315
+ // first turn is a real submission, and got no title at all — which reads in the session
316
+ // list as a session that failed to start.
309
317
  const text = content
310
318
  .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
311
- .map(c => c.text).join(' ').trim();
319
+ .map(c => c.text).join(' ').trim()
320
+ || content
321
+ .filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
322
+ .map(c => c.name).join(', ');
312
323
  if (text) {
313
324
  const words = text.split(/\s+/).slice(0, 8).join(' ');
314
325
  session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
@@ -351,6 +362,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
351
362
  // inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
352
363
  const wire = await deps.toolTypeIndex?.()?.wireContracts();
353
364
  const toolPresenter = deps.toolPresenter?.();
365
+ const mediaStore = deps.mediaStore?.();
354
366
  const toolMap = deps.tools !== undefined
355
367
  ? new Map<string, Tool>(deps.tools.list().map(t => {
356
368
  const wc = wire?.[t.name];
@@ -400,6 +412,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
400
412
  ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
401
413
  ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
402
414
  ...(deps.files !== undefined ? { files: deps.files } : {}),
415
+ ...(mediaStore !== undefined ? { mediaStore } : {}),
403
416
  ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
404
417
  ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
405
418
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
@@ -455,7 +468,11 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
455
468
  // event and the persisted user message the pump builds from this item.
456
469
  for (const raw of resubmits.reverse()) {
457
470
  const rt = crypto.randomUUID();
458
- const content = raw.map(c => (c.type === 'text' ? { ...c, origin: 'robo' as const } : c));
471
+ // Every block, not just text: `origin` is authorship, orthogonal to what the block
472
+ // carries, and a resubmission that attached an image was silently presenting it as the
473
+ // user's own. The arms a stamp would be wrong on don't occur here — a hook returns
474
+ // content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
475
+ const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
459
476
  s.queue.unshift({
460
477
  traceId: rt,
461
478
  rootTraceId: head.rootTraceId,
@@ -565,9 +582,26 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
565
582
  }
566
583
  s = stateFor(opts.sessionId);
567
584
  traceId = opts.traceId ?? crypto.randomUUID();
568
- const { content, provider, principal } = opts;
585
+ const { provider, principal } = opts;
569
586
  const mode = opts.mode ?? 'queue';
570
587
 
588
+ // Which turn a steer is FOR is fixed here, before the first await, and checked again before the
589
+ // abort. `s.running` alone cannot answer it: it is true across the pump's whole queue, so it
590
+ // says "something is running", not "still the one you meant". Every await below can outlive a
591
+ // turn — the classifier and the nudge could already, and media ingestion can hold for seconds
592
+ // on a large attachment — after which aborting on `s.running` kills whatever is running NOW,
593
+ // a turn the user never saw, and names the wrong `interruptedTraceId` on the way.
594
+ const steerTarget = s.runningTraceId;
595
+
596
+ // The media boundary, before anything else this submission touches: by-value attachments are
597
+ // written through the MediaStore and become `file-ref`s here, so nothing downstream — the
598
+ // steering classifier, the `queued`/`steer` events, the persisted user message — ever sees
599
+ // bytes. One place, and every frontend inherits it, which is what lets Telegram (where bytes
600
+ // arrive WITH the message and there is no upload-in-advance leg) work with no code of its own.
601
+ // Throws MediaRejectedError on a refusal; nothing is enqueued, so the caller has nothing to
602
+ // undo — it just tells the user which file and why.
603
+ const content = await ingestMedia(opts.content, opts.sessionId, deps.mediaStore?.());
604
+
571
605
  // Decide interrupt vs queue for a submission arriving mid-turn. Steering is only meaningful
572
606
  // while a turn is running; with nothing running it enqueues and runs next regardless of mode.
573
607
  // The decision — and, on interrupt, the abort+unshift — happen here synchronously against
@@ -589,8 +623,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
589
623
  ? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
590
624
  : DEFAULT_STEERING_POLICY;
591
625
 
592
- // Any await above may have outlived the running turn; only interrupt one still live.
593
- if (decision === 'interrupt' && s.running) {
626
+ // Any await above may have outlived the turn this steer was for: interrupt only if THAT one
627
+ // is still the running turn. Otherwise fall through and queue — the turn they were steering
628
+ // has already committed, which makes this an ordinary follow-up rather than an interruption.
629
+ if (decision === 'interrupt' && s.running && s.runningTraceId === steerTarget) {
594
630
  const nudge = policy?.nudge !== undefined && committed
595
631
  ? policy.nudge({ session: committed, steer: content })
596
632
  : DEFAULT_STEER_NUDGE;
@@ -1,11 +1,50 @@
1
- import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
1
+ import type {
2
+ Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine, MimeType, UserContent, FileStore,
3
+ } from '@matatbread/matbot-plugin-api';
4
+ import { collectBytes, encodeBase64 } from '@matatbread/matbot-plugin-api';
5
+ // The arm mapping is the runner's, not a copy of it: it decides which types may be sent at all, and a
6
+ // rule with two copies is one that gets fixed in one of them.
7
+ import { armFor } from './media.js';
2
8
 
3
9
  declare module '@matatbread/matbot-plugin-api' {
4
10
  interface ToolContracts {
5
- single_turn: ToolContract<{ text: string }, { provider?: string; prompt: string; system?: string }>; // the consulted provider's reply text
11
+ single_turn: ToolContract<{ text: string }, { provider?: string; prompt: string; system?: string; attach?: string[] }>; // the consulted provider's reply text
6
12
  }
7
13
  }
8
14
 
15
+ /**
16
+ * Resolve `attach` names/ids to inline media for the one-shot. Looked up by **id first, then name**,
17
+ * across the MediaStore and then the turn's own file store: an id is unambiguous and a name is what a
18
+ * model actually has to hand, and both stores are in play because session media and workspace files are
19
+ * separately owned. A one-shot has no session and so no residency window — whatever is named is sent,
20
+ * once, and the caller's own provider limits are the only ceiling.
21
+ */
22
+ async function resolveAttachments(
23
+ names: readonly string[],
24
+ stores: ReadonlyArray<FileStore | undefined>,
25
+ signal: AbortSignal | undefined,
26
+ ): Promise<{ content: UserContent[]; missing: string[]; unsupported: string[] }> {
27
+ const content: UserContent[] = [];
28
+ const missing: string[] = [];
29
+ const unsupported: string[] = [];
30
+ for (const ref of names) {
31
+ let handle = null;
32
+ for (const store of stores) {
33
+ if (store === undefined) continue;
34
+ handle = await store.get(ref).catch(() => null) ?? await store.getByName(ref).catch(() => null);
35
+ if (handle !== null) break;
36
+ }
37
+ if (handle === null) { missing.push(ref); continue; }
38
+ // A type no endpoint decodes is refused, not routed to an arm that will 400 the call. Reported
39
+ // apart from `missing`: "no such file" would send the model hunting for a file it named correctly.
40
+ const arm = armFor(handle.mimeType);
41
+ if (arm === null) { unsupported.push(`${ref} (${handle.mimeType})`); continue; }
42
+ const bytes = await collectBytes(handle.stream(signal));
43
+ content.push({ type: arm, data: encodeBase64(bytes), mimeType: handle.mimeType as MimeType, name: handle.name });
44
+ }
45
+ return { content, missing, unsupported };
46
+ }
47
+
9
48
  /**
10
49
  * Exposes {@link MatbotMachine.singleTurn} to the model: a one-shot completion against a configured
11
50
  * provider, returning its reply. The intended use is consulting another model (e.g. a different-lineage
@@ -21,7 +60,7 @@ declare module '@matatbread/matbot-plugin-api' {
21
60
  export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf<'single_turn'>> {
22
61
  const executor: ToolExecutor<ToolResultOf<'single_turn'>> = {
23
62
  async *execute(input: unknown, ctx: ToolContext) {
24
- const args = input as { provider?: string; prompt?: string; system?: string };
63
+ const args = input as { provider?: string; prompt?: string; system?: string; attach?: unknown };
25
64
  if (typeof args.prompt !== 'string') { yield { type: 'error', message: 'single_turn requires a string "prompt".' }; return; }
26
65
  const provider = args.provider ?? ctx.provider;
27
66
  if (!provider) {
@@ -33,9 +72,29 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
33
72
  yield { type: 'error', message: `Unknown provider "${provider}". Configured providers: ${known}.` };
34
73
  return;
35
74
  }
75
+ // Attachments make the prompt a UserContent[] rather than a string. Media the store doesn't have
76
+ // is REPORTED, not silently dropped: a consulted model answering about a file it never saw is the
77
+ // failure that looks like a bad answer rather than a bad call.
78
+ const attach = Array.isArray(args.attach) ? args.attach.filter((a): a is string => typeof a === 'string') : [];
79
+ let prompt: string | UserContent[] = args.prompt;
80
+ if (attach.length > 0) {
81
+ const { content, missing, unsupported } = await resolveAttachments(attach, [services.MediaStore, ctx.files], ctx.signal);
82
+ if (missing.length > 0) {
83
+ yield { type: 'error', message: `single_turn: no such file(s): ${missing.join(', ')}. Attachments are looked up by store id or by name.` };
84
+ return;
85
+ }
86
+ if (unsupported.length > 0) {
87
+ yield { type: 'error', message:
88
+ `single_turn: no model endpoint decodes ${unsupported.join(', ')}, so sending it would fail ` +
89
+ 'the call. Convert it to PNG or JPEG, or read the file as text and put that in the prompt.' };
90
+ return;
91
+ }
92
+ prompt = [...content, { type: 'text', text: args.prompt }];
93
+ }
94
+
36
95
  const res = await services.singleTurn({
37
96
  provider,
38
- prompt: args.prompt,
97
+ prompt,
39
98
  signal: ctx.signal,
40
99
  ...(typeof args.system === 'string' ? { system: args.system } : {}),
41
100
  });
@@ -53,7 +112,13 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
53
112
  'back its text. Use it to consult a different model — e.g. a second, ' +
54
113
  'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
55
114
  'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
56
- 'a configured provider to switch models (list or add providers with the provider tool).',
115
+ 'a configured provider to switch models (list or add providers with the provider tool). ' +
116
+ '`attach` is an ARRAY OF STORED FILE NAMES OR IDS (e.g. ["chart.png"]) to send alongside the ' +
117
+ 'prompt, for the consulted model to look at. It is not a content type and not a way to forward ' +
118
+ 'media from THIS conversation — you can already see that; only name a file that exists in the ' +
119
+ 'workspace or media store. Whether the consulted provider can read a given file is its business; ' +
120
+ 'one it cannot take degrades to a text note. An image in a format no endpoint decodes (HEIC, ' +
121
+ 'SVG, BMP, TIFF) is refused outright rather than sent — convert it first, or read it as text.',
57
122
  inputSchema: {
58
123
  type: 'object',
59
124
  required: ['prompt'],
@@ -61,6 +126,7 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
61
126
  provider: { type: 'string', description: 'Name of a configured provider to run the completion against. Optional — defaults to the current turn\'s provider.' },
62
127
  prompt: { type: 'string', description: 'The user message to send to that provider.' },
63
128
  system: { type: 'string', description: 'Optional system prompt for the call.' },
129
+ attach: { type: 'array', items: { type: 'string' }, description: 'Optional array of stored FILE NAMES or store ids, e.g. ["diagram.png"]. Each is loaded and sent as inline media (image/document/audio) ahead of the prompt text. Not a content type — naming a file that does not exist is an error, not a request for one.' },
64
130
  },
65
131
  },
66
132
  executor,
@@ -1,4 +1,4 @@
1
- import type { Session, SystemContextContributor, SystemContextRegistry } from './types.js';
1
+ import type { Session, SystemContextContributor, SystemContextPart, SystemContextRegistry } from './types.js';
2
2
 
3
3
  interface TaggedContributor {
4
4
  fn: SystemContextContributor;
@@ -18,9 +18,23 @@ export class SystemContextRegistryImpl implements SystemContextRegistry {
18
18
  }
19
19
  }
20
20
 
21
+ async parts(ctx: { session: Session; signal: AbortSignal }): Promise<SystemContextPart[]> {
22
+ // Zipped, not indexed twice. Re-reading `_contributors` after the await pairs each text with
23
+ // whatever now sits at that index: a plugin unloading mid-await (`removeByPlugin` splices) shifts
24
+ // every survivor onto its predecessor's text and drops the last one, and since `build()` derives
25
+ // from this, that corrupts the prompt actually sent — not merely the breakdown reported.
26
+ const settled = await Promise.all(
27
+ this._contributors.map(async c => ({ c, text: await c.fn(ctx) })));
28
+ return settled.flatMap(({ c, text }) =>
29
+ typeof text === 'string' && text.length > 0
30
+ ? [{ text, ...(c.pluginName !== undefined ? { plugin: c.pluginName } : {}) }]
31
+ : []);
32
+ }
33
+
34
+ // The joined form is derived from the attributed one, so the prompt the model receives and the
35
+ // breakdown `about_matbot` reports cannot drift: there is one traversal and one filter.
21
36
  async build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null> {
22
- const parts = (await Promise.all(this._contributors.map(c => c.fn(ctx))))
23
- .filter((s): s is string => typeof s === 'string' && s.length > 0);
24
- return parts.length > 0 ? parts.join('\n\n') : null;
37
+ const parts = await this.parts(ctx);
38
+ return parts.length > 0 ? parts.map(p => p.text).join('\n\n') : null;
25
39
  }
26
40
  }