shadok-ai 0.16.28 β†’ 0.16.30

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/README.md CHANGED
@@ -191,6 +191,13 @@ Enter to skip; you can add it later from the web UI).
191
191
  `SHADOK_WHISPER_MODEL` picks a bigger one if your recordings need it.
192
192
  Set `SHADOK_WHISPER_DIR` to a directory shared between containers and one copy
193
193
  serves them all.
194
+ - **Dictate a prompt on the web** β€” a πŸŽ™ button beside *Send* records your
195
+ microphone and transcribes it with the **same local whisper.cpp** the voice
196
+ notes use (nothing leaves the machine). The text drops into the composer
197
+ **editable**, never auto-sent, so a misheard word is fixed before it goes β€”
198
+ click to start, click to stop, a red timer runs while recording. Needs a
199
+ secure context (localhost or HTTPS) for the browser to grant the mic; the
200
+ first use pays the same one-time whisper install as the voice notes above.
194
201
  - **Videos on Telegram** β€” share a video (or a GIF, or a round video note) and
195
202
  the agent actually sees it: shadok pulls a handful of stills out of the clip
196
203
  for it to look at, and transcribes the sound when there is speech. Both run
@@ -242,6 +249,13 @@ Enter to skip; you can add it later from the web UI).
242
249
  otherwise just print a local path you cannot open. **Artifacts are denied**
243
250
  cockpit-wide for the same reason β€” a page on claude.ai is not the file you
244
251
  asked for (`SHADOK_ALLOW_ARTIFACTS=1` lifts it).
252
+ - **Photos and videos, viewable** β€” an image or a **video** (mp4/webm/mov…) in
253
+ the chat renders inline (a video gets a real player), and clicking a photo
254
+ opens it full-size in a lightbox. The πŸ–Ό button in an agent's bar opens a
255
+ **media gallery**: a grid of every photo and video in that conversation β€”
256
+ click a thumbnail to view or play it full-size, then back to the grid. Videos
257
+ are served with HTTP range requests so they seek and stream instead of
258
+ downloading whole.
245
259
  - **Compaction summaries, folded away** β€” when Claude Code runs out of context
246
260
  it compacts the conversation and writes a long "This session is being
247
261
  continued…" summary as the first message of what follows. Shown in full it
@@ -2,6 +2,8 @@
2
2
  export declare const SEND_FILE_TOOL = "SendUserFile";
3
3
  /** Would the web render this inline as an <img>? */
4
4
  export declare function isImageFile(name: string): boolean;
5
+ /** Would the web render this inline as a <video>? */
6
+ export declare function isVideoFile(name: string): boolean;
5
7
  /** Should Telegram send this as a photo (inline) rather than a document? */
6
8
  export declare function telegramPhotoable(name: string): boolean;
7
9
  /** Best-effort Content-Type for GET /download. `image/svg+xml` is served with a
@@ -13,8 +15,24 @@ export interface FileCard {
13
15
  path: string;
14
16
  name: string;
15
17
  image: boolean;
18
+ video: boolean;
16
19
  }
17
20
  export declare function fileCard(p: string): FileCard;
21
+ /**
22
+ * Pure: parse an HTTP `Range` header against a known file size, for the single
23
+ * `bytes=start-end` form a <video> issues (browsers never send multipart ranges
24
+ * to a plain file server, so one range is all we honour).
25
+ *
26
+ * Returns the inclusive byte slice, `null` when there is no range to honour (no
27
+ * header, a non-bytes unit, a syntactically broken value β†’ serve the whole file,
28
+ * a 200), or `"unsatisfiable"` when the range is well-formed but out of bounds
29
+ * (β†’ the caller answers 416). `bytes=N-` is "from N to the end"; `bytes=-N` is
30
+ * "the last N bytes".
31
+ */
32
+ export declare function parseByteRange(header: string | undefined, size: number): {
33
+ start: number;
34
+ end: number;
35
+ } | null | "unsatisfiable";
18
36
  /** The `files` a SendUserFile tool_use delivered β€” its `input.files`, kept only
19
37
  * when they are real string paths. Any other tool β†’ none. */
20
38
  export declare function toolFiles(name: unknown, input: unknown): string[];
