@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.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Recalls the images of recent user turns into each model request.
3
+ *
4
+ * Attachment bytes are never stored with the conversation: the checkpointer
5
+ * keeps a placeholder text block in their place (`src/sqlite/
6
+ * inline-payloads.ts`), so a message loaded from the checkpoint — every
7
+ * earlier turn, and the turn itself after a durable run restarted — reaches
8
+ * the model without the image the person sent. Right before each model call
9
+ * this middleware puts the images of the newest `ATTACHMENT_RECALL_TURNS`
10
+ * user turns back, in the request only (graph state, the checkpoint and the
11
+ * transcript never see them):
12
+ *
13
+ * - a human message qualifies when it holds the placeholder (its bytes
14
+ * were left out of storage), no inline block, and image attachments in
15
+ * its metadata; a `view_attachment` re-attachment message belongs to the
16
+ * user turn before it;
17
+ * - images only: a document is never recalled, the model calls
18
+ * `view_attachment` for it;
19
+ * - an image already in the request (a message that still holds its
20
+ * bytes: the turn's own message, a `view_attachment` re-attachment) is
21
+ * not recalled again, and neither is the same file under another
22
+ * reference (a channel photo sent twice gets a new media event id but
23
+ * keeps its `channelRef`);
24
+ * - at most `ATTACHMENT_RECALL_MAX_IMAGES` per request, newest first,
25
+ * fetched one at a time; an image whose recorded size is over
26
+ * `ATTACHMENT_RECALL_MAX_IMAGE_BYTES` is skipped without a fetch, one of
27
+ * unknown size is fetched with that cap and skipped when cut off; the
28
+ * images of one request hold at most `ATTACHMENT_RECALL_MAX_TOTAL_BYTES`
29
+ * of base64 together;
30
+ * - a finished image (resized, base64) is kept in a small LRU
31
+ * (`RecalledImageCache`) that the host holds per user object, so the
32
+ * next turn's recall neither fetches nor transforms it again; each fetch
33
+ * has a deadline of `ATTACHMENT_RECALL_TIMEOUT_MS`; a failure leaves the
34
+ * placeholder in place and is logged once per reference per run;
35
+ * - when the provider refuses a request that carries recalled images with
36
+ * a client error (4xx, not a context overflow, not a credential, billing,
37
+ * timeout or rate-limit status), the call is made once more with the
38
+ * request as it came in (no recalled images), and those images are not
39
+ * recalled again for the rest of the run;
40
+ * - nothing happens when the turn's model does not read images.
41
+ *
42
+ * The placeholder of a message whose images came back lists only the
43
+ * attachments that did not, or goes when none is left. The middleware sits
44
+ * innermost, after the context guard: the guard's chars/4 estimate would
45
+ * count an image's base64 as text, far above what the provider charges for
46
+ * it. The guard therefore never sees the recalled images.
47
+ */
48
+ import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
49
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
50
+ import { isContextOverflowError } from '../context-window';
51
+ import {
52
+ attachmentMetas,
53
+ attachmentRef,
54
+ buildPlaceholderText,
55
+ hasInlinePayload,
56
+ isAttachmentPlaceholderText,
57
+ isAttachmentViewMessage,
58
+ } from '../../attachments/retention';
59
+ import {
60
+ nativeContentBlock,
61
+ type NativeAttachment,
62
+ } from '../../attachments/content-blocks';
63
+ import type { Logger } from '../../plugin-api/types';
64
+ import type { AttachmentMeta } from '../../sqlite/serialization';
65
+ import { NOOP_LOGGER } from '../utils';
66
+
67
+ /** Newest user turns whose images are recalled into a request. */
68
+ export const ATTACHMENT_RECALL_TURNS = 2;
69
+
70
+ /** Images recalled into one request at most. */
71
+ export const ATTACHMENT_RECALL_MAX_IMAGES = 4;
72
+
73
+ /** Deadline of one image fetch, from the request to the resized bytes. */
74
+ export const ATTACHMENT_RECALL_TIMEOUT_MS = 15_000;
75
+
76
+ /**
77
+ * Largest original image recalled (bytes). A larger recorded size is
78
+ * skipped without a fetch; the loader cuts a download off at this size.
79
+ */
80
+ export const ATTACHMENT_RECALL_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
81
+
82
+ /**
83
+ * Base64 characters the recalled images of one request hold together (the
84
+ * request carries them as they are, ~4/3 of the image bytes).
85
+ */
86
+ export const ATTACHMENT_RECALL_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
87
+
88
+ /** Finished images a `RecalledImageCache` keeps by default. */
89
+ export const ATTACHMENT_RECALL_CACHE_ENTRIES = 8;
90
+
91
+ /** Base64 characters a `RecalledImageCache` keeps by default. */
92
+ export const ATTACHMENT_RECALL_CACHE_BYTES = 10 * 1024 * 1024;
93
+
94
+ /**
95
+ * Fetches one image attachment of the session, ready for the model (the
96
+ * host downloads, checks and resizes it). The download must stop once it
97
+ * passes `maxBytes` (a rejection then). Resolves null when the file is not
98
+ * available, or when the image may not go to the model natively (the
99
+ * resize step reports it `dropped`); a rejection counts the same.
100
+ */
101
+ export type RecallImageLoader = (
102
+ meta: AttachmentMeta,
103
+ opts: { signal: AbortSignal; maxBytes: number },
104
+ ) => Promise<NativeAttachment | null>;
105
+
106
+ /**
107
+ * The finished (resized, base64) images recalled lately, by reference,
108
+ * least recently used evicted first, bounded by entry count and by base64
109
+ * characters. The host keeps one per user object and hands it to every
110
+ * turn's recall, so an image recalled on one turn is neither fetched nor
111
+ * transformed (each transformation is billed) on the next.
112
+ */
113
+ export class RecalledImageCache {
114
+ private readonly entries = new Map<string, NativeAttachment>();
115
+ private chars = 0;
116
+
117
+ constructor(
118
+ private readonly maxEntries = ATTACHMENT_RECALL_CACHE_ENTRIES,
119
+ private readonly maxChars = ATTACHMENT_RECALL_CACHE_BYTES,
120
+ ) {}
121
+
122
+ get(ref: string): NativeAttachment | undefined {
123
+ const hit = this.entries.get(ref);
124
+ if (hit === undefined) return undefined;
125
+ // Re-inserted: a Map iterates in insertion order, oldest first.
126
+ this.entries.delete(ref);
127
+ this.entries.set(ref, hit);
128
+ return hit;
129
+ }
130
+
131
+ set(ref: string, native: NativeAttachment): void {
132
+ const size = native.base64.length;
133
+ this.delete(ref);
134
+ if (size > this.maxChars) return;
135
+ this.entries.set(ref, native);
136
+ this.chars += size;
137
+ for (const [oldest, entry] of this.entries) {
138
+ if (this.entries.size <= this.maxEntries && this.chars <= this.maxChars)
139
+ break;
140
+ this.entries.delete(oldest);
141
+ this.chars -= entry.base64.length;
142
+ }
143
+ }
144
+
145
+ get size(): number {
146
+ return this.entries.size;
147
+ }
148
+
149
+ private delete(ref: string): void {
150
+ const entry = this.entries.get(ref);
151
+ if (entry === undefined) return;
152
+ this.entries.delete(ref);
153
+ this.chars -= entry.base64.length;
154
+ }
155
+ }
156
+
157
+ export interface AttachmentRecallMiddlewareOptions {
158
+ loadImage: RecallImageLoader;
159
+ /** Whether the turn's model reads images; nothing is recalled otherwise. */
160
+ imageInput: boolean;
161
+ /**
162
+ * The finished-image cache shared across turns (one per user object).
163
+ * Absent = a cache for this recaller only, i.e. for one run.
164
+ */
165
+ cache?: RecalledImageCache;
166
+ /** The turn's abort signal: a cancelled turn stops its fetches. */
167
+ signal?: AbortSignal;
168
+ timeoutMs?: number;
169
+ maxImages?: number;
170
+ maxImageBytes?: number;
171
+ maxTotalBytes?: number;
172
+ logger?: Logger;
173
+ }
174
+
175
+ /** What one `recall` put into the request. */
176
+ export interface RecallResult {
177
+ /** The request's messages: the same array when nothing came back. */
178
+ messages: BaseMessage[];
179
+ /** The references of the images put back, newest first. */
180
+ recalled: string[];
181
+ }
182
+
183
+ interface RecallCandidate {
184
+ index: number;
185
+ message: BaseMessage;
186
+ images: AttachmentMeta[];
187
+ }
188
+
189
+ function isRecord(value: unknown): value is Record<string, unknown> {
190
+ return typeof value === 'object' && value !== null;
191
+ }
192
+
193
+ function isPlaceholderBlock(block: unknown): boolean {
194
+ return (
195
+ isRecord(block) &&
196
+ block.type === 'text' &&
197
+ typeof block.text === 'string' &&
198
+ isAttachmentPlaceholderText(block.text)
199
+ );
200
+ }
201
+
202
+ function hasPlaceholder(message: BaseMessage): boolean {
203
+ return (
204
+ Array.isArray(message.content) &&
205
+ message.content.some((block) => isPlaceholderBlock(block))
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Every identifier of one attachment: the media event id, the channel
211
+ * file's reference and the URI. A channel photo sent again gets a new
212
+ * event id but keeps its `channelRef`, so either one names the same file.
213
+ */
214
+ function attachmentKeys(meta: AttachmentMeta): string[] {
215
+ const keys: string[] = [];
216
+ for (const key of [meta.eventId, meta.channelRef, meta.mxcUri])
217
+ if (key !== undefined && key.length > 0) keys.push(key);
218
+ return keys;
219
+ }
220
+
221
+ /**
222
+ * The human messages of the newest `ATTACHMENT_RECALL_TURNS` user turns
223
+ * whose image bytes were left out of storage, newest first, with those
224
+ * images: each file once across the request, none that a message of the
225
+ * request still holds inline, none in `excluded` (by reference).
226
+ */
227
+ function recallCandidates(
228
+ messages: readonly BaseMessage[],
229
+ excluded: ReadonlySet<string>,
230
+ ): RecallCandidate[] {
231
+ const candidates: RecallCandidate[] = [];
232
+ // The files the request already carries as bytes come first, wherever
233
+ // they sit: the turn's own message, a re-attachment message.
234
+ const seen = new Set<string>();
235
+ for (const message of messages) {
236
+ if (message.type !== 'human' || !hasInlinePayload(message)) continue;
237
+ for (const meta of attachmentMetas(message))
238
+ for (const key of attachmentKeys(meta)) seen.add(key);
239
+ }
240
+ let turnsSeen = 0;
241
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
242
+ const message = messages[i]!;
243
+ if (message.type !== 'human') continue;
244
+ const userTurn = !isAttachmentViewMessage(message);
245
+ if (userTurn) turnsSeen += 1;
246
+ // A re-attachment belongs to the user turn before it (not counted yet).
247
+ const turnIndex = userTurn ? turnsSeen : turnsSeen + 1;
248
+ if (turnIndex > ATTACHMENT_RECALL_TURNS) {
249
+ if (userTurn) break;
250
+ continue;
251
+ }
252
+ if (hasInlinePayload(message) || !hasPlaceholder(message)) continue;
253
+ const images = attachmentMetas(message).filter((meta) => {
254
+ const ref = attachmentRef(meta);
255
+ if (meta.category !== 'image' || ref === undefined) return false;
256
+ const keys = attachmentKeys(meta);
257
+ if (keys.some((key) => seen.has(key))) return false;
258
+ // An excluded file still claims its keys: an older copy of it under
259
+ // another reference is not recalled in its place.
260
+ for (const key of keys) seen.add(key);
261
+ return !excluded.has(ref);
262
+ });
263
+ if (images.length > 0) candidates.push({ index: i, message, images });
264
+ }
265
+ return candidates;
266
+ }
267
+
268
+ /**
269
+ * A copy of the candidate's message with its recalled images in place of
270
+ * its placeholder: the image blocks, then a placeholder for the attachments
271
+ * still left out.
272
+ */
273
+ function withRecalledImages(
274
+ { message, images }: RecallCandidate,
275
+ recalled: ReadonlyMap<string, NativeAttachment>,
276
+ ): BaseMessage {
277
+ if (!Array.isArray(message.content)) return message;
278
+ const own = new Set(images.map((meta) => attachmentRef(meta)));
279
+ const restored: NativeAttachment[] = [];
280
+ const left: AttachmentMeta[] = [];
281
+ for (const meta of attachmentMetas(message)) {
282
+ const ref = attachmentRef(meta);
283
+ const native =
284
+ ref !== undefined && own.has(ref) ? recalled.get(ref) : undefined;
285
+ if (native) restored.push(native);
286
+ else left.push(meta);
287
+ }
288
+ if (restored.length === 0) return message;
289
+ const content: typeof message.content = [];
290
+ for (const block of message.content) {
291
+ if (!isPlaceholderBlock(block)) {
292
+ content.push(block);
293
+ continue;
294
+ }
295
+ content.push(...restored.map(nativeContentBlock));
296
+ if (left.length > 0)
297
+ content.push({ type: 'text', text: buildPlaceholderText(left) });
298
+ }
299
+ return new HumanMessage({
300
+ id: message.id,
301
+ content,
302
+ ...(message.name !== undefined ? { name: message.name } : {}),
303
+ additional_kwargs: message.additional_kwargs,
304
+ response_metadata: message.response_metadata,
305
+ });
306
+ }
307
+
308
+ function errorText(error: unknown): string {
309
+ return error instanceof Error ? error.message : String(error);
310
+ }
311
+
312
+ /** How far an error's `cause` chain is walked (one wrap per middleware layer). */
313
+ const MAX_CAUSE_DEPTH = 32;
314
+
315
+ /**
316
+ * 4xx statuses that say nothing about the request's content: credentials,
317
+ * billing, a timeout, a rate limit. Dropping the images cannot help them.
318
+ */
319
+ const NOT_CONTENT_STATUSES = new Set([401, 402, 403, 408, 429]);
320
+
321
+ /** The HTTP status of a provider error or of one it wraps. */
322
+ function providerStatus(error: unknown): number | undefined {
323
+ let current: unknown = error;
324
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
325
+ if (typeof current !== 'object' || current === null) return undefined;
326
+ const status: unknown = Reflect.get(current, 'status');
327
+ if (typeof status === 'number') return status;
328
+ const response: unknown = Reflect.get(current, 'response');
329
+ if (typeof response === 'object' && response !== null) {
330
+ const responseStatus: unknown = Reflect.get(response, 'status');
331
+ if (typeof responseStatus === 'number') return responseStatus;
332
+ }
333
+ current = Reflect.get(current, 'cause');
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ /** Whether the error, or one it wraps, is a context overflow. */
339
+ function isOverflow(error: unknown): boolean {
340
+ let current: unknown = error;
341
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
342
+ if (current === undefined || current === null) return false;
343
+ if (isContextOverflowError(current)) return true;
344
+ if (typeof current !== 'object') return false;
345
+ current = Reflect.get(current, 'cause');
346
+ }
347
+ return false;
348
+ }
349
+
350
+ /**
351
+ * The status of a provider refusal the recalled images may have caused: a
352
+ * client error (4xx) that is not a context overflow (the context guard's
353
+ * business) and not one of `NOT_CONTENT_STATUSES`. Undefined otherwise.
354
+ */
355
+ function imageRefusalStatus(error: unknown): number | undefined {
356
+ const status = providerStatus(error);
357
+ if (status === undefined || status < 400 || status >= 500) return undefined;
358
+ if (NOT_CONTENT_STATUSES.has(status) || isOverflow(error)) return undefined;
359
+ return status;
360
+ }
361
+
362
+ /**
363
+ * The recall step on its own: `recall(messages)` returns the request's
364
+ * messages with recent images put back (the same array when nothing came
365
+ * back) and the references put back. Holds the run's failures; finished
366
+ * images live in the `RecalledImageCache`.
367
+ */
368
+ export function createAttachmentRecaller(
369
+ options: AttachmentRecallMiddlewareOptions,
370
+ ): {
371
+ recall(messages: BaseMessage[]): Promise<RecallResult>;
372
+ /** Never recall these references again in this run. */
373
+ exclude(refs: readonly string[]): void;
374
+ } {
375
+ const logger = options.logger ?? NOOP_LOGGER;
376
+ const timeoutMs = options.timeoutMs ?? ATTACHMENT_RECALL_TIMEOUT_MS;
377
+ const maxImages = options.maxImages ?? ATTACHMENT_RECALL_MAX_IMAGES;
378
+ const maxImageBytes =
379
+ options.maxImageBytes ?? ATTACHMENT_RECALL_MAX_IMAGE_BYTES;
380
+ const maxTotal = options.maxTotalBytes ?? ATTACHMENT_RECALL_MAX_TOTAL_BYTES;
381
+ const cache = options.cache ?? new RecalledImageCache();
382
+ // References not recalled again this run: a failed or skipped fetch (so
383
+ // it is neither retried nor logged again) and an image the provider
384
+ // refused.
385
+ const excluded = new Set<string>();
386
+
387
+ const fetchImage = async (
388
+ ref: string,
389
+ meta: AttachmentMeta,
390
+ ): Promise<NativeAttachment | null> => {
391
+ const cached = cache.get(ref);
392
+ if (cached) return cached;
393
+ if (meta.size !== undefined && meta.size > maxImageBytes) {
394
+ excluded.add(ref);
395
+ logger.warn(
396
+ `[attachments] recall: ${ref} is ${meta.size} bytes, over the ${maxImageBytes}-byte recall limit; the placeholder stays`,
397
+ );
398
+ return null;
399
+ }
400
+ const deadline = AbortSignal.timeout(timeoutMs);
401
+ const signal = options.signal
402
+ ? AbortSignal.any([options.signal, deadline])
403
+ : deadline;
404
+ const started = Date.now();
405
+ try {
406
+ const native = await options.loadImage(meta, {
407
+ signal,
408
+ maxBytes: maxImageBytes,
409
+ });
410
+ if (!native) {
411
+ excluded.add(ref);
412
+ logger.warn(
413
+ `[attachments] recall: ${ref} is not available; the placeholder stays`,
414
+ );
415
+ return null;
416
+ }
417
+ logger.log(
418
+ `[attachments] recall: ${ref} re-attached (${native.mimeType}, ${native.base64.length} base64 chars, ${Date.now() - started} ms)`,
419
+ );
420
+ cache.set(ref, native);
421
+ return native;
422
+ } catch (error) {
423
+ excluded.add(ref);
424
+ logger.warn(
425
+ `[attachments] recall: ${ref} failed (${errorText(error)}); the placeholder stays`,
426
+ );
427
+ return null;
428
+ }
429
+ };
430
+
431
+ return {
432
+ exclude(refs) {
433
+ for (const ref of refs) excluded.add(ref);
434
+ },
435
+ async recall(messages) {
436
+ const nothing: RecallResult = { messages, recalled: [] };
437
+ if (!options.imageInput) return nothing;
438
+ const candidates = recallCandidates(messages, excluded);
439
+ if (candidates.length === 0) return nothing;
440
+
441
+ // Newest first, at most `maxImages` across the request.
442
+ const wanted: Array<{ ref: string; meta: AttachmentMeta }> = [];
443
+ for (const candidate of candidates) {
444
+ for (const meta of candidate.images) {
445
+ const ref = attachmentRef(meta);
446
+ if (ref === undefined || wanted.length >= maxImages) continue;
447
+ wanted.push({ ref, meta });
448
+ }
449
+ }
450
+ // One at a time: only one download and its base64 are in flight.
451
+ const recalled = new Map<string, NativeAttachment>();
452
+ let total = 0;
453
+ for (const { ref, meta } of wanted) {
454
+ if (options.signal?.aborted) break;
455
+ const native = await fetchImage(ref, meta);
456
+ if (!native) continue;
457
+ if (total + native.base64.length > maxTotal) {
458
+ logger.warn(
459
+ `[attachments] recall: ${ref} left out (${native.base64.length} base64 chars; the request already holds ${total} of ${maxTotal})`,
460
+ );
461
+ continue;
462
+ }
463
+ total += native.base64.length;
464
+ recalled.set(ref, native);
465
+ }
466
+ if (recalled.size === 0) return nothing;
467
+
468
+ const out = messages.slice();
469
+ for (const candidate of candidates)
470
+ out[candidate.index] = withRecalledImages(candidate, recalled);
471
+ return { messages: out, recalled: [...recalled.keys()] };
472
+ },
473
+ };
474
+ }
475
+
476
+ export function createAttachmentRecallMiddleware(
477
+ options: AttachmentRecallMiddlewareOptions,
478
+ ): AgentMiddleware {
479
+ const logger = options.logger ?? NOOP_LOGGER;
480
+ const recaller = createAttachmentRecaller(options);
481
+ return createMiddleware({
482
+ name: 'AttachmentRecallMiddleware',
483
+ wrapModelCall: async (request, handler) => {
484
+ const { messages, recalled } = await recaller.recall(request.messages);
485
+ if (recalled.length === 0) return handler(request);
486
+ try {
487
+ return await handler({ ...request, messages });
488
+ } catch (error) {
489
+ const status = imageRefusalStatus(error);
490
+ if (status === undefined || options.signal?.aborted) throw error;
491
+ // The images may be what the provider refused (a type, size or
492
+ // dimension it does not take): once more without them, and not
493
+ // again this run, so the turn and its next calls go through.
494
+ recaller.exclude(recalled);
495
+ logger.warn(
496
+ `[attachments] recall: the provider refused a request with ${recalled.length} recalled image(s) (${status}: ${errorText(error).slice(0, 200)}); retrying once without them, not recalled again this run: ${recalled.join(', ')}`,
497
+ );
498
+ return handler(request);
499
+ }
500
+ },
501
+ });
502
+ }
@@ -18,6 +18,11 @@ export {
18
18
  } from './summarization';
19
19
  export type { SummarizationMiddlewareOptions } from './summarization';
20
20
  export { createByoHistorySanitizerMiddleware } from './byo-history-sanitizer';
21
+ export { createAttachmentRecallMiddleware } from './attachment-recall';
22
+ export type {
23
+ AttachmentRecallMiddlewareOptions,
24
+ RecallImageLoader,
25
+ } from './attachment-recall';
21
26
  export {
22
27
  createDanglingToolCallRepairMiddleware,
23
28
  repairDanglingToolCalls,
@@ -248,6 +248,13 @@ export interface OracleWorkerEnv {
248
248
  * `"ai": { "binding": "AI" }` in the wrangler config.
249
249
  */
250
250
  AI?: Ai;
251
+ /**
252
+ * Cloudflare Images binding. Optional: images sent natively to a model are
253
+ * downscaled to a 1568 px long edge as JPEG first (`attachments/resize.ts`); the
254
+ * sandbox keeps the original. Absent = images go as they are. Declare it
255
+ * with `"images": { "binding": "IMAGES" }` in the wrangler config.
256
+ */
257
+ IMAGES?: ImagesBinding;
251
258
 
252
259
  // --- llm ----------------------------------------------------------------
253
260
  OPEN_ROUTER_API_KEY: string;
@@ -982,10 +989,9 @@ export interface CreateRoomOptions {
982
989
  }
983
990
 
984
991
  /**
985
- * Deadline for one gateway media download, from opening it to the last byte
986
- * (the gateway's default applies when omitted). Only the timeout crosses the
987
- * RPC: an `AbortSignal` cannot, so the caller stops a transfer early by
988
- * cancelling the returned stream.
992
+ * Deadline for one gateway media event lookup (`eventMediaInfo`; the
993
+ * gateway's default applies when omitted). Only the timeout crosses the RPC:
994
+ * an `AbortSignal` cannot.
989
995
  */
990
996
  export interface MediaDownloadDeadline {
991
997
  timeoutMs?: number;
@@ -1000,22 +1006,20 @@ export interface MediaDownloadDeadline {
1000
1006
  */
1001
1007
  export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1002
1008
  /**
1003
- * A media event's content as a stream, AS STORED: in an E2EE room the
1004
- * ciphertext plus the `EncryptedFile` fields to pipe through the SDK's
1005
- * `createAttachmentDecryptor` on the caller's side (a stream that errors
1006
- * on the far side of an RPC only surfaces here as a disconnect, so the
1007
- * hash check belongs to the consumer). Null when unknown or redacted.
1009
+ * Where the media of event `eventId` in `roomId` lives, as small JSON: the
1010
+ * gateway fetches and decrypts the EVENT (only it holds the room keys),
1011
+ * never the media. The caller downloads `mxc` from the homeserver itself
1012
+ * (`openHomeserverMedia`, with the plugin device's token) and decrypts it
1013
+ * with `file` (`createAttachmentDecryptor`), so no media byte crosses the
1014
+ * shared gateway object. Bounded by `opts.timeoutMs` (default the media
1015
+ * download deadline). Null when the event is unknown, redacted or carries
1016
+ * no media.
1008
1017
  */
1009
- downloadEventMediaStream(
1018
+ eventMediaInfo(
1010
1019
  roomId: string,
1011
1020
  eventId: string,
1012
1021
  opts?: MediaDownloadDeadline,
1013
- ): Promise<MediaStream | null>;
1014
- /** Authenticated media download of an `mxc://` URI, as a stream. */
1015
- downloadMxcMediaStream(
1016
- mxc: string,
1017
- opts?: MediaDownloadDeadline,
1018
- ): Promise<ReadableStream<Uint8Array>>;
1022
+ ): Promise<EventMediaInfo | null>;
1019
1023
  /** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
1020
1024
  debugAbortObject(): Promise<void>;
1021
1025
  /** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
@@ -1042,11 +1046,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1042
1046
  listOutbox(): Promise<OutboxRow[]>;
1043
1047
  /**
1044
1048
  * Credentials of the oracle's second, crypto-less device for plugins that
1045
- * run their own Matrix client (editor, flows). Logged in once with the
1046
- * account password, kept in the gateway's storage, re-logged in with the
1047
- * same device id if the homeserver ever rejects the token.
1049
+ * run their own Matrix client (editor, flows) and for the user objects'
1050
+ * own media transfers (channel file uploads, Matrix media downloads).
1051
+ * Logged in once with the account password, kept in the gateway's
1052
+ * storage; concurrent first calls share one login. A caller whose
1053
+ * request the homeserver answered 401 passes the token it used as
1054
+ * `rejected`: when that is still the cached token, the cache is dropped and
1055
+ * the stored token re-validated (`whoami`), else the device is logged in
1056
+ * again with the same device id — once, for every caller waiting.
1048
1057
  */
1049
- botCredentials(): Promise<BotCredentials>;
1058
+ botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
1050
1059
  status(): Promise<GatewayStatus>;
1051
1060
  /** Send a text message (encrypted when the room is). Returns the event id. */
1052
1061
  sendText(
@@ -1075,12 +1084,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1075
1084
  * event id. `txnId` pins the transaction id, as for `sendText`: the
1076
1085
  * homeserver deduplicates by it per device, so a send retried after a lost
1077
1086
  * response returns the original event instead of posting a second one.
1087
+ * `priority: 'background'` (channel text mirrors, channel file events)
1088
+ * goes through the gateway's small background lane, so such sends never
1089
+ * hold more than a few of the send path's places ahead of a reply
1090
+ * (`interactive`, the default). Content carrying an attachment key
1091
+ * (`file.key`) is refused for a room the gateway does not see as
1092
+ * end-to-end encrypted.
1078
1093
  */
1079
1094
  sendEvent(
1080
1095
  roomId: string,
1081
1096
  type: string,
1082
1097
  content: JsonString,
1083
- opts?: { txnId?: string },
1098
+ opts?: { txnId?: string; priority?: 'interactive' | 'background' },
1084
1099
  ): Promise<string>;
1085
1100
  /** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
1086
1101
  resolveUserRoom(
@@ -1105,7 +1120,7 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1105
1120
  ): Promise<{ eventId: string }>;
1106
1121
  /**
1107
1122
  * The latest snapshot for `storageKey` as a stream, as stored (see
1108
- * `downloadEventMediaStream`), or null when none exists.
1123
+ * `MediaStream`), or null when none exists.
1109
1124
  */
1110
1125
  downloadUserSnapshotStream(
1111
1126
  userDid: string,
@@ -1200,7 +1215,6 @@ export interface OutboxRow {
1200
1215
  enqueuedAt: number;
1201
1216
  }
1202
1217
 
1203
- /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
1204
1218
  /**
1205
1219
  * Media handed across the Durable Object boundary as a stream, as stored on
1206
1220
  * the homeserver: `file` is present when the event carried an encrypted
@@ -1216,6 +1230,19 @@ export interface MediaStream {
1216
1230
  filename?: string;
1217
1231
  }
1218
1232
 
1233
+ /**
1234
+ * Where a media event's file lives (`MatrixGatewayObject.eventMediaInfo`):
1235
+ * its `mxc://` URI and, for an encrypted attachment, the `EncryptedFile`
1236
+ * fields to decrypt it with. `size` is the sender's `info.size` when set.
1237
+ */
1238
+ export interface EventMediaInfo {
1239
+ mxc: string;
1240
+ file?: EncryptedFileInfo;
1241
+ mimetype?: string;
1242
+ filename?: string;
1243
+ size?: number;
1244
+ }
1245
+
1219
1246
  /** `MediaStream` of a user's snapshot, plus the media event that carries it. */
1220
1247
  export interface SnapshotStream extends MediaStream {
1221
1248
  eventId: string;
@@ -1229,6 +1256,7 @@ export interface GroupRoomInfo {
1229
1256
  groupLane: boolean;
1230
1257
  }
1231
1258
 
1259
+ /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
1232
1260
  export interface GatewayStatus extends BotStatus {
1233
1261
  turns: { inFlight: number; waiting: number };
1234
1262
  /** Channel memory of group rooms: messages awaiting compaction, summary chunks, pinned facts. */