talon-agent 3.35.0 → 3.36.0

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.35.0",
3
+ "version": "3.36.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -14,14 +14,18 @@ import type { ChatEntry } from "./chats.js";
14
14
  import type { ClientChat, QueuedMessage } from "./protocol.js";
15
15
  import type { NativeRuntime } from "./runtime.js";
16
16
 
17
- /** Project the stored queue entry to its wire shape (text + attachment flag). */
17
+ /** Project the stored queue entry to its wire shape (text + attachments). */
18
18
  function toQueued(
19
19
  runtime: NativeRuntime,
20
20
  chatId: string,
21
21
  ): QueuedMessage | undefined {
22
22
  const q = runtime.queuedByChat.get(chatId);
23
23
  if (!q) return undefined;
24
- return { text: q.text, hasAttachment: Boolean(q.attachmentPath) };
24
+ return {
25
+ text: q.text,
26
+ hasAttachment: q.attachments.length > 0,
27
+ attachmentCount: q.attachments.length,
28
+ };
25
29
  }
26
30
 
27
31
  export function toClientChat(
@@ -12,6 +12,7 @@ import {
12
12
  BOT_SENDER_ID,
13
13
  USER_SENDER_ID,
14
14
  type ClientButton,
15
+ type ClientAttachment,
15
16
  type ClientMessage,
16
17
  } from "./protocol.js";
17
18
  import type { NativeRuntime } from "./runtime.js";
@@ -104,43 +105,61 @@ export function emitPhoto(
104
105
  return id;
105
106
  }
106
107
 
108
+ /** The sidebar preview for a message whose real content is its files. */
109
+ function attachmentPreview(attachments: ClientAttachment[]): string {
110
+ if (attachments.length === 1) {
111
+ const only = attachments[0]!;
112
+ return only.image ? "[photo]" : `[${only.name}]`;
113
+ }
114
+ return `[${attachments.length} files]`;
115
+ }
116
+
107
117
  /** Persist + broadcast a user message; returns its numeric id so the turn
108
118
  * hands the model that same id (as `[msg_id:N]`) to react/reply to.
109
- * An optional imagePath renders an attached image inline. */
119
+ * Attached files ride along on the message and are persisted with it. */
110
120
  export function emitUser(
111
121
  runtime: NativeRuntime,
112
122
  entry: ChatEntry,
113
123
  text: string,
114
- imagePath?: string,
115
- attachmentPath?: string,
124
+ attachments: ClientAttachment[] = [],
116
125
  ): number {
117
126
  const id = runtime.nextId();
118
127
  const ts = Date.now();
128
+ // `imagePath` stays in sync with the first image so clients written against
129
+ // the single-image shape keep rendering it.
130
+ const firstImage = attachments.find((a) => a.image);
119
131
  const message: ClientMessage = {
120
132
  id: String(id),
121
133
  chatId: entry.id,
122
134
  role: "user",
123
135
  text,
124
136
  ts,
125
- ...(imagePath ? { imagePath } : {}),
137
+ ...(firstImage ? { imagePath: firstImage.url } : {}),
138
+ ...(attachments.length ? { attachments } : {}),
126
139
  };
127
- // For an attached image, persist the on-disk path + a `photo` mediaType so
128
- // it re-renders on history reload (rehydrated in the `history` handler)
129
- // instead of vanishing to a text-only placeholder. Caption stays as text.
140
+ // Persist the attachment records so the files re-render on history reload
141
+ // (rehydrated in `history.ts`) instead of vanishing to a text-only
142
+ // placeholder. mediaType/filePath keep the pre-multi-file row shape
143
+ // populated from the first attachment. Caption stays as text.
144
+ const first = attachments[0];
130
145
  pushMessage(entry.id, {
131
146
  msgId: id,
132
147
  senderId: USER_SENDER_ID,
133
148
  senderName: "User",
134
149
  text,
135
150
  timestamp: ts,
136
- ...(imagePath
151
+ ...(first
137
152
  ? {
138
- mediaType: "photo" as const,
139
- ...(attachmentPath ? { filePath: attachmentPath } : {}),
153
+ mediaType: first.image ? ("photo" as const) : ("document" as const),
154
+ filePath: first.path,
155
+ attachments,
140
156
  }
141
157
  : {}),
142
158
  });
143
- runtime.chats.touch(entry.id, imagePath ? text || "[photo]" : text);
159
+ runtime.chats.touch(
160
+ entry.id,
161
+ attachments.length ? text || attachmentPreview(attachments) : text,
162
+ );
144
163
  maybeAutoTitle(runtime, entry, text);
145
164
  runtime.broadcast({ kind: "message", chatId: entry.id, message });
146
165
  broadcastChatUpdated(runtime, entry);
@@ -18,7 +18,12 @@ import {
18
18
  } from "./extensions.js";
19
19
  import { historyPage, searchHistory } from "./history.js";
20
20
  import { readLogEntries } from "./logs.js";
21
- import { mediaUrl, registerMedia, saveUpload } from "./media.js";
21
+ import {
22
+ describeAttachment,
23
+ MAX_UPLOAD_BYTES,
24
+ resolveUpload,
25
+ saveUploadStream,
26
+ } from "./media.js";
22
27
  import {
23
28
  effortLevels,
24
29
  listBackends,
@@ -30,11 +35,40 @@ import {
30
35
  import { setQueued } from "./queue.js";
31
36
  import { resetChat } from "./reset.js";
32
37
  import type { NativeRuntime } from "./runtime.js";
33
- import type { BridgeServerHandlers } from "./server.js";
38
+ import type { BridgeServerHandlers, SendOptions } from "./server.js";
39
+ import type { ClientAttachment } from "./protocol.js";
34
40
  import { configSnapshot, applyConfigUpdate } from "./settings.js";
35
41
  import { bridgeStatus, broadcastStatus } from "./status.js";
36
42
  import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
37
43
 
44
+ /**
45
+ * Turn a `/send` body's attachment references into the records this daemon
46
+ * minted at upload time. Both wire shapes land here: the `attachments` list
47
+ * multi-file clients send, and the `imagePath`/`attachmentPath` pair older
48
+ * single-image clients send. References the daemon cannot account for are
49
+ * dropped — a message can only ever point the model at a file the daemon
50
+ * itself wrote to its uploads dir.
51
+ */
52
+ function resolveAttachments(
53
+ runtime: NativeRuntime,
54
+ opts: SendOptions | undefined,
55
+ ): ClientAttachment[] {
56
+ const refs = [...(opts?.attachments ?? [])];
57
+ if (!refs.length && (opts?.imagePath || opts?.attachmentPath)) {
58
+ refs.push({ url: opts.imagePath, path: opts.attachmentPath });
59
+ }
60
+ const resolved: ClientAttachment[] = [];
61
+ for (const ref of refs) {
62
+ const found = resolveUpload(runtime, ref);
63
+ // Same file referenced twice (a re-send of a queued message, say) stays
64
+ // one attachment.
65
+ if (found && !resolved.some((a) => a.path === found.path)) {
66
+ resolved.push(found);
67
+ }
68
+ }
69
+ return resolved;
70
+ }
71
+
38
72
  export function buildBridgeHandlers(
39
73
  runtime: NativeRuntime,
40
74
  ): BridgeServerHandlers {
@@ -49,29 +83,44 @@ export function buildBridgeHandlers(
49
83
  search: (query, chatId) => searchHistory(runtime, query, chatId),
50
84
  send: (id, text, opts) => {
51
85
  const entry = chats.get(id) ?? chats.ensure(id);
86
+ // Resolve the client's references into the records this daemon minted
87
+ // at upload time — dropping anything it can't account for.
88
+ const attachments = resolveAttachments(runtime, opts);
52
89
  // A turn is already running for this chat — don't interrupt it. Park the
53
90
  // message as the single queued follow-up (synced to every client); it
54
91
  // auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
55
92
  // which `runTurn` sets synchronously, so even a rapid second /send from
56
93
  // any client is caught here rather than starting a concurrent turn.
57
94
  if (isBusy(runtime, entry.id)) {
58
- setQueued(runtime, entry.id, {
59
- text,
60
- imagePath: opts?.imagePath,
61
- attachmentPath: opts?.attachmentPath,
62
- });
95
+ setQueued(runtime, entry.id, { text, attachments });
63
96
  return;
64
97
  }
65
- startTurn(runtime, entry, text, opts);
98
+ startTurn(runtime, entry, text, { attachments });
66
99
  },
67
100
  queueMessage: (id, text) => {
68
- // Edit/replace the queued follow-up (text-only). Empty clears it.
101
+ // Edit/replace the queued follow-up's text, keeping whatever files were
102
+ // queued with it. Empty text cancels the whole follow-up, attachments
103
+ // included — the client's queue editor offers no other way to drop it.
69
104
  const entry = chats.get(id);
70
- if (entry) setQueued(runtime, entry.id, { text });
105
+ if (!entry) return;
106
+ const attachments = text.trim()
107
+ ? (runtime.queuedByChat.get(entry.id)?.attachments ?? [])
108
+ : [];
109
+ setQueued(runtime, entry.id, { text, attachments });
71
110
  },
72
- upload: async (filename, _contentType, bytes) => {
73
- const path = await saveUpload(runtime, filename, bytes);
74
- return { imagePath: mediaUrl(registerMedia(runtime, path)), path };
111
+ upload: async (filename, contentType, body) => {
112
+ const saved = await saveUploadStream(
113
+ runtime,
114
+ filename,
115
+ body,
116
+ MAX_UPLOAD_BYTES,
117
+ );
118
+ return describeAttachment(runtime, {
119
+ path: saved.path,
120
+ name: filename,
121
+ size: saved.size,
122
+ contentType,
123
+ });
75
124
  },
76
125
  listModels: (chatId) => listModels(runtime, chatId),
77
126
  setModel: (id, model) => setModel(runtime, id, model),
@@ -3,18 +3,21 @@
3
3
  * (attached images get a fresh /media URL, assistant rows their turn meta).
4
4
  */
5
5
 
6
+ import { basename } from "node:path";
6
7
  import {
7
8
  getRecentHistory,
8
9
  getHistoryBefore,
9
10
  searchHistoryMessages,
10
11
  type HistoryMessage,
12
+ type MessageAttachment,
11
13
  } from "../../storage/history.js";
12
14
  import { isDeliveryTool } from "../../core/tools/index.js";
13
15
  import type { ChatEntry } from "./chats.js";
14
16
  import { refreshContext } from "./context.js";
15
- import { mediaUrl, registerMedia } from "./media.js";
17
+ import { contentTypeFor, rehydrateAttachment } from "./media.js";
16
18
  import {
17
19
  historyToClientMessage,
20
+ type ClientAttachment,
18
21
  type ClientMessage,
19
22
  type SearchResult,
20
23
  } from "./protocol.js";
@@ -29,11 +32,32 @@ function hydrateHistoryRow(
29
32
  row: HistoryMessage,
30
33
  ): ClientMessage {
31
34
  const msg = historyToClientMessage(row, chatId);
32
- // Re-hydrate an attached image: re-register its on-disk path into
33
- // the media map and hand back a fresh /media URL so the image shows
34
- // in reloaded history (survives restarts as long as the file exists).
35
- if (row.mediaType === "photo" && row.filePath) {
36
- msg.imagePath = mediaUrl(registerMedia(runtime, row.filePath));
35
+ // Re-hydrate attached files: media ids are per-daemon-run, so each stored
36
+ // attachment is re-registered and handed a fresh /media URL. Rows written
37
+ // before multi-file attachments carry only mediaType + filePath, which is
38
+ // projected into the same one-element list so every client renders history
39
+ // the same way regardless of when it was recorded.
40
+ const stored: ClientAttachment[] = row.attachments?.length
41
+ ? row.attachments.map((a: MessageAttachment) =>
42
+ rehydrateAttachment(runtime, { ...a, url: "" }),
43
+ )
44
+ : row.filePath &&
45
+ (row.mediaType === "photo" || row.mediaType === "document")
46
+ ? [
47
+ rehydrateAttachment(runtime, {
48
+ path: row.filePath,
49
+ name: basename(row.filePath),
50
+ size: 0,
51
+ mimeType: contentTypeFor(row.filePath),
52
+ image: row.mediaType === "photo",
53
+ url: "",
54
+ }),
55
+ ]
56
+ : [];
57
+ if (stored.length) {
58
+ msg.attachments = stored;
59
+ const firstImage = stored.find((a) => a.image);
60
+ if (firstImage) msg.imagePath = firstImage.url;
37
61
  }
38
62
  // Re-hydrate turn meta (tool timeline + stats) for assistant rows.
39
63
  if (msg.role === "assistant") {
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * Media handling — the id → path registry the `/media` route serves from,
3
- * and the uploads directory client attachments land in.
3
+ * the uploads directory client attachments land in, and the MIME table both
4
+ * the upload path and the `/media` route classify files with.
4
5
  */
5
6
 
6
- import { mkdir, writeFile } from "node:fs/promises";
7
- import { basename, join } from "node:path";
7
+ import { createWriteStream, type WriteStream } from "node:fs";
8
+ import { mkdir, unlink } from "node:fs/promises";
9
+ import { basename, extname, join } from "node:path";
10
+ import { pipeline } from "node:stream/promises";
11
+ import type { Readable } from "node:stream";
8
12
  import { dirs } from "../../util/paths.js";
13
+ import type { ClientAttachment } from "./protocol.js";
9
14
  import type { NativeRuntime } from "./runtime.js";
10
15
 
16
+ /**
17
+ * Upload ceiling. Bodies stream straight to disk, so this bounds what a
18
+ * client may park in the uploads dir rather than what has to fit in memory —
19
+ * high enough for the archives and media people actually attach.
20
+ */
21
+ export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
22
+
11
23
  /** Register a file for serving and return its short media id. */
12
24
  export function registerMedia(
13
25
  runtime: NativeRuntime,
@@ -24,20 +36,264 @@ export function mediaUrl(mediaId: string): string {
24
36
  }
25
37
 
26
38
  /**
27
- * Persist an uploaded attachment to the workspace uploads dir under a safe,
28
- * unique name, and return its absolute path (handed to the model to read).
39
+ * Extension → MIME. Deliberately small: the types a companion client is
40
+ * likely to attach (images it renders inline, archives, documents, code,
41
+ * audio/video), with `application/octet-stream` for everything else — which
42
+ * is a correct answer for a download, just not a descriptive one.
43
+ */
44
+ const MIME_BY_EXT: Record<string, string> = {
45
+ // Images (the set `isImageType` renders inline).
46
+ ".png": "image/png",
47
+ ".jpg": "image/jpeg",
48
+ ".jpeg": "image/jpeg",
49
+ ".gif": "image/gif",
50
+ ".webp": "image/webp",
51
+ ".bmp": "image/bmp",
52
+ ".svg": "image/svg+xml",
53
+ ".heic": "image/heic",
54
+ ".heif": "image/heif",
55
+ ".avif": "image/avif",
56
+ ".ico": "image/x-icon",
57
+ ".tif": "image/tiff",
58
+ ".tiff": "image/tiff",
59
+ // Archives.
60
+ ".zip": "application/zip",
61
+ ".gz": "application/gzip",
62
+ ".tgz": "application/gzip",
63
+ ".bz2": "application/x-bzip2",
64
+ ".xz": "application/x-xz",
65
+ ".zst": "application/zstd",
66
+ ".tar": "application/x-tar",
67
+ ".7z": "application/x-7z-compressed",
68
+ ".rar": "application/vnd.rar",
69
+ // Documents.
70
+ ".pdf": "application/pdf",
71
+ ".doc": "application/msword",
72
+ ".docx":
73
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
74
+ ".xls": "application/vnd.ms-excel",
75
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
76
+ ".ppt": "application/vnd.ms-powerpoint",
77
+ ".pptx":
78
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
79
+ ".odt": "application/vnd.oasis.opendocument.text",
80
+ ".rtf": "application/rtf",
81
+ ".epub": "application/epub+zip",
82
+ // Text + code.
83
+ ".txt": "text/plain",
84
+ ".md": "text/markdown",
85
+ ".csv": "text/csv",
86
+ ".tsv": "text/tab-separated-values",
87
+ ".log": "text/plain",
88
+ ".json": "application/json",
89
+ ".jsonl": "application/x-ndjson",
90
+ ".xml": "application/xml",
91
+ ".yaml": "application/yaml",
92
+ ".yml": "application/yaml",
93
+ ".toml": "application/toml",
94
+ ".html": "text/html",
95
+ ".css": "text/css",
96
+ ".js": "text/javascript",
97
+ ".mjs": "text/javascript",
98
+ ".ts": "text/x-typescript",
99
+ ".tsx": "text/x-typescript",
100
+ ".py": "text/x-python",
101
+ ".rs": "text/x-rust",
102
+ ".go": "text/x-go",
103
+ ".java": "text/x-java",
104
+ ".c": "text/x-c",
105
+ ".h": "text/x-c",
106
+ ".cpp": "text/x-c++",
107
+ ".sh": "application/x-sh",
108
+ ".sql": "application/sql",
109
+ ".patch": "text/x-diff",
110
+ ".diff": "text/x-diff",
111
+ // Audio / video.
112
+ ".mp3": "audio/mpeg",
113
+ ".m4a": "audio/mp4",
114
+ ".wav": "audio/wav",
115
+ ".ogg": "audio/ogg",
116
+ ".opus": "audio/opus",
117
+ ".flac": "audio/flac",
118
+ ".mp4": "video/mp4",
119
+ ".mov": "video/quicktime",
120
+ ".webm": "video/webm",
121
+ ".mkv": "video/x-matroska",
122
+ ".avi": "video/x-msvideo",
123
+ // Fonts.
124
+ ".ttf": "font/ttf",
125
+ ".otf": "font/otf",
126
+ ".woff": "font/woff",
127
+ ".woff2": "font/woff2",
128
+ };
129
+
130
+ /** Best-effort MIME type for a path, by extension. */
131
+ export function contentTypeFor(filePath: string): string {
132
+ return (
133
+ MIME_BY_EXT[extname(filePath).toLowerCase()] ?? "application/octet-stream"
134
+ );
135
+ }
136
+
137
+ /** Whether a MIME type is one clients render inline as an image. */
138
+ export function isImageType(mimeType: string): boolean {
139
+ // SVG is an image by type but a script host by nature — clients render it
140
+ // through the same <img> path, so keep it inline-able; the bytes are only
141
+ // ever served back to the authenticated client that uploaded them.
142
+ return mimeType.startsWith("image/");
143
+ }
144
+
145
+ /**
146
+ * Sanitise an uploaded file name to a single safe path segment. Strips any
147
+ * directory component (`../`, absolute paths, Windows separators) and any
148
+ * character outside a conservative allowlist, so an upload can never escape
149
+ * the uploads dir no matter what the client sends.
150
+ */
151
+ export function safeUploadName(filename: string): string {
152
+ const base = basename(filename.replace(/\\/g, "/"));
153
+ const safe = base.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "");
154
+ return safe || "upload";
155
+ }
156
+
157
+ /** The absolute path an upload with this name lands at (unique per call). */
158
+ function uploadDest(runtime: NativeRuntime, filename: string): string {
159
+ return join(
160
+ dirs.uploads,
161
+ `${Date.now()}-${runtime.nextId().toString(36)}-${safeUploadName(filename)}`,
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Stream an upload body straight to the uploads dir — never buffering the
167
+ * whole file in memory, so a multi-hundred-megabyte archive costs a socket
168
+ * and a file handle rather than the heap. Aborts (and cleans up the partial
169
+ * file) as soon as `maxBytes` is exceeded.
29
170
  */
30
- export async function saveUpload(
171
+ export async function saveUploadStream(
31
172
  runtime: NativeRuntime,
32
173
  filename: string,
33
- bytes: Buffer,
34
- ): Promise<string> {
174
+ body: Readable,
175
+ maxBytes: number,
176
+ ): Promise<{ path: string; size: number }> {
35
177
  await mkdir(dirs.uploads, { recursive: true });
36
- const safe = basename(filename).replace(/[^\w.-]+/g, "_") || "upload";
37
- const dest = join(
38
- dirs.uploads,
39
- `${Date.now()}-${runtime.nextId().toString(36)}-${safe}`,
40
- );
41
- await writeFile(dest, bytes);
42
- return dest;
178
+ const dest = uploadDest(runtime, filename);
179
+ const sink = createWriteStream(dest);
180
+ let size = 0;
181
+ let tooLarge = false;
182
+ try {
183
+ await pipeline(
184
+ body,
185
+ async function* (source: AsyncIterable<Buffer>) {
186
+ for await (const chunk of source) {
187
+ size += chunk.length;
188
+ if (size > maxBytes) {
189
+ tooLarge = true;
190
+ throw new Error(
191
+ `Upload exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MB limit`,
192
+ );
193
+ }
194
+ yield chunk;
195
+ }
196
+ },
197
+ sink,
198
+ );
199
+ } catch (err) {
200
+ // Wait for the sink to actually close before removing the partial file:
201
+ // the pipeline can reject before the fd exists, and an unlink that races
202
+ // it leaves the file on disk once the open finally lands.
203
+ await closed(sink);
204
+ await unlink(dest).catch(() => {});
205
+ if (tooLarge) throw err;
206
+ throw new Error(
207
+ `Upload failed: ${err instanceof Error ? err.message : String(err)}`,
208
+ );
209
+ }
210
+ if (size === 0) {
211
+ await closed(sink);
212
+ await unlink(dest).catch(() => {});
213
+ throw new Error("Empty upload");
214
+ }
215
+ return { path: dest, size };
216
+ }
217
+
218
+ /** Resolve once a write stream is closed, destroying it if it still is not. */
219
+ function closed(sink: WriteStream): Promise<void> {
220
+ return new Promise((resolve) => {
221
+ if (sink.closed) return resolve();
222
+ sink.once("close", () => resolve());
223
+ sink.destroy();
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Register a saved upload for serving and project it to its wire shape. The
229
+ * declared content type wins when it is specific; otherwise the extension
230
+ * decides, so a client that sends `application/octet-stream` for everything
231
+ * still gets a usefully typed attachment back.
232
+ */
233
+ export function describeAttachment(
234
+ runtime: NativeRuntime,
235
+ file: { path: string; name: string; size: number; contentType?: string },
236
+ ): ClientAttachment {
237
+ const declared = (file.contentType ?? "").split(";")[0]?.trim().toLowerCase();
238
+ const mimeType =
239
+ declared && declared !== "application/octet-stream"
240
+ ? declared
241
+ : contentTypeFor(file.name || file.path);
242
+ const mediaId = registerMedia(runtime, file.path);
243
+ const attachment: ClientAttachment = {
244
+ path: file.path,
245
+ name: safeUploadName(file.name || basename(file.path)),
246
+ size: file.size,
247
+ mimeType,
248
+ url: mediaUrl(mediaId),
249
+ image: isImageType(mimeType),
250
+ };
251
+ // Remember it by media id: `/send` resolves the client's references through
252
+ // this registry instead of trusting paths off the wire.
253
+ runtime.uploads.set(mediaId, attachment);
254
+ return attachment;
255
+ }
256
+
257
+ /** The media id inside a `/media?id=…` reference, or undefined. */
258
+ export function mediaIdFrom(url: string | undefined): string | undefined {
259
+ if (!url) return undefined;
260
+ const match = /[?&]id=([^&]+)/.exec(url);
261
+ return match?.[1] ? decodeURIComponent(match[1]) : undefined;
262
+ }
263
+
264
+ /**
265
+ * Resolve one attachment reference from a client into the record this daemon
266
+ * minted at upload time. Unknown ids resolve to undefined — a client cannot
267
+ * name a file the daemon did not write to its own uploads dir.
268
+ */
269
+ export function resolveUpload(
270
+ runtime: NativeRuntime,
271
+ ref: { url?: string; path?: string } | undefined,
272
+ ): ClientAttachment | undefined {
273
+ if (!ref) return undefined;
274
+ const byId = mediaIdFrom(ref.url);
275
+ if (byId) {
276
+ const known = runtime.uploads.get(byId);
277
+ if (known) return known;
278
+ }
279
+ // Legacy single-file clients send only the on-disk path back. Match it
280
+ // against what was uploaded this run rather than accepting it outright.
281
+ if (ref.path) {
282
+ for (const entry of runtime.uploads.values()) {
283
+ if (entry.path === ref.path) return entry;
284
+ }
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ /**
290
+ * Re-describe an attachment persisted in history: the stored record already
291
+ * has the name/size/type, it just needs a fresh media id (ids are per-daemon
292
+ * run) so the bytes are fetchable again after a restart.
293
+ */
294
+ export function rehydrateAttachment(
295
+ runtime: NativeRuntime,
296
+ stored: ClientAttachment,
297
+ ): ClientAttachment {
298
+ return { ...stored, url: mediaUrl(registerMedia(runtime, stored.path)) };
43
299
  }
@@ -52,6 +52,31 @@ export type ClientToolCall = {
52
52
  output?: string;
53
53
  };
54
54
 
55
+ /**
56
+ * One file attached to a message — an image, an archive, a document, or
57
+ * anything else the user dropped on the composer. Additive in v1: older
58
+ * clients that only know `imagePath` keep rendering the first image.
59
+ *
60
+ * `path` is the absolute on-disk location in the daemon's uploads dir; it is
61
+ * what the model is pointed at so it can read the file itself. `url` is the
62
+ * relative bridge path a client fetches the bytes from (`/media?id=…`),
63
+ * resolved against its own base URL + token.
64
+ */
65
+ export type ClientAttachment = {
66
+ /** Absolute path on the daemon host (handed to the model). */
67
+ path: string;
68
+ /** Original file name, for display. */
69
+ name: string;
70
+ /** Size in bytes. */
71
+ size: number;
72
+ /** Best-effort MIME type, `application/octet-stream` when unknown. */
73
+ mimeType: string;
74
+ /** Relative bridge path the bytes are served from (`/media?id=…`). */
75
+ url: string;
76
+ /** True when the client should render this inline as an image. */
77
+ image: boolean;
78
+ };
79
+
55
80
  /** A single rendered message in a conversation. */
56
81
  export type ClientMessage = {
57
82
  id: string;
@@ -68,6 +93,12 @@ export type ClientMessage = {
68
93
  * Present on photo messages the bot sends; `text` carries any caption.
69
94
  */
70
95
  imagePath?: string;
96
+ /**
97
+ * Every file attached to this message, in the order the user staged them.
98
+ * `imagePath` stays in sync with the first image here so clients written
99
+ * against the single-image shape keep working unchanged.
100
+ */
101
+ attachments?: ClientAttachment[];
71
102
  /** Tools that ran during this assistant turn (history hydration). */
72
103
  tools?: ClientToolCall[];
73
104
  /** Turn stats attached once the turn ended (history hydration). */
@@ -109,8 +140,10 @@ export type ContextInfo = {
109
140
  */
110
141
  export type QueuedMessage = {
111
142
  text: string;
112
- /** True when an image/file is attached to the queued send. */
143
+ /** True when one or more files are attached to the queued send. */
113
144
  hasAttachment: boolean;
145
+ /** How many files are attached (additive; older clients read the flag). */
146
+ attachmentCount?: number;
114
147
  };
115
148
 
116
149
  /** A conversation in the sidebar. */
@@ -9,7 +9,7 @@ import type { NativeRuntime, QueuedEntry } from "./runtime.js";
9
9
 
10
10
  /**
11
11
  * Set (or replace) a chat's queued follow-up and sync it to every client via
12
- * chat_updated. Empty text with no attachment clears the queue.
12
+ * chat_updated. Empty text with no attachments clears the queue.
13
13
  */
14
14
  export function setQueued(
15
15
  runtime: NativeRuntime,
@@ -19,14 +19,11 @@ export function setQueued(
19
19
  const entry = runtime.chats.get(chatId);
20
20
  if (!entry) return;
21
21
  const text = next.text.trim();
22
- if (!text && !next.attachmentPath) {
22
+ const attachments = next.attachments;
23
+ if (!text && attachments.length === 0) {
23
24
  if (!runtime.queuedByChat.delete(chatId)) return;
24
25
  } else {
25
- runtime.queuedByChat.set(chatId, {
26
- text,
27
- imagePath: next.imagePath,
28
- attachmentPath: next.attachmentPath,
29
- });
26
+ runtime.queuedByChat.set(chatId, { text, attachments });
30
27
  }
31
28
  broadcastChatUpdated(runtime, entry);
32
29
  }