package/dist/download.js CHANGED
@@ -9,6 +9,10 @@ import path from "node:path";
9
9
  /** The tool that delivers a file to the user. */
10
10
  export const SEND_FILE_TOOL = "SendUserFile";
11
11
  const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp", ".ico"]);
12
+ // What the web renders inline as a <video>. Kept to the formats a browser can
13
+ // actually play (mp4/webm/ogg, plus mov which Safari plays and Chrome often
14
+ // does too); an unplayable container still downloads via the octet-stream path.
15
+ const VIDEO_EXT = new Set([".mp4", ".m4v", ".webm", ".ogv", ".mov"]);
12
16
  // What Telegram will accept as a PHOTO (inline preview). Narrower than the web's
13
17
  // <img>: svg/gif/bmp/ico are not photos there, so they go as documents.
14
18
  const TG_PHOTO_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
@@ -18,12 +22,18 @@ const CONTENT_TYPE = {
18
22
  ".ico": "image/x-icon", ".pdf": "application/pdf", ".html": "text/html", ".htm": "text/html",
19
23
  ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".csv": "text/csv",
20
24
  ".txt": "text/plain", ".md": "text/markdown", ".xml": "application/xml", ".zip": "application/zip",
25
+ ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg",
26
+ ".mov": "video/quicktime",
21
27
  };
22
28
  const ext = (name) => path.extname(String(name || "")).toLowerCase();
23
29
  /** Would the web render this inline as an <img>? */
24
30
  export function isImageFile(name) {
25
31
  return IMAGE_EXT.has(ext(name));
26
32
  }
33
+ /** Would the web render this inline as a <video>? */
34
+ export function isVideoFile(name) {
35
+ return VIDEO_EXT.has(ext(name));
36
+ }
27
37
  /** Should Telegram send this as a photo (inline) rather than a document? */
