@ixo/oracle-runtime-workers 0.37.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,9 +67,9 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
+ "@ixo/ucan": "2.3.0",
70
71
  "@ixo/common": "1.7.2",
71
- "@ixo/ixo-transaction": "0.2.0",
72
- "@ixo/ucan": "2.3.0"
72
+ "@ixo/ixo-transaction": "0.2.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -3,10 +3,17 @@
3
3
  * user's own SQLite file in their own table, apart from the messages: the
4
4
  * messages, the model and the transcript carry only the `channel:`
5
5
  * reference, and `view_attachment` resolves it here once the turn that
6
- * brought the file has ended. A row lives until its link expires (the
7
- * gateway signs links for 30 days; `exp` in the link) and goes with its
6
+ * brought the file has ended. A link is served until it expires (the
7
+ * gateway signs links for 30 days; `exp` in the link); a row goes with its
8
8
  * session (`forgetSession`). Failures are logged and treated as a miss: a
9
9
  * missing link makes the file unavailable, never the turn.
10
+ *
11
+ * A row also keeps the Matrix event the file was posted as in the session
12
+ * thread (`event_id`, `attachments/room-media.ts`): that room copy serves
13
+ * the file when the link fails or has expired, so a row that carries one
14
+ * outlives its link (the expiry sweep only removes rows without it). A
15
+ * resend of the same file in a later turn refreshes the link and keeps the
16
+ * event until the new post records its own.
10
17
  */
11
18
  import type { DoSqliteDatabase } from '../sqlite/database';
12
19
  import type { Logger } from '../plugin-api/types';
