talon-agent 3.34.1 → 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.
Files changed (63) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +3 -3
  4. package/src/backend/codex/auth.ts +40 -1
  5. package/src/backend/codex/handler/message.ts +16 -1
  6. package/src/backend/codex/plan-usage.ts +27 -2
  7. package/src/backend/kilo/handler/message.ts +2 -0
  8. package/src/backend/kilo/server.ts +1 -0
  9. package/src/backend/opencode/handler/message.ts +2 -0
  10. package/src/backend/opencode/server.ts +1 -0
  11. package/src/backend/remote-server/chat-turn.ts +13 -3
  12. package/src/backend/remote-server/lifecycle.ts +37 -0
  13. package/src/backend/remote-server/server-bindings.ts +12 -0
  14. package/src/backend/remote-server/state.ts +8 -0
  15. package/src/backend/remote-server/turn.ts +93 -23
  16. package/src/bootstrap.ts +24 -10
  17. package/src/core/auth/expiry-monitor.ts +89 -0
  18. package/src/core/auth/login-flow.ts +247 -0
  19. package/src/core/auth/status.ts +193 -0
  20. package/src/core/engine/gateway-actions/history.ts +12 -1
  21. package/src/core/errors.ts +1 -1
  22. package/src/core/tools/history.ts +10 -1
  23. package/src/frontend/native/chat-wire.ts +6 -2
  24. package/src/frontend/native/emit.ts +30 -11
  25. package/src/frontend/native/handlers.ts +62 -13
  26. package/src/frontend/native/history.ts +30 -6
  27. package/src/frontend/native/media.ts +271 -15
  28. package/src/frontend/native/protocol.ts +34 -1
  29. package/src/frontend/native/queue.ts +4 -7
  30. package/src/frontend/native/routes/chats.ts +30 -12
  31. package/src/frontend/native/routes/host.ts +17 -5
  32. package/src/frontend/native/routes/params.ts +21 -0
  33. package/src/frontend/native/runtime.ts +16 -4
  34. package/src/frontend/native/server.ts +2 -35
  35. package/src/frontend/native/status.ts +3 -1
  36. package/src/frontend/native/turn.ts +39 -17
  37. package/src/frontend/shared/model-commands.ts +400 -0
  38. package/src/frontend/telegram/auth-panel.ts +205 -0
  39. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  40. package/src/frontend/telegram/callbacks/index.ts +16 -0
  41. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  42. package/src/frontend/telegram/commands/auth.ts +58 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +10 -4
  45. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  46. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  47. package/src/frontend/whatsapp/actions/history.ts +100 -0
  48. package/src/frontend/whatsapp/actions/index.ts +4 -1
  49. package/src/frontend/whatsapp/commands.ts +371 -0
  50. package/src/frontend/whatsapp/inbound.ts +24 -46
  51. package/src/frontend/whatsapp/index.ts +12 -1
  52. package/src/frontend/whatsapp/media-store.ts +20 -1
  53. package/src/frontend/whatsapp/message-store.ts +101 -24
  54. package/src/storage/db.ts +7 -1
  55. package/src/storage/history.ts +26 -2
  56. package/src/storage/repositories/history-repo.ts +61 -1
  57. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  58. package/src/storage/sql/db.sql +6 -0
  59. package/src/storage/sql/history.sql +22 -9
  60. package/src/storage/sql/schema.sql +27 -1
  61. package/src/storage/sql/statements.generated.ts +70 -11
  62. package/src/storage/sql/whatsapp-messages.sql +29 -0
  63. package/src/storage/whatsapp-messages.ts +51 -0
@@ -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
  }
@@ -1,8 +1,11 @@
1
1
  import type { RouteHost } from "./host.js";
2
2
  import type { BridgeRoutes } from "./table.js";
3
- import { asPositiveInt, asString, deviceIdParam } from "./params.js";
4
-
5
- const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
3
+ import {
4
+ asAttachmentRefs,
5
+ asPositiveInt,
6
+ asString,
7
+ deviceIdParam,
8
+ } from "./params.js";
6
9
 
7
10
  export function chatRoutes(
8
11
  host: RouteHost,
@@ -87,27 +90,42 @@ export function chatRoutes(
87
90
  const body = await readJson(req);
88
91
  const id = asString(body.chatId) ?? "";
89
92
  const text = asString(body.text) ?? "";
93
+ // Multi-file clients send `attachments`; the single-image shape older
94
+ // clients send is folded into the same list by the handler.
95
+ const attachments = asAttachmentRefs(body.attachments);
90
96
  const imagePath = asString(body.imagePath);
91
97
  const attachmentPath = asString(body.attachmentPath);
92
- // Text may be empty when an image is attached; require one or the
93
- // other.
94
- if (!id || (!text.trim() && !attachmentPath))
98
+ const hasAttachment =
99
+ attachments.length > 0 || Boolean(attachmentPath || imagePath);
100
+ // Text may be empty when a file is attached; require one or the other.
101
+ if (!id || (!text.trim() && !hasAttachment))
95
102
  return json(res, 400, {
96
103
  ok: false,
97
104
  error: "chatId and text (or an attachment) required",
98
105
  });
99
- h.send(id, text, { imagePath, attachmentPath });
106
+ h.send(id, text, { attachments, imagePath, attachmentPath });
100
107
  json(res, 202, { ok: true });
101
108
  },
102
109
  "POST /upload": async ({ req, res, url }) => {
103
110
  const filename = url.searchParams.get("filename") ?? "upload";
104
111
  const contentType =
105
112
  req.headers["content-type"] ?? "application/octet-stream";
106
- const bytes = await host.readRaw(req, MAX_UPLOAD_BYTES);
107
- if (!bytes.length)
108
- return json(res, 400, { ok: false, error: "Empty upload" });
109
- const result = await h.upload(filename, contentType, bytes);
110
- json(res, 200, { ok: true, ...result });
113
+ try {
114
+ const attachment = await h.upload(filename, contentType, req);
115
+ // `imagePath` mirrors the URL for clients written against the
116
+ // single-image upload response.
117
+ json(res, 200, { ok: true, ...attachment, imagePath: attachment.url });
118
+ } catch (err) {
119
+ // The client can only act on this if it knows why: too large (pick a
120
+ // smaller file), empty (nothing was read), or a server-side failure.
121
+ const error = err instanceof Error ? err.message : String(err);
122
+ const status = /limit/i.test(error)
123
+ ? 413
124
+ : /empty/i.test(error)
125
+ ? 400
126
+ : 500;
127
+ json(res, status, { ok: false, error });
128
+ }
111
129
  },
112
130
  "GET /media": ({ res, url }) =>
113
131
  host.serveMedia(res, url.searchParams.get("id") ?? ""),