28
38
  export function telegramPhotoable(name) {
29
39
  return TG_PHOTO_EXT.has(ext(name));
@@ -35,7 +45,44 @@ export function contentTypeFor(name) {
35
45
  }
36
46
  export function fileCard(p) {
37
47
  const name = path.basename(String(p || ""));
38
- return { path: String(p || ""), name, image: isImageFile(name) };
48
+ return { path: String(p || ""), name, image: isImageFile(name), video: isVideoFile(name) };
49
+ }
50
+ /**
51
+ * Pure: parse an HTTP `Range` header against a known file size, for the single
52
+ * `bytes=start-end` form a <video> issues (browsers never send multipart ranges
53
+ * to a plain file server, so one range is all we honour).
54
+ *
55
+ * Returns the inclusive byte slice, `null` when there is no range to honour (no
56
+ * header, a non-bytes unit, a syntactically broken value β†’ serve the whole file,
57
+ * a 200), or `"unsatisfiable"` when the range is well-formed but out of bounds
58
+ * (β†’ the caller answers 416). `bytes=N-` is "from N to the end"; `bytes=-N` is
59
+ * "the last N bytes".
60
+ */
61
+ export function parseByteRange(header, size) {
62
+ if (!header || size <= 0)
63
+ return null;
64
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
65
+ if (!m)
66
+ return null;
67
+ const [, rawStart, rawEnd] = m;
68
+ if (rawStart === "" && rawEnd === "")
69
+ return null;
70
+ let start, end;
71
+ if (rawStart === "") {
72
+ // Suffix: the last N bytes. N === 0 is unsatisfiable (no "last zero bytes").
73
+ const n = Number(rawEnd);
74
+ if (n === 0)
75
+ return "unsatisfiable";
76
+ start = Math.max(0, size - n);
77
+ end = size - 1;
78
+ }
79
+ else {
80
+ start = Number(rawStart);
81
+ end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
82
+ }
83
+ if (start > end || start >= size)
84
+ return "unsatisfiable";
85
+ return { start, end };
39
86
  }
40
87
  /** The `files` a SendUserFile tool_use delivered β€” its `input.files`, kept only
41
88
  * when they are real string paths. Any other tool β†’ none. */
@@ -1 +1 @@
1
- {"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,uEAAuE;AACvE,gFAAgF;AAChF,kFAAkF;AAClF,EAAE;AACF,iFAAiF;AAEjF,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,iDAAiD;AACjD,MAAM,CAAC,MAAM,cAAc,GAAG,cAAc,CAAC;AAE7C,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACvG,8EAA8E;AAC9E,4EAA4E;AAC5E,gFAAgF;AAChF,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACrE,iFAAiF;AACjF,wEAAwE;AACxE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAEjE,MAAM,YAAY,GAA2B;IAC3C,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACrF,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IAC1F,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW;IAC5F,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,kBAAkB,EAAE,MAAM,EAAE,UAAU;IAC7F,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,iBAAiB;IAClG,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW;IACpF,MAAM,EAAE,iBAAiB;CAC1B,CAAC;AAEF,MAAM,GAAG,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;AAErF,oDAAoD;AACpD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,OAAO,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;qEACqE;AACrE,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,0BAA0B,CAAC;AAC/D,CAAC;AAWD,MAAM,UAAU,QAAQ,CAAC,CAAS;IAChC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC5C,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;AAC7F,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAC5B,MAA0B,EAC1B,IAAY;IAEZ,IAAI,CAAC,MAAM,IAAI,IAAI,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,MAAM,CAAC,GAAG,qBAAqB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACpD,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,QAAQ,KAAK,EAAE,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAClD,IAAI,KAAa,EAAE,GAAW,CAAC;IAC/B,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;QACpB,6EAA6E;QAC7E,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,eAAe,CAAC;QACpC,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC;QAC9B,GAAG,GAAG,IAAI,GAAG,CAAC,CAAC;IACjB,CAAC;SAAM,CAAC;QACN,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;QACzB,GAAG,GAAG,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,eAAe,CAAC;IACzD,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;AACxB,CAAC;AAED;8DAC8D;AAC9D,MAAM,UAAU,SAAS,CAAC,IAAa,EAAE,KAAc;IACrD,IAAI,IAAI,KAAK,cAAc,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IAC9E,MAAM,KAAK,GAAI,KAA6B,CAAC,KAAK,CAAC;IACnD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACrC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,UAAkB;IAC9C,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,CAAC,UAAU;QAAE,OAAO,GAAG,CAAC;IAC9D,KAAK,MAAM,IAAI,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,SAAS;QAC7D,IAAI,CAAM,CAAC;QACX,IAAI,CAAC;YAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,SAAS;QAAC,CAAC;QACjD,MAAM,OAAO,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;QACpC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC3B,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;gBACxB,IAAI,CAAC,EAAE,IAAI,KAAK,UAAU;oBAAE,KAAK,MAAM,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC;wBAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACrF,CAAC;QACH,CAAC;QACD,MAAM,IAAI,GAAG,CAAC,EAAE,aAAa,EAAE,WAAW,CAAC;QAC3C,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,KAAK,MAAM,CAAC,IAAI,IAAI;gBAAE,IAAI,OAAO,CAAC,EAAE,IAAI,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI;oBAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
package/dist/server.js CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import express from "express";
12
12
  import { WebSocketServer, WebSocket } from "ws";
13
13
  import { detectDialog, dialogKey, isResumeSummaryDialog, findSessionId, lastPromptAt, listSessions, loadHistory, resumedTurnStart, transcriptFilePath, } from "./extract.js";
14
- import { fileCard, sentFilePaths, contentTypeFor, isImageFile } from "./download.js";
14
+ import { fileCard, sentFilePaths, contentTypeFor, isImageFile, isVideoFile, parseByteRange } from "./download.js";
15
15
  import { OFFER_TOOL, filesFileFor, loadOffers, saveOffers, offerVerdict, withOffer, offeredPaths, refusalMessage, statForOffer, } from "./files.js";
16
16
  // Same implementation as the web client's preview (plain JS, loaded as is by
17
17
  // the browser): one source for reading the in-flight text off the screen.
@@ -39,6 +39,7 @@ import { getUsage } from "./usage.js";
39
39
  import { loadChannels, loadGroups, saveGroups, upsertChannel, removeChannel, mergeClientChannels, channelWriteAllowed, profilePatch, loadTgGroup, saveTgGroup, isHomeChannel, homeAdoptionTarget, resolveSessionTarget, resolveResumeId, resumeTarget, } from "./channels.js";
40
40
  import { deliverWithRetry } from "./reload.js";
41
41
  import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, announceLoggedOut, resetLoggedOutNotice, attachmentPrompt, pasteFileName, MEDIA_DIR, } from "./telegram.js";
42
+ import { transcribe, audioClipExtension } from "./whisper.js";
42
43
  import { cancelPairingCode, issuePairingCode, pairingStatus } from "./tg-pairing.js";
43
44
  import { migrateTgBindings } from "./channels.js";
44
45
  import { instanceKey } from "./paths.js";
@@ -1267,6 +1268,50 @@ app.post("/paste", express.raw({ type: () => true, limit: PASTE_LIMIT }), (req,
1267
1268
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
1268
1269
  }
1269
1270
  });