@@ -32,7 +39,7 @@ export class ChannelAttachmentLinkStore {
32
39
  private setupPromise: Promise<void> | undefined;
33
40
 
34
41
  constructor(
35
- private readonly db: Pick<DoSqliteDatabase, 'run' | 'get'>,
42
+ private readonly db: Pick<DoSqliteDatabase, 'run' | 'get' | 'exec'>,
36
43
  private readonly log: Pick<Logger, 'warn'>,
37
44
  private readonly now: () => number = Date.now,
38
45
  ) {}
@@ -54,16 +61,30 @@ export class ChannelAttachmentLinkStore {
54
61
  url TEXT NOT NULL,
55
62
  sha256 TEXT NOT NULL,
56
63
  expires_at INTEGER NOT NULL,
64
+ event_id TEXT,
57
65
  UNIQUE (session_id, ref)
58
66
  )`,
59
67
  );
68
+ // A table created before `event_id` existed (CREATE TABLE IF NOT
69
+ // EXISTS leaves it as it was).
70
+ const columns = await this.db.exec<{ name: string }>(
71
+ 'PRAGMA table_info(channel_attachment_links)',
72
+ );
73
+ if (!columns.some((column) => column.name === 'event_id'))
74
+ await this.db.run(
75
+ 'ALTER TABLE channel_attachment_links ADD COLUMN event_id TEXT',
76
+ );
60
77
  await this.db.run(
61
78
  `CREATE INDEX IF NOT EXISTS idx_channel_attachment_links_expiry
62
79
  ON channel_attachment_links(expires_at)`,
63
80
  );
64
81
  }
65
82
 
66
- /** Keep the links of a turn's files for its session (a resend refreshes them). */
83
+ /**
84
+ * Keep the links of a turn's files for its session (a resend refreshes
85
+ * them, keeping a recorded room event), then sweep expired rows that have
86
+ * no room copy.
87
+ */
67
88
  async put(
68
89
  sessionId: string,
69
90
  links: ReadonlyArray<ChannelMediaLink & { ref: string }>,
@@ -74,9 +95,12 @@ export class ChannelAttachmentLinkStore {
74
95
  const now = this.now();
75
96
  for (const link of links)
76
97
  await this.db.run(
77
- `INSERT OR REPLACE INTO channel_attachment_links
98
+ `INSERT INTO channel_attachment_links
78
99
  (session_id, ref, binding_id, url, sha256, expires_at)
79
- VALUES (?, ?, ?, ?, ?, ?)`,
100
+ VALUES (?, ?, ?, ?, ?, ?)
101
+ ON CONFLICT (session_id, ref) DO UPDATE SET
102
+ binding_id = excluded.binding_id, url = excluded.url,
103
+ sha256 = excluded.sha256, expires_at = excluded.expires_at`,
80
104
  [
81
105
  sessionId,
82
106
  link.ref,
@@ -87,7 +111,7 @@ export class ChannelAttachmentLinkStore {
87
111
  ],
88
112
  );
89
113
  await this.db.run(
90
- 'DELETE FROM channel_attachment_links WHERE expires_at <= ?',
114
+ 'DELETE FROM channel_attachment_links WHERE expires_at <= ? AND event_id IS NULL',
91
115
  [now],
92
116
  );
93
117
  } catch (error) {
@@ -124,6 +148,49 @@ export class ChannelAttachmentLinkStore {
124
148
  }
125
149
  }
126
150
 
151
+ /**
152
+ * Record the session-thread events the turn's files were posted as
153
+ * (reference → event id). A failure is logged: the files are then served
154
+ * by their links alone.
155
+ */
156
+ async setEventIds(
157
+ sessionId: string,
158
+ eventIds: ReadonlyMap<string, string>,
159
+ ): Promise<void> {
160
+ if (eventIds.size === 0) return;
161
+ try {
162
+ await this.setup();
163
+ for (const [ref, eventId] of eventIds)
164
+ await this.db.run(
165
+ `UPDATE channel_attachment_links SET event_id = ?
166
+ WHERE session_id = ? AND ref = ?`,
167
+ [eventId, sessionId, ref],
168
+ );
169
+ } catch (error) {
170
+ this.log.warn(
171
+ `[attachments] could not keep the thread events of ${sessionId}'s channel files: ${error instanceof Error ? error.message : String(error)}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ /** The session-thread event recorded for `ref`, expired link or not. */
177
+ async eventIdOf(sessionId: string, ref: string): Promise<string | undefined> {
178
+ try {
179
+ await this.setup();
180
+ const row = await this.db.get<{ event_id: string | null }>(
181
+ `SELECT event_id FROM channel_attachment_links
182
+ WHERE session_id = ? AND ref = ?`,
183
+ [sessionId, ref],
184
+ );
185
+ return row?.event_id ?? undefined;
186
+ } catch (error) {
187
+ this.log.warn(
188
+ `[attachments] channel file thread event read failed: ${error instanceof Error ? error.message : String(error)}`,
189
+ );
190
+ return undefined;
191
+ }
192
+ }
193
+
127
194
  /** Drop every link of a deleted session. */
128
195
  async forgetSession(sessionId: string): Promise<void> {
129
196
  await this.setup();
@@ -27,8 +27,9 @@ export const CHANNEL_REF_PREFIX = 'channel:';
27
27
  /**
28
28
  * The gateway's per-file bound for a video or a document
29
29
  * (`maxLargeMediaBytes`, 50 MiB); photos are at most 16 MB there. A file
30
- * above `MAX_FILE_SIZE` is archived to the sandbox but never handed to a
31
- * model (see the pipeline).
30
+ * above `MAX_FILE_SIZE` is posted to the session thread (`room-media.ts`)
31
+ * but never read by the turn: no model is handed it and the sandbox
32
+ * archive refuses it (see the pipeline).
32
33
  */
33
34
  export const CHANNEL_MAX_FILE_BYTES = 52_428_800;
34
35
 
@@ -94,7 +95,8 @@ export function isGatewayMediaUrl(
94
95
  );
95
96
  }
96
97
 
97
- function hex(bytes: ArrayBuffer): string {
98
+ /** Lowercase hex of a digest. */
99
+ export function hex(bytes: ArrayBuffer): string {
98
100
  return Array.from(new Uint8Array(bytes), (byte) =>
99
101
  byte.toString(16).padStart(2, '0'),
100
102
  ).join('');
@@ -163,15 +165,23 @@ export interface ChannelMediaSource {
163
165
  timeoutMs?: number;
164
166
  },
165
167
  ): Promise<{ bytes: Uint8Array; contentType?: string }>;
168
+ /**
169
+ * The media event the file was posted as in the session thread
170
+ * (`room-media.ts`), when one is recorded: the room copy that serves the
171
+ * file once its link has expired or the gateway no longer has it.
172
+ */
173
+ roomEventOf?(ref: string): Promise<string | undefined>;
166
174
  }
167
175
 
168
176
  /**
169
177
  * A `ChannelMediaSource` over `resolve` (the turn's files, then the
170
- * session's stored links) for the gateways in `origins`.
178
+ * session's stored links) for the gateways in `origins`; `roomEventOf`
179
+ * names the room copy of a file, when the owner keeps one.
171
180
  */
172
181
  export function createChannelMediaSource(input: {
173
182
  resolve: (ref: string) => Promise<ChannelMediaLink | undefined>;
174
183
  origins: readonly string[];
184
+ roomEventOf?: (ref: string) => Promise<string | undefined>;
175
185
  }): ChannelMediaSource {
176
186
  return {
177
187
  load: async (ref, opts) => {
@@ -182,6 +192,7 @@ export function createChannelMediaSource(input: {
182
192
  );
183
193
  return downloadChannelFile(link, { ...opts, origins: input.origins });
184
194
  },
195
+ ...(input.roomEventOf ? { roomEventOf: input.roomEventOf } : {}),
185
196
  };
186
197
  }
187
198
 
@@ -30,22 +30,25 @@ export function buildUserMessageContent(
30
30
 
31
31
  const content: MessageContent = [
32
32
  { type: 'text', text },
33
- ...natives.map((native) =>
34
- native.kind === 'image'
35
- ? {
36
- type: 'image',
37
- source_type: 'base64',
38
- mime_type: native.mimeType,
39
- data: native.base64,
40
- }
41
- : {
42
- type: 'file',
43
- source_type: 'base64',
44
- mime_type: native.mimeType,
45
- data: native.base64,
46
- filename: native.filename,
47
- },
48
- ),
33
+ ...natives.map(nativeContentBlock),
49
34
  ];
50
35
  return content;
51
36
  }
37
+
38
+ /** The content block of one native attachment (the shape described above). */
39
+ export function nativeContentBlock(native: NativeAttachment) {
40
+ return native.kind === 'image'
41
+ ? {
42
+ type: 'image',
43
+ source_type: 'base64',
44
+ mime_type: native.mimeType,
45
+ data: native.base64,
46
+ }
47
+ : {
48
+ type: 'file',
49
+ source_type: 'base64',
50
+ mime_type: native.mimeType,
51
+ data: native.base64,
52
+ filename: native.filename,
53
+ };
54
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Fetching attachment bytes — Matrix media (through the gateway, which holds
3
- * the authenticated client and the room keys), channel files (through the
4
- * owner object's `ChannelMediaSource`, see `channel.ts`) and plain `http(s)`
5
- * URLs with the Node runtime's SSRF guard, manual redirect validation and
6
- * streaming size cap.
2
+ * Fetching attachment bytes — Matrix media (the owner object asks the
3
+ * gateway for the media event's small JSON — it alone holds the room keys —
4
+ * and reads the bytes from the homeserver itself, `openHomeserverMedia`),
5
+ * channel files (through the owner object's `ChannelMediaSource`, see
6
+ * `channel.ts`, with the file's room copy as the fallback) and plain
7
+ * `http(s)` URLs with the Node runtime's SSRF guard, manual redirect
8
+ * validation and streaming size cap.
7
9
  */
8
10
  import type { ChannelMediaSource } from './channel';
9
11
  import { containerMatchesClaim, detectMimeFromMagicBytes } from './magic';
@@ -53,7 +55,7 @@ function tooLarge(maxBytes: number): AttachmentTooLargeError {
53
55
  }
54
56
 
55
57
  /** The signal's reason as an Error (an aborter may pass any value). */
56
- function abortError(signal: AbortSignal): Error {
58
+ export function abortError(signal: AbortSignal): Error {
57
59
  const reason: unknown = signal.reason;
58
60
  if (reason instanceof Error) return reason;
59
61
  return new Error(
@@ -65,7 +67,10 @@ function abortError(signal: AbortSignal): Error {
65
67
  * `work`, or the signal's reason as soon as it aborts — so a transfer that
66
68
  * ignores the signal still cannot hold the caller past it.
67
69
  */
68
- function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
70
+ export function untilAborted<T>(
71
+ work: Promise<T>,
72
+ signal: AbortSignal,
73
+ ): Promise<T> {
69
74
  if (signal.aborted) {
70
75
  work.catch(() => undefined);
71
76
  return Promise.reject(abortError(signal));
@@ -105,8 +110,9 @@ export function validateUrlTarget(targetUrl: string): void {
105
110
  }
106
111
 
107
112
  /**
108
- * Matrix media access the pipeline needs. Implemented over the gateway
109
- * Durable Object in the user object; tests pass an in-memory fake.
113
+ * Matrix media access the pipeline needs. Implemented in the user object
114
+ * (the event's media info from the gateway, the bytes from the homeserver);
115
+ * tests pass an in-memory fake.
110
116
  *
111
117
  * `signal` aborts at the download deadline or when the turn is aborted: an
112
118
  * implementation stops the transfer then (`readBytesCapped` with the signal
@@ -148,15 +154,69 @@ export interface DownloadOptions {
148
154
  maxRedirects?: number;
149
155
  /** Where a `channelRef` attachment's bytes come from; absent = none can be read. */
150
156
  channel?: ChannelMediaSource;
157
+ /**
158
+ * The attachment's bytes as this turn already read them (a channel file
159
+ * read while it was posted to the session thread): used in place of a
160
+ * download, under the same size, emptiness and type checks.
161
+ */
162
+ preloaded?: { bytes: Uint8Array; contentType?: string };
163
+ }
164
+
165
+ /** The homeserver answered 401: the access token it was given is no longer valid. */
166
+ export class HomeserverUnauthorizedError extends Error {
167
+ override readonly name: string = 'HomeserverUnauthorizedError';
168
+ }
169
+
170
+ /** `mxc://<server>/<media id>`, the parts checked before they go into a URL. */
171
+ const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
172
+
173
+ /**
174
+ * Open the media behind `mxc` on the homeserver as the account behind
175
+ * `accessToken`: the authenticated endpoint
176
+ * (`/_matrix/client/v1/media/download`), then the legacy one
177
+ * (`/_matrix/media/v3/download`) when the server answers 404 — the order
178
+ * matrix-js-sdk's `mxcUrlToHttp` and the bot SDK use. The body is returned
179
+ * unread; a 401 is `HomeserverUnauthorizedError`.
180
+ */
181
+ export async function openHomeserverMedia(
182
+ mxc: string,
183
+ homeserver: { baseUrl: string; accessToken: string },
184
+ opts: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {},
185
+ ): Promise<ReadableStream<Uint8Array>> {
186
+ const match = MXC_PATTERN.exec(mxc);
187
+ if (!match) throw new Error('Invalid mxc URI');
188
+ const fetchImpl = opts.fetchImpl ?? fetch;
189
+ const path = `${match[1]}/${match[2]}?allow_redirect=true`;
190
+ const headers = { authorization: `Bearer ${homeserver.accessToken}` };
191
+ const open = (prefix: string): Promise<Response> =>
192
+ fetchImpl(new URL(`${prefix}/${path}`, homeserver.baseUrl), {
193
+ headers,
194
+ ...(opts.signal ? { signal: opts.signal } : {}),
195
+ });
196
+ let res = await open('/_matrix/client/v1/media/download');
197
+ if (res.status === 404) {
198
+ await res.body?.cancel().catch(() => undefined);
199
+ res = await open('/_matrix/media/v3/download');
200
+ }
201
+ if (!res.ok || !res.body) {
202
+ await res.body?.cancel().catch(() => undefined);
203
+ if (res.status === 401)
204
+ throw new HomeserverUnauthorizedError(
205
+ 'the homeserver rejected the access token (401)',
206
+ );
207
+ throw new Error(`Matrix media download failed: HTTP ${res.status}`);
208
+ }
209
+ return res.body;
151
210
  }
152
211
 
153
212
  /**
154
213
  * A controller that aborts on `signal` (with its reason) OR after
155
- * `timeoutMs` (with a timeout error), whichever comes first.
214
+ * `timeoutMs` (with a timeout error naming `what`), whichever comes first.
156
215
  */
157
- function boundedSignal(
216
+ export function boundedSignal(
158
217
  signal: AbortSignal | undefined,
159
218
  timeoutMs: number,
219
+ what = 'Attachment download',
160
220
  ): { signal: AbortSignal; release: () => void } {
161
221
  const controller = new AbortController();
162
222
  const onAbort = (): void => {
@@ -167,9 +227,7 @@ function boundedSignal(
167
227
  const timer = setTimeout(
168
228
  () =>
169
229
  controller.abort(
170
- new Error(
171
- `Attachment download timed out after ${Math.round(timeoutMs / 1000)} s`,
172
- ),
230
+ new Error(`${what} timed out after ${Math.round(timeoutMs / 1000)} s`),
173
231
  ),
174
232
  timeoutMs,
175
233
  );
@@ -297,11 +355,87 @@ export async function downloadFromUrl(
297
355
  }
298
356
  }
299
357
 
358
+ /**
359
+ * The Matrix media event `eventId` of `roomId`, under the download deadline
360
+ * (`opts.timeoutMs`, default `DOWNLOAD_TIMEOUT_MS`) and `opts.signal`.
361
+ */
362
+ async function loadMatrixEvent(
363
+ eventId: string,
364
+ roomId: string | undefined,
365
+ source: MatrixMediaSource,
366
+ maxBytes: number,
367
+ opts: DownloadOptions,
368
+ ): Promise<{ bytes: Uint8Array; httpType?: string }> {
369
+ if (!roomId)
370
+ throw new Error(
371
+ `Cannot fetch event ${eventId}: no Matrix room for this turn`,
372
+ );
373
+ const bound = boundedSignal(
374
+ opts.signal,
375
+ opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
376
+ );
377
+ try {
378
+ const media = await untilAborted(
379
+ source.downloadEvent(roomId, eventId, maxBytes, bound.signal),
380
+ bound.signal,
381
+ );
382
+ if (!media) throw new Error(`Matrix event ${eventId} not found`);
383
+ return {
384
+ bytes: media.bytes,
385
+ ...(media.mimetype ? { httpType: media.mimetype } : {}),
386
+ };
387
+ } finally {
388
+ bound.release();
389
+ }
390
+ }
391
+
392
+ /**
393
+ * A channel file's bytes: from its link, else — when the link is gone or
394
+ * fails for any reason but an abort or the size cap — from the copy posted
395
+ * in the session thread (`postedAs`, else the event the channel source
396
+ * recorded), which outlives the gateway's 30-day link.
397
+ */
398
+ async function loadChannelFile(
399
+ ref: string,
400
+ postedAs: string | undefined,
401
+ roomId: string | undefined,
402
+ source: MatrixMediaSource,
403
+ maxBytes: number,
404
+ opts: DownloadOptions,
405
+ ): Promise<{ bytes: Uint8Array; httpType?: string }> {
406
+ try {
407
+ if (!opts.channel)
408
+ throw new Error('Channel files cannot be read on this host');
409
+ const loaded = await opts.channel.load(ref, {
410
+ maxBytes,
411
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
412
+ ...(opts.signal ? { signal: opts.signal } : {}),
413
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
414
+ });
415
+ return {
416
+ bytes: loaded.bytes,
417
+ ...(loaded.contentType ? { httpType: loaded.contentType } : {}),
418
+ };
419
+ } catch (error) {
420
+ if (
421
+ opts.signal?.aborted ||
422
+ error instanceof AttachmentTooLargeError ||
423
+ !roomId
424
+ )
425
+ throw error;
426
+ const eventId = postedAs ?? (await opts.channel?.roomEventOf?.(ref));
427
+ if (!eventId) throw error;
428
+ return loadMatrixEvent(eventId, roomId, source, maxBytes, opts);
429
+ }
430
+ }
431
+
300
432
  /**
301
433
  * Bytes for one attachment from whichever source it names, with the per-file
302
434
  * cap (`opts.maxBytes`, default `MAX_FILE_SIZE`) enforced before and during
303
435
  * the transfer and the `DOWNLOAD_TIMEOUT_MS` deadline (or `opts.timeoutMs`)
304
- * on every source — Matrix media included. `mimetype` is the best-known
436
+ * on every source — Matrix media included; `opts.preloaded` bytes are
437
+ * checked the same way without a download. A channel file whose link fails
438
+ * is read from its room copy when one is known (`loadChannelFile`). `mimetype` is the best-known
305
439
  * type: content sniffing (`sniffed` is then true; a container signature
306
440
  * consistent with the claim, such as a zip for a `.docx`, keeps the claimed
307
441
  * type), then the HTTP `content-type`, then the client's claim. An empty
@@ -326,47 +460,36 @@ export async function loadAttachmentBytes(
326
460
 
327
461
  let bytes: Uint8Array;
328
462
  let httpType: string | undefined;
329
- if (attachment.channelRef) {
330
- if (!opts.channel)
331
- throw new Error('Channel files cannot be read on this host');
332
- const loaded = await opts.channel.load(attachment.channelRef, {
463
+ if (opts.preloaded) {
464
+ bytes = opts.preloaded.bytes;
465
+ httpType = opts.preloaded.contentType;
466
+ } else if (attachment.channelRef) {
467
+ ({ bytes, httpType } = await loadChannelFile(
468
+ attachment.channelRef,
469
+ attachment.eventId,
470
+ roomId,
471
+ source,
333
472
  maxBytes,
334
- ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
335
- ...(opts.signal ? { signal: opts.signal } : {}),
336
- ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
337
- });
338
- bytes = loaded.bytes;
339
- httpType = loaded.contentType;
340
- } else if (attachment.eventId || attachment.mxcUri!.startsWith('mxc://')) {
473
+ opts,
474
+ ));
475
+ } else if (attachment.eventId) {
476
+ ({ bytes, httpType } = await loadMatrixEvent(
477
+ attachment.eventId,
478
+ roomId,
479
+ source,
480
+ maxBytes,
481
+ opts,
482
+ ));
483
+ } else if (attachment.mxcUri!.startsWith('mxc://')) {
341
484
  const bound = boundedSignal(
342
485
  opts.signal,
343
486
  opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
344
487
  );
345
488
  try {
346
- if (attachment.eventId) {
347
- if (!roomId)
348
- throw new Error(
349
- `Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
350
- );
351
- const media = await untilAborted(
352
- source.downloadEvent(
353
- roomId,
354
- attachment.eventId,
355
- maxBytes,
356
- bound.signal,
357
- ),
358
- bound.signal,
359
- );
360
- if (!media)
361
- throw new Error(`Matrix event ${attachment.eventId} not found`);
362
- bytes = media.bytes;
363
- httpType = media.mimetype;
364
- } else {
365
- bytes = await untilAborted(
366
- source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
367
- bound.signal,
368
- );
369
- }
489
+ bytes = await untilAborted(
490
+ source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
491
+ bound.signal,
492
+ );
370
493
  } finally {
371
494
  bound.release();
372
495
  }
@@ -73,7 +73,12 @@ export {
73
73
  MIN_CHARS_PER_PAGE,
74
74
  type PdfTextResult,
75
75
  } from './pdf-text';
76
- export { routeAttachment, type AttachmentStrategy } from './route';
76
+ export { resizeForModel, RESIZE_JPEG_QUALITY, RESIZE_MAX_EDGE } from './resize';
77
+ export {
78
+ NATIVE_DOCUMENT_MAX_BYTES,
79
+ routeAttachment,
80
+ type AttachmentStrategy,
81
+ } from './route';
77
82
  export {
78
83
  buildAnalysisMarkdown,
79
84
  uploadToSandbox,
@@ -94,21 +99,17 @@ export {
94
99
  type AttachmentInput,
95
100
  } from './types';
96
101
  export {
97
- applyAttachmentRetention,
98
- ATTACHMENT_PAYLOAD_TURNS,
99
102
  ATTACHMENT_PLACEHOLDER_PREFIX,
100
103
  ATTACHMENT_VIEW_SOURCE,
101
104
  attachmentMetas,
102
105
  attachmentRef,
103
106
  buildPlaceholderText,
104
107
  hasInlinePayload,
105
- INLINE_PAYLOAD_NEEDLE,
106
108
  isAttachmentPlaceholderText,
107
109
  isAttachmentViewMessage,
108
110
  isInlinePayloadBlock,
109
111
  offloadInlinePayloads,
110
112
  VIEW_ATTACHMENT_TOOL_NAME,
111
- type AttachmentRetentionResult,
112
113
  } from './retention';
113
114
  export {
114
115
  createAttachmentViewSurface,