@ixo/oracle-runtime-workers 0.37.2 → 0.38.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.
@@ -4,14 +4,21 @@
4
4
  * processAttachments`:
5
5
  *
6
6
  * 1. classify + route each attachment by the selected model's native input
7
- * capabilities (`send-native` / `parse-local` / `model-extract`);
7
+ * capabilities (`route.ts`: `send-native` / `parse-local` /
8
+ * `model-extract`) — the same rule for every source; a document whose
9
+ * declared size is over `NATIVE_DOCUMENT_MAX_BYTES` skips the native
10
+ * lane, one of unknown or smaller size is a native candidate;
8
11
  * 2. NATIVE lane: download, sniff, base64 into LangChain content blocks on
9
- * the human message; the original is archived to the sandbox in the
10
- * background. A failed download falls back to extraction so a bad
11
- * download never drops the whole message; a file that is itself refused
12
- * (empty, over the budget) is noted, and one whose content is not what it
13
- * claims goes to the extract lane with its bytes — never downloaded a
14
- * second time, never sent as a native block;
12
+ * the human message; an image is first downscaled for the model when the
13
+ * oracle binds Cloudflare Images (`resize.ts`), and the ORIGINAL is
14
+ * archived to the sandbox in the background. A failed download falls
15
+ * back to extraction so a bad download never drops the whole message; a
16
+ * file that is itself refused (empty, over the budget) is noted, and one
17
+ * whose content is not what it claims, a document whose downloaded
18
+ * length is over `NATIVE_DOCUMENT_MAX_BYTES` (the length decides: a
19
+ * declared size may be missing or wrong) or an image the resize step
20
+ * could not make fit (`dropped`) goes to the extract lane with its
21
+ * bytes — never downloaded a second time, never sent as a native block;
15
22
  * 3. EXTRACT lane: sequential, plain text read locally, everything else
16
23
  * described / transcribed / extracted by the helper model, then archived
17
24
  * to the sandbox — when the archive succeeds the text handed to the model
@@ -28,9 +35,13 @@
28
35
  * A channel file (`channelRef`, see `channel.ts`) is private: it is read
29
36
  * through the owner object's channel source and always goes to a model as
30
37
  * the bytes already loaded, never by URL; the model, the notes and the
31
- * stored metadata know it only by its `channel:` reference. It may be up to
32
- * the gateway's 50 MiB: one above `MAX_FILE_SIZE` is archived to the
33
- * sandbox and noted, never handed to a model.
38
+ * stored metadata know it only by its `channel:` reference. The gateway
39
+ * serves files of up to 50 MiB, but any file declared over `MAX_FILE_SIZE`
40
+ * is noted without being read: no model is handed it, and the sandbox
41
+ * archive refuses files over 25 MB (413), so reading it would only cost
42
+ * memory. A file the owner object reads while posting it to the session
43
+ * thread (`room-media.ts`) arrives as a pending copy in `deps.preloaded`: the
44
+ * turn waits for that one read instead of starting a second.
34
45
  */
35
46
  import type { MessageContent } from '@langchain/core/messages';
36
47
  import type { ModelInputCapabilities } from '../core/llm';
@@ -43,7 +54,6 @@ import {
43
54
  type NativeAttachment,
44
55
  } from './content-blocks';
45
56
  import {
46
- CHANNEL_MAX_FILE_BYTES,
47
57
  channelArchiveName,
48
58
  ChannelFileUnavailableError,
49
59
  type ChannelMediaSource,
@@ -55,6 +65,7 @@ import {
55
65
  loadAttachmentBytes,
56
66
  MAX_FILE_SIZE,
57
67
  MAX_TOTAL_SIZE,
68
+ untilAborted,
58
69
  type MatrixMediaSource,
59
70
  } from './download';
60
71
  import {
@@ -65,7 +76,8 @@ import {
65
76
  type ExtractionUsage,
66
77
  } from './extract';
67
78
  import { categorizeFile, verifyMagicBytes, type FileCategory } from './magic';
68
- import { routeAttachment } from './route';
79
+ import { resizeForModel } from './resize';
80
+ import { NATIVE_DOCUMENT_MAX_BYTES, routeAttachment } from './route';
69
81
  import {
70
82
  buildAnalysisMarkdown,
71
83
  uploadToSandbox,
@@ -90,10 +102,25 @@ export interface AttachmentPipelineDeps {
90
102
  source: MatrixMediaSource;
91
103
  /** Where `channelRef` files are read; absent = none can be read (noted). */
92
104
  channel?: ChannelMediaSource;
105
+ /**
106
+ * Channel files this turn is already reading, by `channel:` reference:
107
+ * each resolves with the verified bytes, or null when no copy came (the
108
+ * file is then read from `channel` as usual). A copy is counted against
109
+ * the budget like a download and serves only its own reference; the
110
+ * entry is removed when it is taken, so the map lets go of the bytes as
111
+ * the turn moves on. Never rejects (`room-media.ts`); the wait ends with
112
+ * `signal` too.
113
+ */
114
+ preloaded?: Map<
115
+ string,
116
+ Promise<{ bytes: Uint8Array; mimetype: string } | null>
117
+ >;
93
118
  /** Helper-model provider for the extract lane; null → media cannot be extracted here. */
94
119
  extraction: ExtractionProvider | null;
95
120
  /** Sandbox archive target; null → originals are not archived (logged once per turn). */
96
121
  sandbox: SandboxUploadConfig | null;
122
+ /** Cloudflare Images: downscales native images for the model; absent = sent as they are. */
123
+ images?: ImagesBinding;
97
124
  fetchImpl?: typeof fetch;
98
125
  signal?: AbortSignal;
99
126
  logger: Logger;
@@ -145,9 +172,17 @@ export function attachmentMetaFor(attachment: AttachmentInput): AttachmentMeta {
145
172
  };
146
173
  }
147
174
 
175
+ /** A filename as log lines show it (cleaned like every model-facing name). */
176
+ function logName(attachment: AttachmentInput): string {
177
+ return sanitizeAttachmentFilename(attachment.filename);
178
+ }
179
+
148
180
  /** A filename as the hidden notes quote it (no controls, brackets or quotes). */
149
181
  function noteName(attachment: AttachmentInput): string {
150
- return sanitizeNoteField(attachment.filename, NOTE_FILENAME_MAX);
182
+ return sanitizeNoteField(
183
+ sanitizeAttachmentFilename(attachment.filename),
184
+ NOTE_FILENAME_MAX,
185
+ );
151
186
  }
152
187
 
153
188
  /** A mimetype as the hidden notes show it. */
@@ -196,11 +231,6 @@ function archiveName(attachment: AttachmentInput): string {
196
231
  : safeName;
197
232
  }
198
233
 
199
- /** The largest file of this source the turn downloads. */
200
- function fileCap(attachment: AttachmentInput): number {
201
- return attachment.channelRef ? CHANNEL_MAX_FILE_BYTES : MAX_FILE_SIZE;
202
- }
203
-
204
234
  function megabytes(bytes: number): string {
205
235
  return `${Math.round(bytes / 1024 / 1024)} MB`;
206
236
  }
@@ -213,9 +243,10 @@ const BUDGET_EXCEEDED = 'Total attachment size budget exceeded';
213
243
 
214
244
  /**
215
245
  * The turn's download budget: `MAX_TOTAL_SIZE` across every attachment,
216
- * both lanes. A file is checked against what is left before its download
217
- * (declared size) and fetched under a cap of what is left, so a download
218
- * never runs past the budget and an over-budget file is never fetched twice.
246
+ * both lanes, and `MAX_FILE_SIZE` per file. A file is checked against what
247
+ * is left before its download (declared size) and fetched under a cap of
248
+ * what is left, so a download never runs past the budget and an
249
+ * over-budget file is never fetched twice.
219
250
  */
220
251
  class DownloadBudget {
221
252
  private used = 0;
@@ -230,13 +261,19 @@ class DownloadBudget {
230
261
  params: PrepareAttachmentsParams,
231
262
  deps: AttachmentPipelineDeps,
232
263
  ): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
233
- const perFile = fileCap(attachment);
264
+ const perFile = MAX_FILE_SIZE;
234
265
  if (attachment.size && attachment.size > perFile)
235
266
  throw new AttachmentTooLargeError('File exceeds maximum size');
236
267
  const remaining = this.remaining;
237
268
  if (remaining === 0 || (attachment.size && attachment.size > remaining))
238
269
  throw new AttachmentRejectedError(BUDGET_EXCEEDED);
239
270
  const cap = Math.min(perFile, remaining);
271
+ const ref = attachment.channelRef;
272
+ const pending = ref ? deps.preloaded?.get(ref) : undefined;
273
+ if (ref && pending) deps.preloaded?.delete(ref);
274
+ const preloaded = pending
275
+ ? await (deps.signal ? untilAborted(pending, deps.signal) : pending)
276
+ : null;
240
277
  try {
241
278
  const loaded = await loadAttachmentBytes(
242
279
  attachment,
@@ -247,6 +284,14 @@ class DownloadBudget {
247
284
  signal: deps.signal,
248
285
  maxBytes: cap,
249
286
  ...(deps.channel ? { channel: deps.channel } : {}),
287
+ ...(preloaded
288
+ ? {
289
+ preloaded: {
290
+ bytes: preloaded.bytes,
291
+ contentType: preloaded.mimetype,
292
+ },
293
+ }
294
+ : {}),
250
295
  },
251
296
  );
252
297
  this.used += loaded.bytes.length;
@@ -280,17 +325,26 @@ async function extractOne(
280
325
  const category: FileCategory = categorizeFile(attachment.mimetype);
281
326
  if (category === 'unsupported') {
282
327
  deps.logger.warn(
283
- `[attachments] unsupported file type ${attachment.mimetype} for ${attachment.filename}`,
328
+ `[attachments] unsupported file type ${noteType(attachment)} for "${logName(attachment)}"`,
284
329
  );
285
330
  return {
286
331
  text: `[File "${noteName(attachment)}" (${noteType(attachment)}) is not a supported file type and could not be processed]`,
287
332
  };
288
333
  }
289
334
 
335
+ // No model reads it and the sandbox refuses it: not worth a download.
336
+ if (attachment.size !== undefined && attachment.size > MAX_FILE_SIZE)
337
+ return {
338
+ text: `[File "${noteName(attachment)}" (${noteType(attachment)}, ${megabytes(attachment.size)}) is larger than the ${megabytes(MAX_FILE_SIZE)} a model or the sandbox takes here, so it was neither read nor archived]`,
339
+ };
340
+
290
341
  const bytes =
291
342
  preloaded ?? (await budget.load(attachment, params, deps)).bytes;
292
- verifyMagicBytes(bytes, category, attachment, (m) =>
293
- deps.logger.warn(`[attachments] ${m}`),
343
+ verifyMagicBytes(
344
+ bytes,
345
+ category,
346
+ { mimetype: attachment.mimetype, filename: logName(attachment) },
347
+ (m) => deps.logger.warn(`[attachments] ${m}`),
294
348
  );
295
349
 
296
350
  // Public http(s) images/videos: let the provider fetch the URL itself. A
@@ -302,10 +356,7 @@ async function extractOne(
302
356
  /^https?:\/\//i.test(attachment.mxcUri ?? '');
303
357
  let text: string | undefined;
304
358
  let usage: ExtractionUsage | undefined;
305
- if (bytes.length > MAX_FILE_SIZE) {
306
- // A channel file larger than any model is handed here: kept, not read.
307
- text = `[File "${noteName(attachment)}" (${noteType(attachment)}, ${megabytes(bytes.length)}) is larger than the ${megabytes(MAX_FILE_SIZE)} a model reads here, so its content was not read]`;
308
- } else if (
359
+ if (
309
360
  isHttpUrl &&
310
361
  deps.extraction &&
311
362
  (category === 'image' || category === 'video')
@@ -323,7 +374,7 @@ async function extractOne(
323
374
  }
324
375
  } catch (error) {
325
376
  deps.logger.warn(
326
- `[attachments] URL passthrough failed for "${attachment.filename}", falling back to bytes: ${errorMessage(error)}`,
377
+ `[attachments] URL passthrough failed for "${logName(attachment)}", falling back to bytes: ${errorMessage(error)}`,
327
378
  );
328
379
  }
329
380
  }
@@ -356,10 +407,7 @@ async function extractOne(
356
407
  );
357
408
  const actualPath = sanitizeSandboxPath(archived.path);
358
409
  let analysisPath: string | undefined;
359
- if (
360
- bytes.length <= MAX_FILE_SIZE &&
361
- (category === 'image' || category === 'video' || category === 'audio')
362
- ) {
410
+ if (category === 'image' || category === 'video' || category === 'audio') {
363
411
  const analysisFilename = `${storedName.replace(/\.[^.]+$/, '')}-analysis.md`;
364
412
  const analysisDest = `${SANDBOX_OUTPUT_PREFIX}/${analysisFilename}`;
365
413
  try {
@@ -382,7 +430,7 @@ async function extractOne(
382
430
  analysisPath = sanitizeSandboxPath(analysis.path);
383
431
  } catch (error) {
384
432
  deps.logger.warn(
385
- `[attachments] analysis .md upload failed for "${attachment.filename}": ${errorMessage(error)}`,
433
+ `[attachments] analysis .md upload failed for "${logName(attachment)}": ${errorMessage(error)}`,
386
434
  );
387
435
  }
388
436
  }
@@ -404,7 +452,7 @@ async function extractOne(
404
452
  };
405
453
  } catch (error) {
406
454
  deps.logger.warn(
407
- `[attachments] sandbox upload failed for "${attachment.filename}": ${errorMessage(error)}`,
455
+ `[attachments] sandbox upload failed for "${logName(attachment)}": ${errorMessage(error)}`,
408
456
  );
409
457
  return {
410
458
  text: `${text}\n\n[Warning: sandbox upload failed — file content is included above]`,
@@ -443,9 +491,11 @@ export async function prepareAttachments(
443
491
  }> = [];
444
492
  for (const attachment of params.attachments) {
445
493
  const kind = classifyAttachment(attachment);
446
- const strategy = routeAttachment(kind, params.caps);
494
+ const strategy = routeAttachment(kind, params.caps, {
495
+ size: attachment.size,
496
+ });
447
497
  deps.logger.log(
448
- `[attachments] "${attachment.filename}" (${attachment.mimetype}) kind=${kind} model=${params.model} → ${strategy}`,
498
+ `[attachments] "${logName(attachment)}" (${noteType(attachment)}) kind=${kind} size=${attachment.size ?? 'unknown'} model=${params.model} → ${strategy}`,
449
499
  );
450
500
  // A file larger than a model is handed is never sent as a block.
451
501
  if (
@@ -474,28 +524,46 @@ export async function prepareAttachments(
474
524
  mimetype,
475
525
  filename: sniffed ? '' : attachment.filename,
476
526
  });
477
- if (bytes.length > MAX_FILE_SIZE) {
527
+ if (kind !== classifyAttachment(attachment)) {
528
+ deps.logger.warn(
529
+ `[attachments] "${logName(attachment)}" claims ${noteType(attachment)} but is ${mimetype} — not sent natively`,
530
+ );
478
531
  extractQueue.push({ attachment, preloaded: bytes });
479
532
  continue;
480
533
  }
481
- if (kind !== classifyAttachment(attachment)) {
482
- deps.logger.warn(
483
- `[attachments] "${attachment.filename}" claims ${attachment.mimetype} but is ${mimetype} — not sent natively`,
534
+ if (kind === 'document' && bytes.length > NATIVE_DOCUMENT_MAX_BYTES) {
535
+ deps.logger.log(
536
+ `[attachments] "${logName(attachment)}" is ${bytes.length} bytes (declared ${attachment.size ?? 'unknown'}), over the ${NATIVE_DOCUMENT_MAX_BYTES}-byte native document limit → model-extract`,
484
537
  );
485
538
  extractQueue.push({ attachment, preloaded: bytes });
486
539
  continue;
487
540
  }
541
+ // The model gets a downscaled copy; the sandbox keeps the original.
542
+ let sent = { bytes, mimetype };
543
+ if (kind === 'image') {
544
+ const resized = await resizeForModel(bytes, mimetype, deps.images, {
545
+ logger: deps.logger,
546
+ });
547
+ if (resized.dropped) {
548
+ deps.logger.log(
549
+ `[attachments] "${logName(attachment)}" cannot go to the model as an image (${resized.dropped}) → model-extract`,
550
+ );
551
+ extractQueue.push({ attachment, preloaded: bytes });
552
+ continue;
553
+ }
554
+ sent = resized;
555
+ }
488
556
  natives.push({
489
557
  kind: kind === 'image' ? 'image' : 'file',
490
- mimeType: mimetype,
491
- base64: bytesToBase64(bytes),
558
+ mimeType: sent.mimetype,
559
+ base64: bytesToBase64(sent.bytes),
492
560
  filename: sanitizeAttachmentFilename(attachment.filename),
493
561
  });
494
562
  const note = nativeAttachmentNote(attachment);
495
563
  if (note)
496
564
  nativeNotes.push({ text: note, meta: attachmentMetaFor(attachment) });
497
565
  deps.logger.log(
498
- `[attachments] NATIVE → "${attachment.filename}" sent directly to ${params.model} (${bytes.length} bytes, ${kind})`,
566
+ `[attachments] NATIVE → "${logName(attachment)}" sent directly to ${params.model} (${sent.bytes.length} bytes, ${kind})`,
499
567
  );
500
568
  if (deps.sandbox) {
501
569
  const storedName = archiveName(attachment);
@@ -510,20 +578,20 @@ export async function prepareAttachments(
510
578
  deps.fetchImpl,
511
579
  ).catch((error: unknown) => {
512
580
  deps.logger.warn(
513
- `[attachments] background archive failed for "${attachment.filename}": ${errorMessage(error)}`,
581
+ `[attachments] background archive failed for "${logName(attachment)}": ${errorMessage(error)}`,
514
582
  );
515
583
  });
516
584
  }
517
585
  } catch (error) {
518
586
  if (error instanceof AttachmentRejectedError) {
519
587
  deps.logger.warn(
520
- `[attachments] "${attachment.filename}" refused: ${errorMessage(error)}`,
588
+ `[attachments] "${logName(attachment)}" refused: ${errorMessage(error)}`,
521
589
  );
522
590
  extractQueue.push({ attachment, refused: error });
523
591
  continue;
524
592
  }
525
593
  deps.logger.warn(
526
- `[attachments] native load failed for "${attachment.filename}", falling back to extraction: ${errorMessage(error)}`,
594
+ `[attachments] native load failed for "${logName(attachment)}", falling back to extraction: ${errorMessage(error)}`,
527
595
  );
528
596
  extractQueue.push({ attachment });
529
597
  }
@@ -563,7 +631,7 @@ export async function prepareAttachments(
563
631
  text = result.text;
564
632
  } catch (error) {
565
633
  deps.logger.error(
566
- `[attachments] failed to process ${attachment.filename}: ${errorMessage(error)}`,
634
+ `[attachments] failed to process "${logName(attachment)}": ${errorMessage(error)}`,
567
635
  );
568
636
  // A channel file's error never names its link (`channel.ts`).
569
637
  text =
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Downscale an image before the model sees it, through the optional
3
+ * Cloudflare Images binding (`IMAGES`, docs/configuration.md). A native image
4
+ * block is the file's base64 and is replayed on every later turn, so a 4 MB
5
+ * phone photo costs ~5.3 MB of request per call; at a 1568 px long edge as
6
+ * JPEG it is a few hundred KB and reads the same to a vision model.
7
+ *
8
+ * Only the copy handed to the model changes: callers archive the original
9
+ * bytes. Without the binding, for a type the binding does not take (SVG,
10
+ * BMP, TIFF, HEIC, …), for an image already within the bound, and when
11
+ * `info()` fails for another reason than a malformed image (its dimensions
12
+ * are then unknown), the input is returned unchanged, so an oracle that does
13
+ * not bind Images behaves as before.
14
+ *
15
+ * An image that must not go to the model as it is comes back with
16
+ * `dropped`, a short reason, and the caller does not send it natively: one
17
+ * the binding reports as malformed (not an image, an unsupported format, a
18
+ * file it cannot decode), one over `RESIZE_HARD_MAX_PIXELS` /
19
+ * `RESIZE_HARD_MAX_SIDE` (never transformed), and one over the bound whose
20
+ * transformation fails or yields nothing. `bytes`/`mimetype` are then the
21
+ * input's, for the caller's log.
22
+ *
23
+ * The bound is on the LONG edge: `maxWidth` caps both width and height, so a
24
+ * tall phone screenshot is reduced too. The dimensions are read first with
25
+ * `info()` (not billed); only an image with either side over the bound is
26
+ * transformed (one unique transformation, billed after the monthly free
27
+ * allowance). `fit: 'scale-down'` keeps the aspect ratio and never upscales.
28
+ */
29
+ import type { Logger } from '../plugin-api/types';
30
+
31
+ /** Longest edge handed to a model (px): Anthropic's recommended maximum edge. */
32
+ export const RESIZE_MAX_EDGE = 1568;
33
+ /** JPEG quality of a downscaled image. */
34
+ export const RESIZE_JPEG_QUALITY = 82;
35
+ /**
36
+ * Largest image area transformed (pixels). Cloudflare refuses images over
37
+ * 100 megapixels (error 9413); half of that leaves no photo out and keeps a
38
+ * decompression bomb from being sent on.
39
+ */
40
+ export const RESIZE_HARD_MAX_PIXELS = 50_000_000;
41
+ /** Longest side transformed (px); a longer one is dropped. */
42
+ export const RESIZE_HARD_MAX_SIDE = 12_000;
43
+
44
+ /**
45
+ * Cloudflare Images error codes that mean the input itself is unusable:
46
+ * 9412 not an image, 9520 an unsupported format, 9523 an image it could
47
+ * not decode (developers.cloudflare.com/images/reference/troubleshooting).
48
+ */
49
+ const MALFORMED_IMAGE_ERROR = /\b(9412|9520|9523)\b/;
50
+
51
+ export interface ResizedImage {
52
+ bytes: Uint8Array;
53
+ mimetype: string;
54
+ /**
55
+ * Set when the image must not be sent to the model natively: why, in a
56
+ * few words (for logs and the model-facing note).
57
+ */
58
+ dropped?: string;
59
+ }
60
+
61
+ /**
62
+ * Raster types the binding decodes. A GIF is flattened to its first frame by
63
+ * the JPEG output.
64
+ */
65
+ const RESIZABLE_TYPES = new Set([
66
+ 'image/jpeg',
67
+ 'image/png',
68
+ 'image/webp',
69
+ 'image/gif',
70
+ ]);
71
+
72
+ /** Fill under transparent pixels: JPEG has no alpha channel. */
73
+ const JPEG_BACKGROUND = '#ffffff';
74
+
75
+ function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
76
+ return new Response(bytes).body ?? new ReadableStream<Uint8Array>();
77
+ }
78
+
79
+ export async function resizeForModel(
80
+ bytes: Uint8Array,
81
+ mimetype: string,
82
+ images: ImagesBinding | undefined,
83
+ opts: { maxWidth?: number; quality?: number; logger?: Logger } = {},
84
+ ): Promise<ResizedImage> {
85
+ const original = { bytes, mimetype };
86
+ if (!images) return original;
87
+ const type = mimetype.split(';')[0]?.trim().toLowerCase() ?? '';
88
+ if (!RESIZABLE_TYPES.has(type)) return original;
89
+ // `maxWidth` (the caller's name) bounds both sides.
90
+ const maxEdge = opts.maxWidth ?? RESIZE_MAX_EDGE;
91
+ const drop = (reason: string): ResizedImage => {
92
+ opts.logger?.warn(
93
+ `[attachments] image not sent to the model (${type}, ${bytes.length} bytes): ${reason}`,
94
+ );
95
+ return { ...original, dropped: reason };
96
+ };
97
+ let info: Awaited<ReturnType<ImagesBinding['info']>>;
98
+ try {
99
+ info = await images.info(streamOf(bytes));
100
+ } catch (error) {
101
+ const message = errorMessage(error);
102
+ if (MALFORMED_IMAGE_ERROR.test(message))
103
+ return drop(`not a readable image (${message})`);
104
+ opts.logger?.warn(
105
+ `[attachments] image info failed (${type}, ${bytes.length} bytes), sending the original: ${message}`,
106
+ );
107
+ return original;
108
+ }
109
+ // An SVG reports no dimensions; it is never rasterised here.
110
+ if (!('width' in info) || (info.width <= maxEdge && info.height <= maxEdge))
111
+ return original;
112
+ if (
113
+ info.width > RESIZE_HARD_MAX_SIDE ||
114
+ info.height > RESIZE_HARD_MAX_SIDE ||
115
+ info.width * info.height > RESIZE_HARD_MAX_PIXELS
116
+ )
117
+ return drop(`${info.width}×${info.height}px is over the size limit`);
118
+ try {
119
+ const result = await images
120
+ .input(streamOf(bytes))
121
+ .transform({ width: maxEdge, height: maxEdge, fit: 'scale-down' })
122
+ .output({
123
+ format: 'image/jpeg',
124
+ quality: opts.quality ?? RESIZE_JPEG_QUALITY,
125
+ background: JPEG_BACKGROUND,
126
+ });
127
+ const resized = new Uint8Array(await result.response().arrayBuffer());
128
+ if (resized.length === 0) throw new Error('empty output');
129
+ opts.logger?.log(
130
+ `[attachments] image ${info.width}×${info.height}px resized to fit ${maxEdge}px (${bytes.length} → ${resized.length} bytes)`,
131
+ );
132
+ return { bytes: resized, mimetype: result.contentType() };
133
+ } catch (error) {
134
+ return drop(
135
+ `${info.width}×${info.height}px could not be resized (${errorMessage(error)})`,
136
+ );
137
+ }
138
+ }
139
+
140
+ function errorMessage(error: unknown): string {
141
+ return error instanceof Error ? error.message : String(error);
142
+ }
@@ -1,18 +1,19 @@
1
1
  /**
2
- * Attachment payload retention.
2
+ * Attachment payloads and their placeholder.
3
3
  *
4
- * A human turn whose attachments the model reads natively stores the bytes
5
- * inline, as base64 content blocks, in the persisted message. On Workers that
6
- * payload is replayed to the model on every later turn of the session and
7
- * sits in the per-user SQLite (and its VFS copy) for good, so once a turn is
8
- * older than the newest `ATTACHMENT_PAYLOAD_TURNS` user turns its inline
9
- * blocks are replaced by one compact text placeholder. The placeholder lists
10
- * the message's attachments with the reference `view_attachment` takes, and
11
- * the metadata the Portal renders from (`additional_kwargs.attachment(s)`)
12
- * is left untouched, so transcripts do not change.
13
- *
14
- * The rewrite is a pure function of the thread's messages and idempotent: a
15
- * rewritten message has no inline block left, so it is never selected again.
4
+ * A human turn whose attachments the model reads natively carries the bytes
5
+ * inline, as base64 content blocks, for the turn that posted them. Those
6
+ * bytes are never stored with the conversation: the checkpointer writes
7
+ * every such message with its inline blocks replaced by one compact text
8
+ * placeholder (`offloadInlinePayloads`, applied in `src/sqlite/
9
+ * inline-payloads.ts`), so every later turn, and a run restarted mid-turn,
10
+ * loads the placeholder. The placeholder lists the message's attachments
11
+ * with the reference `view_attachment` takes; the metadata the Portal
12
+ * renders from (`additional_kwargs.attachment(s)`) is left untouched, so
13
+ * transcripts do not change. Recent images are put back into each model
14
+ * request by the attachment recall middleware
15
+ * (`src/core/middlewares/attachment-recall.ts`), never into the stored
16
+ * messages.
16
17
  */
17
18
  import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
18
19
  import type { AttachmentMeta } from '../sqlite/serialization';
@@ -22,25 +23,16 @@ import {
22
23
  sanitizeNoteField,
23
24
  } from './sanitize';
24
25
 
25
- /** Newest user turns whose inline attachment payloads stay in the messages. */
26
- export const ATTACHMENT_PAYLOAD_TURNS = 2;
27
-
28
26
  /**
29
27
  * `additional_kwargs.lc_source` of the human message `view_attachment` adds
30
- * to re-attach a payload — hidden from transcripts and not a user turn.
28
+ * to re-attach a payload — hidden from transcripts and not a user turn (it
29
+ * belongs to the user turn before it).
31
30
  */
32
31
  export const ATTACHMENT_VIEW_SOURCE = 'attachment_view';
33
32
 
34
33
  /** Every placeholder text block starts with this (transcripts drop it). */
35
34
  export const ATTACHMENT_PLACEHOLDER_PREFIX = '[attachment offloaded]';
36
35
 
37
- /**
38
- * Substring every inline payload block serialises to (`message_content`
39
- * holds the JSON of an array content), for a SQL `LIKE` pre-filter that
40
- * finds candidate rows without inflating message blobs.
41
- */
42
- export const INLINE_PAYLOAD_NEEDLE = '"source_type":"base64"';
43
-
44
36
  export const VIEW_ATTACHMENT_TOOL_NAME = 'view_attachment';
45
37
 
46
38
  interface InlinePayloadBlock {
@@ -132,21 +124,23 @@ export function buildPlaceholderText(metas: AttachmentMeta[]): string {
132
124
  ? `Attachments of this message:\n${lines.join('\n')}\n`
133
125
  : 'This message had an attachment whose metadata was not recorded; it cannot be fetched again.\n';
134
126
  return (
135
- `${ATTACHMENT_PLACEHOLDER_PREFIX} The attachment bytes of this message are no longer inline ` +
136
- `(only the newest ${ATTACHMENT_PAYLOAD_TURNS} user turns keep them). ${listing}` +
127
+ `${ATTACHMENT_PLACEHOLDER_PREFIX} The attachment bytes of this message are not stored ` +
128
+ `with the conversation. ${listing}` +
137
129
  `To look at one again, call ${VIEW_ATTACHMENT_TOOL_NAME} with its ref.`
138
130
  );
139
131
  }
140
132
 
141
133
  /**
142
- * A copy of `message` with every inline payload block replaced by one
143
- * placeholder text block (id, kwargs and response metadata preserved), or
144
- * null when the message carries no inline payload.
134
+ * A copy of a human `message` with every inline payload block replaced by
135
+ * one placeholder text block (id, name, kwargs and response metadata
136
+ * preserved, the original untouched), or null when it is not a human
137
+ * message or carries no inline payload. The copy shares every other block
138
+ * with the original; only the payload strings are left behind.
145
139
  */
146
140
  export function offloadInlinePayloads(
147
141
  message: BaseMessage,
148
- ): BaseMessage | null {
149
- if (!Array.isArray(message.content)) return null;
142
+ ): HumanMessage | null {
143
+ if (message.type !== 'human' || !Array.isArray(message.content)) return null;
150
144
  if (!message.content.some((block) => isInlinePayloadBlock(block)))
151
145
  return null;
152
146
  const placeholder = {
@@ -168,46 +162,8 @@ export function offloadInlinePayloads(
168
162
  return new HumanMessage({
169
163
  id: message.id,
170
164
  content,
165
+ ...(message.name !== undefined ? { name: message.name } : {}),
171
166
  additional_kwargs: message.additional_kwargs,
172
167
  response_metadata: message.response_metadata,
173
168
  });
174
169
  }
175
-
176
- export interface AttachmentRetentionResult {
177
- /** Ids of the messages whose inline payload stays (inside the window). */
178
- retainedIds: Set<string>;
179
- /** Rewritten copies of the messages that fell out of the window. */
180
- rewrites: BaseMessage[];
181
- }
182
-
183
- /**
184
- * Decide, for the thread's current messages (oldest first), which inline
185
- * payloads stay and which get a placeholder. A payload belongs to the user
186
- * turn it was posted in — a re-attachment message belongs to the turn of the
187
- * user message before it — and stays while that turn is one of the newest
188
- * `keepTurns` user turns. Messages without an id cannot be rewritten in
189
- * place and are left alone.
190
- */
191
- export function applyAttachmentRetention(
192
- messages: readonly BaseMessage[],
193
- keepTurns: number = ATTACHMENT_PAYLOAD_TURNS,
194
- ): AttachmentRetentionResult {
195
- const retainedIds = new Set<string>();
196
- const rewrites: BaseMessage[] = [];
197
- let turnsSeen = 0;
198
- for (let i = messages.length - 1; i >= 0; i -= 1) {
199
- const message = messages[i]!;
200
- if (message.type !== 'human') continue;
201
- const userTurn = !isAttachmentViewMessage(message);
202
- if (userTurn) turnsSeen += 1;
203
- if (!hasInlinePayload(message) || !message.id) continue;
204
- const turnIndex = userTurn ? turnsSeen : turnsSeen + 1;
205
- if (turnIndex <= keepTurns) {
206
- retainedIds.add(message.id);
207
- continue;
208
- }
209
- const rewritten = offloadInlinePayloads(message);
210
- if (rewritten) rewrites.push(rewritten);
211
- }
212
- return { retainedIds, rewrites };
213
- }