1271
+ /**
1272
+ * Speech-to-text for the composer's microphone button. The browser records a
1273
+ * short clip (MediaRecorder β†’ webm/opus) and POSTs the raw bytes here; we run
1274
+ * the SAME local whisper.cpp pipeline the Telegram voice path uses
1275
+ * (`transcribe`) and return the text for the client to drop into the composer.
1276
+ *
1277
+ * The text is NEVER auto-sent β€” it lands in the editable composer so the user
1278
+ * reads it before acting, because a misheard instruction acted on in silence is
1279
+ * worse than a voice note that was ignored (the reason `heardNotice` exists).
1280
+ *
1281
+ * `requestFromBrowser` like `/paste`: this writes a file and records the mic, a
1282
+ * browser-only capability with no business being reachable by a script on
1283
+ * loopback. The clip is TRANSIENT β€” written to the OS temp dir and removed in
1284
+ * `finally`, never kept in `MEDIA_DIR`: it is spoken input, not a file to hand
1285
+ * the agent.
1286
+ *
1287
+ * The first call on a fresh instance provisions whisper (ffmpeg / model / build
1288
+ * via `ensureWhisper` inside `transcribe`), which can take minutes; the client
1289
+ * shows a working state and we simply wait. The French whisper notices are kept
1290
+ * server-side β€” the client maps `kind` to its own English UI copy, so the web
1291
+ * stays English while Telegram keeps its spoken-language notices.
1292
+ */
1293
+ app.post("/transcribe", express.raw({ type: () => true, limit: PASTE_LIMIT }), async (req, res) => {
1294
+ if (!requestFromBrowser(req))
1295
+ return res.status(403).json({ error: "same-origin browser only" });
1296
+ const body = req.body;
1297
+ if (!Buffer.isBuffer(body) || !body.length)
1298
+ return res.status(400).json({ error: "empty body" });
1299
+ const ext = audioClipExtension(String(req.headers["content-type"] ?? ""));
1300
+ const tmp = path.join(os.tmpdir(), `shadok-stt-${randomUUID()}.${ext}`);
1301
+ try {
1302
+ await fs.promises.writeFile(tmp, body);
1303
+ const out = await transcribe(tmp);
1304
+ if (out.ok)
1305
+ return res.json({ ok: true, text: out.text });
1306
+ return res.json({ ok: false, kind: out.kind });
1307
+ }
1308
+ catch (e) {
1309
+ return res.status(500).json({ ok: false, error: e instanceof Error ? e.message : String(e) });
1310
+ }
1311
+ finally {
1312
+ await fs.promises.rm(tmp, { force: true }).catch(() => { });
1313
+ }
1314
+ });
1270
1315
  app.use(express.static(path.join(__dirname, "..", "public")));
1271
1316
  // Markdown parser served locally (history rendering on the client).
1272
1317
  // Resolved through require.resolve, not a hardcoded path: in the npm install
@@ -3030,6 +3075,38 @@ function historyWithOffers(turns, sessionId) {
3030
3075
  out.push(fileTurns[oi++]);
3031
3076
  return out;
3032
3077
  }
