talon-agent 3.35.0 → 3.37.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.
@@ -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,12 @@
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";
9
+ import { log, logWarn } from "../../../util/log.js";
6
10
 
7
11
  export function chatRoutes(
8
12
  host: RouteHost,
@@ -87,27 +91,56 @@ export function chatRoutes(
87
91
  const body = await readJson(req);
88
92
  const id = asString(body.chatId) ?? "";
89
93
  const text = asString(body.text) ?? "";
94
+ // Multi-file clients send `attachments`; the single-image shape older
95
+ // clients send is folded into the same list by the handler.
96
+ const attachments = asAttachmentRefs(body.attachments);
90
97
  const imagePath = asString(body.imagePath);
91
98
  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))
99
+ const hasAttachment =
100
+ attachments.length > 0 || Boolean(attachmentPath || imagePath);
101
+ // Text may be empty when a file is attached; require one or the other.
102
+ if (!id || (!text.trim() && !hasAttachment))
95
103
  return json(res, 400, {
96
104
  ok: false,
97
105
  error: "chatId and text (or an attachment) required",
98
106
  });
99
- h.send(id, text, { imagePath, attachmentPath });
107
+ h.send(id, text, { attachments, imagePath, attachmentPath });
100
108
  json(res, 202, { ok: true });
101
109
  },
102
110
  "POST /upload": async ({ req, res, url }) => {
103
111
  const filename = url.searchParams.get("filename") ?? "upload";
104
112
  const contentType =
105
113
  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 });
