@circuitwall/jarela 1.2.0 → 1.4.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 (96) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +3 -3
  23. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +3 -3
  25. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +3 -3
  27. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +3 -3
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +3 -3
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +218 -7
  33. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/events/route.js +3 -3
  35. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +8 -1
  37. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js.map +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +8 -1
  39. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js.map +1 -1
  40. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +8 -1
  41. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js.map +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +8 -1
  43. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js.map +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
  45. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
  46. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +37 -3
  47. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js.map +1 -1
  48. package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
  49. package/.next/standalone/.next/server/app/page.js +10 -18
  50. package/.next/standalone/.next/server/app/page.js.map +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/chunks/210.js +1 -1
  54. package/.next/standalone/.next/server/chunks/239.js +5335 -5230
  55. package/.next/standalone/.next/server/chunks/239.js.map +1 -1
  56. package/.next/standalone/.next/server/chunks/{1683.js → 241.js} +210 -36
  57. package/.next/standalone/.next/server/chunks/241.js.map +1 -0
  58. package/.next/standalone/.next/server/chunks/{8135.js → 2539.js} +218 -36
  59. package/.next/standalone/.next/server/chunks/2539.js.map +1 -0
  60. package/.next/standalone/.next/server/chunks/4631.js +218 -7
  61. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  62. package/.next/standalone/.next/server/chunks/8866.js +13389 -13073
  63. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  64. package/.next/standalone/.next/server/chunks/9032.js +1 -1
  65. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  66. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  67. package/.next/standalone/.next/server/pages/404.html +1 -1
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  70. package/.next/standalone/.next/static/chunks/app/{page-62e0d5f2404b403b.js → page-74846c864241b96d.js} +11 -19
  71. package/.next/standalone/.next/static/chunks/app/page-74846c864241b96d.js.map +1 -0
  72. package/.next/standalone/package.json +2 -1
  73. package/CHANGELOG.md +98 -0
  74. package/README.md +51 -26
  75. package/components/chat/InputBar.tsx +10 -1
  76. package/components/ui/BootScreen.tsx +0 -10
  77. package/lib/agents/agent-turn.ts +9 -0
  78. package/lib/agents/prepare/request.ts +9 -0
  79. package/lib/agents/run-thread.ts +9 -1
  80. package/lib/api/extension-turn.ts +7 -0
  81. package/lib/api/page-capture.test.ts +58 -0
  82. package/lib/api/page-capture.ts +31 -1
  83. package/lib/bridges/attachment-store.test.ts +440 -0
  84. package/lib/bridges/attachment-store.ts +184 -0
  85. package/lib/bridges/whatsapp.ts +50 -32
  86. package/lib/tools/async-results-tool.ts +114 -0
  87. package/lib/tools/async-results.test.ts +481 -0
  88. package/lib/tools/async-results.ts +165 -0
  89. package/lib/tools/builtins.ts +1 -0
  90. package/lib/tools/wallclock.ts +114 -8
  91. package/package.json +2 -1
  92. package/.next/standalone/.next/server/chunks/1683.js.map +0 -1
  93. package/.next/standalone/.next/server/chunks/8135.js.map +0 -1
  94. package/.next/standalone/.next/static/chunks/app/page-62e0d5f2404b403b.js.map +0 -1
  95. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → AV5AO0yTRABo-NgwxhDe7}/_buildManifest.js +0 -0
  96. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → AV5AO0yTRABo-NgwxhDe7}/_ssgManifest.js +0 -0
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, existsSync, statSync, utimesSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+
8
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), "jarela-bridge-att-"));
9
+ process.env.JARELA_DB_DIR = tmpRoot;
10
+
11
+ // Imported lazily so the JARELA_DB_DIR override above is in place before
12
+ // getDataDir() caches its result.
13
+ const {
14
+ saveBridgeAttachment,
15
+ pruneBridgeAttachments,
16
+ shouldInline,
17
+ bridgeAttachmentsRoot,
18
+ DEFAULT_INLINE_LIMIT_BYTES,
19
+ BRIDGE_ATTACHMENTS_DIRNAME,
20
+ INLINE_MIME_PREFIXES,
21
+ } = await import("./attachment-store");
22
+
23
+ beforeEach(() => {
24
+ // Clear any prior attachments between tests.
25
+ try {
26
+ rmSync(bridgeAttachmentsRoot(), { recursive: true, force: true });
27
+ } catch { /* */ }
28
+ });
29
+
30
+ afterEach(() => {
31
+ try {
32
+ rmSync(bridgeAttachmentsRoot(), { recursive: true, force: true });
33
+ } catch { /* */ }
34
+ });
35
+
36
+ describe("shouldInline", () => {
37
+ it("inlines small images", () => {
38
+ expect(shouldInline("image/jpeg", 64_000)).toBe(true);
39
+ expect(shouldInline("image/webp", DEFAULT_INLINE_LIMIT_BYTES)).toBe(true);
40
+ expect(shouldInline("image/png", 0)).toBe(true);
41
+ });
42
+
43
+ it("spills oversized images", () => {
44
+ expect(shouldInline("image/png", DEFAULT_INLINE_LIMIT_BYTES + 1)).toBe(false);
45
+ });
46
+
47
+ it("spills non-image media regardless of size", () => {
48
+ expect(shouldInline("application/pdf", 1024)).toBe(false);
49
+ expect(shouldInline("audio/ogg", 1024)).toBe(false);
50
+ expect(shouldInline("video/mp4", 1024)).toBe(false);
51
+ expect(shouldInline("text/plain", 100)).toBe(false);
52
+ });
53
+
54
+ it("honours a caller-supplied limit override", () => {
55
+ expect(shouldInline("image/jpeg", 5_000, 10_000)).toBe(true);
56
+ expect(shouldInline("image/jpeg", 15_000, 10_000)).toBe(false);
57
+ });
58
+
59
+ it("only inlines media types whose prefix is in the allowlist", () => {
60
+ // Sanity-pin the policy so a future change here is intentional.
61
+ expect(INLINE_MIME_PREFIXES).toEqual(["image/"]);
62
+ });
63
+ });
64
+
65
+ describe("saveBridgeAttachment", () => {
66
+ it("writes the buffer under <data>/bridge-attachments/<bridge>/<date>/", async () => {
67
+ const buf = Buffer.from("hello world");
68
+ const saved = await saveBridgeAttachment({
69
+ bridge_id: "b1",
70
+ filename: "hello.txt",
71
+ media_type: "text/plain",
72
+ message_id: "msg-1",
73
+ buffer: buf,
74
+ });
75
+ expect(existsSync(saved.abs_path)).toBe(true);
76
+ expect(saved.size).toBe(buf.length);
77
+ expect(saved.sha256).toMatch(/^[0-9a-f]{64}$/);
78
+ const onDisk = await readFile(saved.abs_path);
79
+ expect(onDisk.equals(buf)).toBe(true);
80
+ // Path must live under the bridge-specific subtree.
81
+ expect(saved.abs_path.startsWith(path.join(bridgeAttachmentsRoot(), "b1"))).toBe(true);
82
+ });
83
+
84
+ it("sha256 matches an independent hash of the buffer", async () => {
85
+ const buf = crypto.randomBytes(4096);
86
+ const saved = await saveBridgeAttachment({
87
+ bridge_id: "b1",
88
+ filename: "rand.bin",
89
+ media_type: "application/octet-stream",
90
+ buffer: buf,
91
+ });
92
+ const expected = crypto.createHash("sha256").update(buf).digest("hex");
93
+ expect(saved.sha256).toBe(expected);
94
+ });
95
+
96
+ it("uses YYYY-MM-DD format for the day directory", async () => {
97
+ const saved = await saveBridgeAttachment({
98
+ bridge_id: "b1",
99
+ filename: "f.bin",
100
+ media_type: "application/octet-stream",
101
+ message_id: "m",
102
+ buffer: Buffer.from("x"),
103
+ });
104
+ const dayDir = path.basename(path.dirname(saved.abs_path));
105
+ expect(dayDir).toMatch(/^\d{4}-\d{2}-\d{2}$/);
106
+ });
107
+
108
+ it("sanitises path separators and control chars in filenames", async () => {
109
+ const buf = Buffer.from("payload");
110
+ const saved = await saveBridgeAttachment({
111
+ bridge_id: "b1",
112
+ filename: "../../etc/passwd",
113
+ media_type: "text/plain",
114
+ message_id: "msg-evil",
115
+ buffer: buf,
116
+ });
117
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
118
+ expect(rel.startsWith("..")).toBe(false);
119
+ expect(path.basename(saved.abs_path)).not.toContain("/");
120
+ expect(path.basename(saved.abs_path)).not.toContain("\\");
121
+ // The "passwd" leaf survives but it's just a filename, not a traversal.
122
+ expect(saved.abs_path.includes("etc" + path.sep + "passwd")).toBe(false);
123
+ });
124
+
125
+ it("strips Windows-hostile filename characters (* ? \" < > | :)", async () => {
126
+ const saved = await saveBridgeAttachment({
127
+ bridge_id: "b1",
128
+ filename: 'a*b?c"d<e>f|g:h.txt',
129
+ media_type: "text/plain",
130
+ message_id: "m",
131
+ buffer: Buffer.from("x"),
132
+ });
133
+ const base = path.basename(saved.abs_path);
134
+ expect(base).not.toMatch(/[*?"<>|:]/);
135
+ });
136
+
137
+ it("strips ASCII control characters from filenames", async () => {
138
+ const saved = await saveBridgeAttachment({
139
+ bridge_id: "b1",
140
+ filename: "a\x00b\x07c\x1f.bin",
141
+ media_type: "application/octet-stream",
142
+ message_id: "m",
143
+ buffer: Buffer.from("x"),
144
+ });
145
+ const base = path.basename(saved.abs_path);
146
+ // No control chars should leak through.
147
+ expect(/[\x00-\x1f]/.test(base)).toBe(false);
148
+ });
149
+
150
+ it("truncates very long filenames while preserving the extension", async () => {
151
+ const longName = "x".repeat(500) + ".pdf";
152
+ const saved = await saveBridgeAttachment({
153
+ bridge_id: "b1",
154
+ filename: longName,
155
+ media_type: "application/pdf",
156
+ message_id: "m",
157
+ buffer: Buffer.from("x"),
158
+ });
159
+ const base = path.basename(saved.abs_path);
160
+ // <id>-<truncated>; <truncated> portion is ≤ 80 chars.
161
+ const truncated = base.replace(/^m-/, "");
162
+ expect(truncated.length).toBeLessThanOrEqual(80);
163
+ expect(truncated.endsWith(".pdf")).toBe(true);
164
+ });
165
+
166
+ it("sanitises a hostile bridge_id", async () => {
167
+ const buf = Buffer.from("x");
168
+ const saved = await saveBridgeAttachment({
169
+ bridge_id: "../etc",
170
+ filename: "f.bin",
171
+ media_type: "application/octet-stream",
172
+ message_id: "m",
173
+ buffer: buf,
174
+ });
175
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
176
+ expect(rel.startsWith("..")).toBe(false);
177
+ // The traversal segment "../etc" collapses to "___etc" (3 chars replaced).
178
+ const bridgeDir = rel.split(path.sep)[0];
179
+ expect(bridgeDir).not.toContain("..");
180
+ expect(bridgeDir).not.toContain("/");
181
+ });
182
+
183
+ it("sanitises a hostile message_id (path traversal characters)", async () => {
184
+ const saved = await saveBridgeAttachment({
185
+ bridge_id: "b1",
186
+ filename: "f.bin",
187
+ media_type: "application/octet-stream",
188
+ message_id: "../../escape",
189
+ buffer: Buffer.from("x"),
190
+ });
191
+ const rel = path.relative(bridgeAttachmentsRoot(), saved.abs_path);
192
+ expect(rel.startsWith("..")).toBe(false);
193
+ });
194
+
195
+ it("generates a filename when none is given", async () => {
196
+ const saved = await saveBridgeAttachment({
197
+ bridge_id: "b1",
198
+ filename: null,
199
+ media_type: "application/octet-stream",
200
+ buffer: Buffer.from("x"),
201
+ });
202
+ expect(path.basename(saved.abs_path)).toMatch(/attachment$/);
203
+ });
204
+
205
+ it("generates a filename when one is whitespace-only", async () => {
206
+ const saved = await saveBridgeAttachment({
207
+ bridge_id: "b1",
208
+ filename: " \t\n ",
209
+ media_type: "application/octet-stream",
210
+ buffer: Buffer.from("x"),
211
+ });
212
+ expect(path.basename(saved.abs_path)).toMatch(/attachment$/);
213
+ });
214
+
215
+ it("generates a random id when message_id is missing", async () => {
216
+ const a = await saveBridgeAttachment({
217
+ bridge_id: "b1",
218
+ filename: "f.bin",
219
+ media_type: "application/octet-stream",
220
+ buffer: Buffer.from("a"),
221
+ });
222
+ const b = await saveBridgeAttachment({
223
+ bridge_id: "b1",
224
+ filename: "f.bin",
225
+ media_type: "application/octet-stream",
226
+ buffer: Buffer.from("b"),
227
+ });
228
+ expect(path.basename(a.abs_path)).not.toBe(path.basename(b.abs_path));
229
+ });
230
+
231
+ it("is idempotent on (bridge_id, message_id, filename) — re-save overwrites", async () => {
232
+ const first = await saveBridgeAttachment({
233
+ bridge_id: "b1",
234
+ filename: "doc.pdf",
235
+ media_type: "application/pdf",
236
+ message_id: "M1",
237
+ buffer: Buffer.from("first"),
238
+ });
239
+ const second = await saveBridgeAttachment({
240
+ bridge_id: "b1",
241
+ filename: "doc.pdf",
242
+ media_type: "application/pdf",
243
+ message_id: "M1",
244
+ buffer: Buffer.from("second-longer"),
245
+ });
246
+ expect(first.abs_path).toBe(second.abs_path);
247
+ const onDisk = await readFile(first.abs_path);
248
+ expect(onDisk.toString("utf8")).toBe("second-longer");
249
+ expect(second.size).toBe(Buffer.byteLength("second-longer"));
250
+ });
251
+
252
+ it("isolates bridges from each other in separate subtrees", async () => {
253
+ const a = await saveBridgeAttachment({
254
+ bridge_id: "bridge-a",
255
+ filename: "x.bin",
256
+ media_type: "application/octet-stream",
257
+ message_id: "m",
258
+ buffer: Buffer.from("a"),
259
+ });
260
+ const b = await saveBridgeAttachment({
261
+ bridge_id: "bridge-b",
262
+ filename: "x.bin",
263
+ media_type: "application/octet-stream",
264
+ message_id: "m",
265
+ buffer: Buffer.from("b"),
266
+ });
267
+ expect(path.dirname(path.dirname(a.abs_path))).not.toBe(path.dirname(path.dirname(b.abs_path)));
268
+ expect(a.abs_path).not.toBe(b.abs_path);
269
+ });
270
+
271
+ it("handles a zero-byte buffer", async () => {
272
+ const saved = await saveBridgeAttachment({
273
+ bridge_id: "b1",
274
+ filename: "empty.bin",
275
+ media_type: "application/octet-stream",
276
+ message_id: "m",
277
+ buffer: Buffer.alloc(0),
278
+ });
279
+ expect(saved.size).toBe(0);
280
+ expect(existsSync(saved.abs_path)).toBe(true);
281
+ const stat = statSync(saved.abs_path);
282
+ expect(stat.size).toBe(0);
283
+ });
284
+
285
+ it("handles a binary buffer with non-utf8 bytes losslessly", async () => {
286
+ const buf = Buffer.from([0x00, 0xff, 0xfe, 0xfd, 0x80, 0x81]);
287
+ const saved = await saveBridgeAttachment({
288
+ bridge_id: "b1",
289
+ filename: "bin.dat",
290
+ media_type: "application/octet-stream",
291
+ message_id: "m",
292
+ buffer: buf,
293
+ });
294
+ const onDisk = await readFile(saved.abs_path);
295
+ expect(onDisk.equals(buf)).toBe(true);
296
+ });
297
+
298
+ it("survives concurrent saves to the same bridge", async () => {
299
+ const saves = Array.from({ length: 20 }, (_, i) =>
300
+ saveBridgeAttachment({
301
+ bridge_id: "b1",
302
+ filename: `f-${i}.bin`,
303
+ media_type: "application/octet-stream",
304
+ message_id: `m-${i}`,
305
+ buffer: Buffer.from(`payload-${i}`),
306
+ }),
307
+ );
308
+ const results = await Promise.all(saves);
309
+ const paths = new Set(results.map((r) => r.abs_path));
310
+ expect(paths.size).toBe(20);
311
+ for (const r of results) {
312
+ expect(existsSync(r.abs_path)).toBe(true);
313
+ }
314
+ });
315
+
316
+ it("exposes a constants surface used by callers", () => {
317
+ expect(BRIDGE_ATTACHMENTS_DIRNAME).toBe("bridge-attachments");
318
+ expect(DEFAULT_INLINE_LIMIT_BYTES).toBe(1 * 1024 * 1024);
319
+ expect(bridgeAttachmentsRoot().endsWith(BRIDGE_ATTACHMENTS_DIRNAME)).toBe(true);
320
+ });
321
+ });
322
+
323
+ describe("pruneBridgeAttachments", () => {
324
+ it("removes files older than maxAgeMs and keeps fresh ones", async () => {
325
+ const old = await saveBridgeAttachment({
326
+ bridge_id: "b1",
327
+ filename: "old.bin",
328
+ media_type: "application/octet-stream",
329
+ message_id: "old",
330
+ buffer: Buffer.from("aaa"),
331
+ });
332
+ const fresh = await saveBridgeAttachment({
333
+ bridge_id: "b1",
334
+ filename: "fresh.bin",
335
+ media_type: "application/octet-stream",
336
+ message_id: "fresh",
337
+ buffer: Buffer.from("bbb"),
338
+ });
339
+ // Backdate the old file by 2 hours.
340
+ const past = (Date.now() - 2 * 60 * 60 * 1000) / 1000;
341
+ utimesSync(old.abs_path, past, past);
342
+
343
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
344
+ expect(res.removed_files).toBe(1);
345
+ expect(res.freed_bytes).toBe(3); // "aaa".length
346
+ expect(existsSync(old.abs_path)).toBe(false);
347
+ expect(existsSync(fresh.abs_path)).toBe(true);
348
+ });
349
+
350
+ it("removes empty day and bridge directories after purging files", async () => {
351
+ const saved = await saveBridgeAttachment({
352
+ bridge_id: "ghost-bridge",
353
+ filename: "only.bin",
354
+ media_type: "application/octet-stream",
355
+ message_id: "m",
356
+ buffer: Buffer.from("x"),
357
+ });
358
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
359
+ utimesSync(saved.abs_path, past, past);
360
+
361
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
362
+ expect(res.removed_files).toBe(1);
363
+ expect(res.removed_dirs).toBeGreaterThanOrEqual(2); // day dir + bridge dir
364
+ expect(existsSync(path.dirname(saved.abs_path))).toBe(false);
365
+ expect(existsSync(path.dirname(path.dirname(saved.abs_path)))).toBe(false);
366
+ });
367
+
368
+ it("keeps the bridge directory if any fresh files remain in any day folder", async () => {
369
+ const stale = await saveBridgeAttachment({
370
+ bridge_id: "mixed",
371
+ filename: "stale.bin",
372
+ media_type: "application/octet-stream",
373
+ message_id: "stale",
374
+ buffer: Buffer.from("x"),
375
+ });
376
+ const fresh = await saveBridgeAttachment({
377
+ bridge_id: "mixed",
378
+ filename: "fresh.bin",
379
+ media_type: "application/octet-stream",
380
+ message_id: "fresh",
381
+ buffer: Buffer.from("y"),
382
+ });
383
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
384
+ utimesSync(stale.abs_path, past, past);
385
+
386
+ await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
387
+ expect(existsSync(fresh.abs_path)).toBe(true);
388
+ expect(existsSync(path.dirname(fresh.abs_path))).toBe(true);
389
+ });
390
+
391
+ it("is a no-op when the attachments dir does not exist", async () => {
392
+ const res = await pruneBridgeAttachments({ maxAgeMs: 1 });
393
+ expect(res).toEqual({ removed_files: 0, removed_dirs: 0, freed_bytes: 0 });
394
+ });
395
+
396
+ it("treats maxAgeMs=0 as 'expire everything finished'", async () => {
397
+ await saveBridgeAttachment({
398
+ bridge_id: "b1",
399
+ filename: "a.bin",
400
+ media_type: "application/octet-stream",
401
+ message_id: "a",
402
+ buffer: Buffer.from("a"),
403
+ });
404
+ // Backdate by 1ms so even maxAgeMs=0 expires it.
405
+ const saved = await saveBridgeAttachment({
406
+ bridge_id: "b1",
407
+ filename: "b.bin",
408
+ media_type: "application/octet-stream",
409
+ message_id: "b",
410
+ buffer: Buffer.from("b"),
411
+ });
412
+ const past = (Date.now() - 1000) / 1000;
413
+ utimesSync(saved.abs_path, past, past);
414
+ const res = await pruneBridgeAttachments({ maxAgeMs: 0 });
415
+ expect(res.removed_files).toBeGreaterThanOrEqual(1);
416
+ });
417
+
418
+ it("ignores non-file entries inside day folders", async () => {
419
+ // Manually create a stray subdirectory under a day folder — prune
420
+ // must not crash and must not delete it.
421
+ const saved = await saveBridgeAttachment({
422
+ bridge_id: "b1",
423
+ filename: "f.bin",
424
+ media_type: "application/octet-stream",
425
+ message_id: "m",
426
+ buffer: Buffer.from("x"),
427
+ });
428
+ const dayDir = path.dirname(saved.abs_path);
429
+ const strayDir = path.join(dayDir, "stray-subdir");
430
+ mkdirSync(strayDir);
431
+ writeFileSync(path.join(strayDir, "child.txt"), "x");
432
+ const past = (Date.now() - 24 * 60 * 60 * 1000) / 1000;
433
+ utimesSync(saved.abs_path, past, past);
434
+
435
+ const res = await pruneBridgeAttachments({ maxAgeMs: 60 * 60 * 1000 });
436
+ expect(res.removed_files).toBe(1);
437
+ expect(existsSync(strayDir)).toBe(true);
438
+ });
439
+ });
440
+
@@ -0,0 +1,184 @@
1
+ // Bridge attachment spill store.
2
+ //
3
+ // Inbound bridge messages (WhatsApp, future Telegram/Slack, etc.) can
4
+ // carry files that are too large or too opaque to inline straight into
5
+ // the LLM context: PDFs, spreadsheets, archives, multi-minute audio,
6
+ // short videos. We persist those bytes under the user's Jarela data
7
+ // dir and hand the agent a text pointer (`saved locally at <abs>`) so
8
+ // it can decide what to do — typically calling `file_read` on the path.
9
+ //
10
+ // Small media (e.g. ≤ 1 MB images) keep going inline so vision-capable
11
+ // models can still describe them in one round-trip without bouncing
12
+ // through disk.
13
+ //
14
+ // Layout: <dataDir>/bridge-attachments/<bridge_id>/<YYYY-MM-DD>/<id>-<safe-name>
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import crypto from "node:crypto";
19
+ import { getDataDir } from "@/lib/db/data-dir";
20
+
21
+ export const BRIDGE_ATTACHMENTS_DIRNAME = "bridge-attachments";
22
+
23
+ /** Default inline cap for media we still want the LLM to see directly. */
24
+ export const DEFAULT_INLINE_LIMIT_BYTES = 1 * 1024 * 1024;
25
+
26
+ /** Media types kept inline when small. Everything else is always spilled. */
27
+ export const INLINE_MIME_PREFIXES: readonly string[] = ["image/"];
28
+
29
+ export interface SaveAttachmentInput {
30
+ bridge_id: string;
31
+ /** Best-effort source filename (may be missing — we'll synthesize one). */
32
+ filename: string | null;
33
+ media_type: string;
34
+ /** Optional adapter-side message id used to make filenames deterministic. */
35
+ message_id?: string | null;
36
+ buffer: Buffer;
37
+ }
38
+
39
+ export interface SavedAttachment {
40
+ abs_path: string;
41
+ size: number;
42
+ sha256: string;
43
+ }
44
+
45
+ /**
46
+ * Decide whether a buffer should be inlined as a `ContentPart` or
47
+ * spilled to disk. Keeps small images inline so vision models still
48
+ * work out-of-the-box; spills everything else.
49
+ */
50
+ export function shouldInline(media_type: string, size: number, limit = DEFAULT_INLINE_LIMIT_BYTES): boolean {
51
+ if (size > limit) return false;
52
+ return INLINE_MIME_PREFIXES.some((p) => media_type.startsWith(p));
53
+ }
54
+
55
+ function baseDir(): string {
56
+ return path.join(getDataDir(), BRIDGE_ATTACHMENTS_DIRNAME);
57
+ }
58
+
59
+ function todayDir(): string {
60
+ // Local-time YYYY-MM-DD keeps directories human-scannable in the
61
+ // user's timezone. Cross-day boundary noise isn't worth UTC.
62
+ const d = new Date();
63
+ const yyyy = d.getFullYear();
64
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
65
+ const dd = String(d.getDate()).padStart(2, "0");
66
+ return `${yyyy}-${mm}-${dd}`;
67
+ }
68
+
69
+ // Strip path separators, control chars, and anything that would
70
+ // surprise a Windows shell. Truncate so long captions can't blow
71
+ // MAX_PATH (260) on Win32.
72
+ function safeFilename(name: string | null, fallback: string): string {
73
+ const raw = (name ?? "").trim() || fallback;
74
+ let s = raw.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_");
75
+ s = s.replace(/\s+/g, " ").trim();
76
+ if (s.length > 80) {
77
+ const ext = path.extname(s).slice(0, 12);
78
+ s = s.slice(0, 80 - ext.length) + ext;
79
+ }
80
+ return s || fallback;
81
+ }
82
+
83
+ function safeBridgeId(id: string): string {
84
+ // bridge_id is internally generated but be defensive — refuse anything
85
+ // that could escape the attachments dir.
86
+ return id.replace(/[^A-Za-z0-9_-]/g, "_");
87
+ }
88
+
89
+ /**
90
+ * Persist a bridge attachment to disk and return its absolute path.
91
+ *
92
+ * Idempotent on (bridge_id, message_id, filename): re-saving the same
93
+ * inbound message overwrites the same file rather than fanning out
94
+ * duplicates on adapter restart.
95
+ */
96
+ export async function saveBridgeAttachment(input: SaveAttachmentInput): Promise<SavedAttachment> {
97
+ const dir = path.join(baseDir(), safeBridgeId(input.bridge_id), todayDir());
98
+ await fs.mkdir(dir, { recursive: true });
99
+
100
+ // Deterministic id when the adapter gave us a message id; fall back
101
+ // to a short random hex so concurrent unrelated messages can't collide.
102
+ const idPart = (input.message_id ?? "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32)
103
+ || crypto.randomBytes(6).toString("hex");
104
+ const fname = `${idPart}-${safeFilename(input.filename, "attachment")}`;
105
+ const abs = path.join(dir, fname);
106
+
107
+ await fs.writeFile(abs, input.buffer);
108
+
109
+ const sha256 = crypto.createHash("sha256").update(input.buffer).digest("hex");
110
+ return { abs_path: abs, size: input.buffer.length, sha256 };
111
+ }
112
+
113
+ export interface PruneOptions {
114
+ /** Files older than this many ms are deleted. */
115
+ maxAgeMs: number;
116
+ }
117
+
118
+ export interface PruneResult {
119
+ removed_files: number;
120
+ removed_dirs: number;
121
+ freed_bytes: number;
122
+ }
123
+
124
+ /**
125
+ * Delete bridge attachments older than `maxAgeMs`. Best-effort: a
126
+ * locked or vanished file is skipped silently. Empty per-day and
127
+ * per-bridge directories are pruned afterwards so the tree doesn't
128
+ * accumulate empty husks.
129
+ */
130
+ export async function pruneBridgeAttachments(opts: PruneOptions): Promise<PruneResult> {
131
+ const root = baseDir();
132
+ const cutoff = Date.now() - Math.max(0, opts.maxAgeMs);
133
+ const result: PruneResult = { removed_files: 0, removed_dirs: 0, freed_bytes: 0 };
134
+
135
+ let bridges: string[] = [];
136
+ try { bridges = await fs.readdir(root); } catch { return result; }
137
+
138
+ for (const bridge of bridges) {
139
+ const bridgeDir = path.join(root, bridge);
140
+ let days: string[] = [];
141
+ try { days = await fs.readdir(bridgeDir); } catch { continue; }
142
+
143
+ for (const day of days) {
144
+ const dayDir = path.join(bridgeDir, day);
145
+ let files: string[] = [];
146
+ try { files = await fs.readdir(dayDir); } catch { continue; }
147
+
148
+ for (const f of files) {
149
+ const fp = path.join(dayDir, f);
150
+ try {
151
+ const st = await fs.stat(fp);
152
+ if (!st.isFile()) continue;
153
+ if (st.mtimeMs >= cutoff) continue;
154
+ await fs.unlink(fp);
155
+ result.removed_files++;
156
+ result.freed_bytes += st.size;
157
+ } catch { /* skip */ }
158
+ }
159
+
160
+ try {
161
+ const remaining = await fs.readdir(dayDir);
162
+ if (remaining.length === 0) {
163
+ await fs.rmdir(dayDir);
164
+ result.removed_dirs++;
165
+ }
166
+ } catch { /* skip */ }
167
+ }
168
+
169
+ try {
170
+ const remaining = await fs.readdir(bridgeDir);
171
+ if (remaining.length === 0) {
172
+ await fs.rmdir(bridgeDir);
173
+ result.removed_dirs++;
174
+ }
175
+ } catch { /* skip */ }
176
+ }
177
+
178
+ return result;
179
+ }
180
+
181
+ /** Test helper: absolute path to the root attachments dir. */
182
+ export function bridgeAttachmentsRoot(): string {
183
+ return baseDir();
184
+ }