3078
+ // Stream a vetted file back to the browser, honouring a single `Range` so a
3079
+ // <video> can seek and so Safari plays one at all (it demands a 206 before it
3080
+ // will start). Images/PDF/etc. get the same path β€” Range is harmless there and
3081
+ // helps a resumed download. A raster image or a video is served `inline` (the
3082
+ // chat's <img>/<video> renders it, and opening the URL shows it); everything
3083
+ // else β€” including SVG/HTML β€” downloads, under the same nosniff + sandbox CSP.
3084
+ function serveStaticFile(req, res, filePath, stat, name) {
3085
+ const ct = contentTypeFor(name);
3086
+ const inline = (isImageFile(name) && ct !== "image/svg+xml") || isVideoFile(name);
3087
+ res.setHeader("Content-Type", ct);
3088
+ res.setHeader("X-Content-Type-Options", "nosniff");
3089
+ res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
3090
+ res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${name.replace(/["\\]/g, "_")}"`);
3091
+ res.setHeader("Accept-Ranges", "bytes");
3092
+ const range = parseByteRange(req.headers.range, stat.size);
3093
+ if (range === "unsatisfiable") {
3094
+ res.setHeader("Content-Range", `bytes */${stat.size}`);
3095
+ res.status(416).end();
3096
+ return;
3097
+ }
3098
+ const onErr = () => { if (!res.headersSent)
3099
+ res.status(500); res.end(); };
3100
+ if (range) {
3101
+ res.status(206);
3102
+ res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
3103
+ res.setHeader("Content-Length", String(range.end - range.start + 1));
3104
+ fs.createReadStream(filePath, { start: range.start, end: range.end }).on("error", onErr).pipe(res);
3105
+ return;
3106
+ }
3107
+ res.setHeader("Content-Length", String(stat.size));
3108
+ fs.createReadStream(filePath).on("error", onErr).pipe(res);
3109
+ }
3033
3110
  app.get("/download", (req, res) => {
3034
3111
  const session = String(req.query.session ?? "");
3035
3112
  const wanted = String(req.query.path ?? "");
@@ -3066,17 +3143,7 @@ app.get("/download", (req, res) => {
3066
3143
  catch {
3067
3144
  return res.status(404).type("text").send("file no longer on disk");
3068
3145
  }
3069
- const name = path.basename(wanted);
3070
- res.setHeader("Content-Type", contentTypeFor(name));
3071
- res.setHeader("Content-Length", String(stat.size));
3072
- res.setHeader("X-Content-Type-Options", "nosniff");
3073
- res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
3074
- // A raster image is served inline (so opening it shows the image); everything
3075
- // else β€” including SVG/HTML β€” downloads. The <img> in the chat renders either.
3076
- const inline = isImageFile(name) && contentTypeFor(name) !== "image/svg+xml";
3077
- res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${name.replace(/["\\]/g, "_")}"`);
3078
- fs.createReadStream(wanted).on("error", () => { if (!res.headersSent)
3079
- res.status(500); res.end(); }).pipe(res);
3146
+ serveStaticFile(req, res, wanted, stat, path.basename(wanted));
3080
3147
  });
3081
3148
  // Serve a file the USER dropped / pasted (or a Telegram attachment) β€” they all
3082
3149
  // land in MEDIA_DIR, referenced in the chat as `[Attached image: <path>]`. The
@@ -3100,14 +3167,7 @@ app.get("/media/:name", (req, res) => {
3100
3167
  catch {
3101
3168
  return res.status(404).type("text").send("no such media");
3102
3169
  }
3103
- res.setHeader("Content-Type", contentTypeFor(base));
3104
- res.setHeader("Content-Length", String(stat.size));
3105
- res.setHeader("X-Content-Type-Options", "nosniff");
3106
- res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
3107
- const inline = isImageFile(base) && contentTypeFor(base) !== "image/svg+xml";
3108
- res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename="${base.replace(/["\\]/g, "_")}"`);
3109
- fs.createReadStream(file).on("error", () => { if (!res.headersSent)
3110
- res.status(500); res.end(); }).pipe(res);
3170
+ serveStaticFile(req, res, file, stat, base);
3111
3171
  });
3112
3172
  // Install (or repair) the global Claude Code CLI on demand β€” `npm i -g
3113
3173
  // @anthropic-ai/claude-code`. The cockpit shows this button when an agent's TUI