114
+ const startedAt = Date.now();
115
+ try {
116
+ const attachment = await h.upload(filename, contentType, req);
117
+ // Uploads are the one client action whose failure used to be visible
118
+ // only in the app: the route answered in JSON and logged nothing, so
119
+ // "attaching a file doesn't work — check the logs" had nothing to
120
+ // read. Both outcomes are logged now, with the size and duration
121
+ // that distinguish a rejected upload from a dropped connection.
122
+ log(
123
+ "native",
124
+ `upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
125
+ );
126
+ // `imagePath` mirrors the URL for clients written against the
127
+ // single-image upload response.
128
+ json(res, 200, { ok: true, ...attachment, imagePath: attachment.url });
129
+ } catch (err) {
130
+ // The client can only act on this if it knows why: too large (pick a
131
+ // smaller file), empty (nothing was read), or a server-side failure.
132
+ const error = err instanceof Error ? err.message : String(err);
133
+ const status = /limit/i.test(error)
134
+ ? 413
135
+ : /empty/i.test(error)
136
+ ? 400
137
+ : 500;
138
+ logWarn(
139
+ "native",
140
+ `upload failed (${status}) for ${filename} after ${Date.now() - startedAt}ms: ${error}`,
141
+ );
142
+ json(res, status, { ok: false, error });
143
+ }
111
144
  },
112
145
  "GET /media": ({ res, url }) =>
113
146
  host.serveMedia(res, url.searchParams.get("id") ?? ""),
@@ -1,6 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Readable } from "node:stream";
3
+ import type { AttachmentRef } from "./params.js";
2
4
  import type {
3
5
  BackendOption,
6
+ ClientAttachment,
4
7
  BridgeEvent,
5
8
  BridgeStatus,
6
9
  ClientChat,
@@ -18,7 +21,13 @@ import type {
18
21
  import type { ConfigSnapshot } from "../settings.js";
19
22
 
20
23
  /** Optional attachment references carried alongside a sent message. */
21
- type SendOptions = {
24
+ export type SendOptions = {
25
+ /**
26
+ * Every file attached to the message, as the client's references to its
27
+ * own uploads. `imagePath` and `attachmentPath` below are the single-file
28
+ * shape older clients send; the handler folds them into the same list.
29
+ */
30
+ attachments?: AttachmentRef[];
22
31
  /** Relative bridge path to render inline (e.g. `/media?id=…`). */
23
32
  imagePath?: string;
24
33
  /** Absolute on-disk path handed to the model so it can read the file. */
@@ -41,12 +50,16 @@ export type BridgeServerHandlers = {
41
50
  search(query: string, chatId?: string): SearchResult[];
42
51
  /** Fire-and-forget: streams its results back through `broadcast`. */
43
52
  send(id: string, text: string, opts?: SendOptions): void;
44
- /** Persist an uploaded image and return its render path + on-disk path. */
53
+ /**
54
+ * Stream an uploaded file to disk and return its wire description. The body
55
+ * is consumed as it arrives (never buffered whole), so the size ceiling is
56
+ * disk, not memory.
57
+ */
45
58
  upload(
46
59
  filename: string,
47
60
  contentType: string,
48
- bytes: Buffer,
49
- ): Promise<{ imagePath: string; path: string }>;
61
+ body: Readable,
62
+ ): Promise<ClientAttachment>;
50
63
  listModels(
51
64
  id?: string,
52
65
  ):
@@ -139,7 +152,6 @@ export type RouteHost = {
139
152
  fingerprint: () => string | null;
140
153
  json: (res: ServerResponse, code: number, body: unknown) => void;
141
154
  readJson: (req: IncomingMessage) => Promise<Record<string, unknown>>;
142
- readRaw: (req: IncomingMessage, max: number) => Promise<Buffer>;
143
155
  corsHeaders: () => Record<string, string>;
144
156
  streamFile: (
145
157
  res: ServerResponse,
@@ -27,3 +27,24 @@ export function asPositiveInt(v: string | null): number | undefined {
27
27
  const n = Number(v);
28
28
  return Number.isInteger(n) && n > 0 ? n : undefined;
29
29
  }
30
+
31
+ /** A client's reference to a file it already uploaded. */
32
+ export type AttachmentRef = { url?: string; path?: string };
33
+
34
+ /**
35
+ * Coerce the `attachments` array on a `/send` body to reference shape. Only
36
+ * the identifying fields are read: name, size and type are whatever the
37
+ * daemon recorded when it wrote the file, never what the client claims now.
38
+ */
39
+ export function asAttachmentRefs(v: unknown): AttachmentRef[] {
40
+ if (!Array.isArray(v)) return [];
41
+ const refs: AttachmentRef[] = [];
42
+ for (const item of v) {
43
+ if (typeof item !== "object" || item === null) continue;
44
+ const row = item as Record<string, unknown>;
45
+ const url = asString(row.url);
46
+ const path = asString(row.path);
47
+ if (url || path) refs.push({ url, path });
48
+ }
49
+ return refs;
50
+ }
@@ -12,7 +12,12 @@ import type { TalonConfig } from "../../util/config.js";
12
12
  import type { Gateway } from "../../core/engine/gateway.js";
13
13
  import { getMeshService, type MeshService } from "../../core/mesh/index.js";
14
14
  import { NativeChats } from "./chats.js";
15
- import type { BridgeEvent, ClientToolCall, ContextInfo } from "./protocol.js";
15
+ import type {
16
+ BridgeEvent,
17
+ ClientAttachment,
18
+ ClientToolCall,
19
+ ContextInfo,
20
+ } from "./protocol.js";
16
21
 
17
22
  /** One tool call observed during a running turn, kept for mid-turn replay. */
18
23
  export type LiveToolEntry = {
@@ -21,11 +26,10 @@ export type LiveToolEntry = {
21
26
  done?: boolean;
22
27
  };
23
28
 
24
- /** A chat's single queued follow-up, with its attachment paths intact. */
29
+ /** A chat's single queued follow-up, with its attachments intact. */
25
30
  export type QueuedEntry = {
26
31
  text: string;
27
- imagePath?: string;
28
- attachmentPath?: string;
32
+ attachments: ClientAttachment[];
29
33
  };
30
34
 
31
35
  export type NativeRuntime = {
@@ -51,6 +55,13 @@ export type NativeRuntime = {
51
55
  * frontends behave).
52
56
  */
53
57
  readonly media: Map<string, string>;
58
+ /**
59
+ * Uploads this daemon run has accepted, keyed by their media id. `/send`
60
+ * resolves a client's attachment references through here rather than
61
+ * trusting the paths in the request body, so a message can only ever point
62
+ * the model at a file this daemon itself wrote to the uploads dir.
63
+ */
64
+ readonly uploads: Map<string, ClientAttachment>;
54
65
  /**
55
66
  * Live context-window fill per chat, refreshed at the end of each turn.
56
67
  * Cached (not computed inline) so the sync `toClientChat` projection stays
@@ -99,6 +110,7 @@ export function createNativeRuntime(
99
110
  chats: new NativeChats(),
100
111
  mesh: getMeshService(),
101
112
  media: new Map(),
113
+ uploads: new Map(),
102
114
  contextByChat: new Map(),
103
115
  liveTurns: new Map(),
104
116
  queuedByChat: new Map(),
@@ -24,9 +24,9 @@ import { createServer as createTlsServer } from "node:https";
24
24
  import { createHash, timingSafeEqual } from "node:crypto";
25
25
  import { createReadStream } from "node:fs";
26
26
  import { stat } from "node:fs/promises";
27
- import { extname } from "node:path";
28
27
  import { log, logError, logDebug, logWarn } from "../../util/log.js";
29
28
  import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
29
+ import { contentTypeFor } from "./media.js";
30
30
  import { type BridgeEvent } from "./protocol.js";
31
31
  import { buildRoutes } from "./routes/index.js";
32
32
  import type { BridgeServerHandlers, RouteHost } from "./routes/host.js";
@@ -38,7 +38,7 @@ import {
38
38
  type RouteHandler,
39
39
  } from "./routes/table.js";
40
40
 
41
- export type { BridgeServerHandlers } from "./routes/host.js";
41
+ export type { BridgeServerHandlers, SendOptions } from "./routes/host.js";
42
42
  export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
43
43
 
44
44
  const SSE_PING_MS = 25_000;
@@ -352,7 +352,6 @@ export class BridgeServer {
352
352
  fingerprint: () => this.getFingerprint(),
353
353
  json: (res, code, body) => this.json(res, code, body),
354
354
  readJson: (req) => this.readJson(req),
355
- readRaw: (req, max) => this.readRaw(req, max),
356
355
  corsHeaders: () => this.corsHeaders(),
357
356
  streamFile: (res, file) => this.streamFile(res, file),
358
357
  serveMedia: (res, id) => this.serveMedia(res, id),
@@ -591,17 +590,6 @@ export class BridgeServer {
591
590
  }
592
591
 
593
592
  /** Read a raw request body (binary-safe) up to `max` bytes. */
594
- private async readRaw(req: IncomingMessage, max: number): Promise<Buffer> {
595
- const chunks: Buffer[] = [];
596
- let total = 0;
597
- for await (const chunk of req) {
598
- total += (chunk as Buffer).length;
599
- if (total > max) throw new Error("Upload too large");
600
- chunks.push(chunk as Buffer);
601
- }
602
- return Buffer.concat(chunks);
603
- }
604
-
605
593
  private async readJson(
606
594
  req: IncomingMessage,
607
595
  ): Promise<Record<string, unknown>> {
@@ -621,24 +609,3 @@ export class BridgeServer {
621
609
  return parsed as Record<string, unknown>;
622
610
  }
623
611
  }
624
-
625
- /** Minimal image content-type map for the media endpoint. */
626
- function contentTypeFor(filePath: string): string {
627
- switch (extname(filePath).toLowerCase()) {
628
- case ".png":
629
- return "image/png";
630
- case ".jpg":
631
- case ".jpeg":
632
- return "image/jpeg";
633
- case ".gif":
634
- return "image/gif";
635
- case ".webp":
636
- return "image/webp";
637
- case ".bmp":
638
- return "image/bmp";
639
- case ".svg":
640
- return "image/svg+xml";
641
- default:
642
- return "application/octet-stream";
643
- }
644
- }