@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -4,14 +4,15 @@
4
4
  * the Node runtime's SSRF guard, manual redirect validation and streaming
5
5
  * size cap.
6
6
  */
7
- import { detectMimeFromMagicBytes } from './magic';
7
+ import { containerMatchesClaim, detectMimeFromMagicBytes } from './magic';
8
8
  import type { AttachmentInput } from './types';
9
9
 
10
10
  export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
11
11
  export const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50 MB across all attachments
12
12
  export const ALLOWED_URI_SCHEMES = /^(mxc|https?):\/\//i;
13
13
  const MAX_REDIRECT_COUNT = 5;
14
- const DOWNLOAD_TIMEOUT_MS = 60_000;
14
+ /** Upper bound on one attachment download, from the request to the last byte. */
15
+ export const DOWNLOAD_TIMEOUT_MS = 60_000;
15
16
 
16
17
  /** Blocks redirects to internal / cloud-metadata addresses (SSRF). */
17
18
  const BLOCKED_HOST_PATTERNS = [
@@ -29,6 +30,60 @@ const BLOCKED_HOST_PATTERNS = [
29
30
  /^metadata\.google\.internal$/i,
30
31
  ];
31
32
 
33
+ /**
34
+ * The file itself is refused — empty, or larger than the cap it was fetched
35
+ * under. Another download of the same file cannot succeed, so the pipeline
36
+ * notes it instead of retrying.
37
+ */
38
+ export class AttachmentRejectedError extends Error {
39
+ override readonly name: string = 'AttachmentRejectedError';
40
+ }
41
+
42
+ /** The file is larger than the cap the download ran under. */
43
+ export class AttachmentTooLargeError extends AttachmentRejectedError {
44
+ override readonly name: string = 'AttachmentTooLargeError';
45
+ }
46
+
47
+ function tooLarge(maxBytes: number): AttachmentTooLargeError {
48
+ return new AttachmentTooLargeError(
49
+ `File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
50
+ );
51
+ }
52
+
53
+ /** The signal's reason as an Error (an aborter may pass any value). */
54
+ function abortError(signal: AbortSignal): Error {
55
+ const reason: unknown = signal.reason;
56
+ if (reason instanceof Error) return reason;
57
+ return new Error(
58
+ reason === undefined ? 'Attachment download aborted' : String(reason),
59
+ );
60
+ }
61
+
62
+ /**
63
+ * `work`, or the signal's reason as soon as it aborts — so a transfer that
64
+ * ignores the signal still cannot hold the caller past it.
65
+ */
66
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
67
+ if (signal.aborted) {
68
+ work.catch(() => undefined);
69
+ return Promise.reject(abortError(signal));
70
+ }
71
+ return new Promise<T>((resolve, reject) => {
72
+ const onAbort = (): void => reject(abortError(signal));
73
+ signal.addEventListener('abort', onAbort, { once: true });
74
+ work.then(
75
+ (value) => {
76
+ signal.removeEventListener('abort', onAbort);
77
+ resolve(value);
78
+ },
79
+ (error: unknown) => {
80
+ signal.removeEventListener('abort', onAbort);
81
+ reject(error instanceof Error ? error : new Error(String(error)));
82
+ },
83
+ );
84
+ });
85
+ }
86
+
32
87
  /** Throws when the URL is not http(s) or points at a blocked internal host. */
33
88
  export function validateUrlTarget(targetUrl: string): void {
34
89
  let parsed: URL;
@@ -50,13 +105,21 @@ export function validateUrlTarget(targetUrl: string): void {
50
105
  /**
51
106
  * Matrix media access the pipeline needs. Implemented over the gateway
52
107
  * Durable Object in the user object; tests pass an in-memory fake.
108
+ *
109
+ * `signal` aborts at the download deadline or when the turn is aborted: an
110
+ * implementation stops the transfer then (`readBytesCapped` with the signal
111
+ * cancels the stream). The pipeline stops waiting at that moment either way.
53
112
  */
54
113
  export interface MatrixMediaSource {
55
114
  /**
56
115
  * Raw bytes behind an `mxc://` URI (authenticated media download), the
57
116
  * transfer cut off once it passes `maxBytes`.
58
117
  */
59
- downloadMxc(mxc: string, maxBytes?: number): Promise<Uint8Array>;
118
+ downloadMxc(
119
+ mxc: string,
120
+ maxBytes?: number,
121
+ signal?: AbortSignal,
122
+ ): Promise<Uint8Array>;
60
123
  /**
61
124
  * Bytes of the media event `eventId` in `roomId`, decrypted when the event
62
125
  * carries an encrypted `file`, the transfer cut off once it passes
@@ -66,6 +129,7 @@ export interface MatrixMediaSource {
66
129
  roomId: string,
67
130
  eventId: string,
68
131
  maxBytes?: number,
132
+ signal?: AbortSignal,
69
133
  ): Promise<{
70
134
  bytes: Uint8Array;
71
135
  mimetype?: string;
@@ -80,16 +144,29 @@ export interface DownloadOptions {
80
144
  maxBytes?: number;
81
145
  }
82
146
 
83
- /** A controller that aborts on `signal` OR after `timeoutMs`, whichever first. */
147
+ /**
148
+ * A controller that aborts on `signal` (with its reason) OR after
149
+ * `timeoutMs` (with a timeout error), whichever comes first.
150
+ */
84
151
  function boundedSignal(
85
152
  signal: AbortSignal | undefined,
86
153
  timeoutMs: number,
87
154
  ): { signal: AbortSignal; release: () => void } {
88
155
  const controller = new AbortController();
89
- const onAbort = (): void => controller.abort();
90
- if (signal?.aborted) controller.abort();
156
+ const onAbort = (): void => {
157
+ if (signal) controller.abort(signal.reason);
158
+ };
159
+ if (signal?.aborted) controller.abort(signal.reason);
91
160
  signal?.addEventListener('abort', onAbort);
92
- const timer = setTimeout(onAbort, timeoutMs);
161
+ const timer = setTimeout(
162
+ () =>
163
+ controller.abort(
164
+ new Error(
165
+ `Attachment download timed out after ${Math.round(timeoutMs / 1000)} s`,
166
+ ),
167
+ ),
168
+ timeoutMs,
169
+ );
93
170
  return {
94
171
  signal: controller.signal,
95
172
  release: () => {
@@ -111,27 +188,43 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array {
111
188
 
112
189
  /**
113
190
  * Collect a stream into bytes with a running size check: the moment the
114
- * total passes `maxBytes` the stream is cancelled and an error thrown, so an
115
- * oversized file never lands in memory whole.
191
+ * total passes `maxBytes` the stream is cancelled and an
192
+ * `AttachmentTooLargeError` thrown, so an oversized file never lands in
193
+ * memory whole. When `signal` aborts, the stream is cancelled and the
194
+ * signal's reason thrown — a stalled source cannot hold the reader.
116
195
  */
117
196
  export async function readBytesCapped(
118
197
  stream: ReadableStream<Uint8Array>,
119
198
  maxBytes: number,
199
+ signal?: AbortSignal,
120
200
  ): Promise<Uint8Array> {
121
201
  const reader = stream.getReader();
202
+ const cancel = (reason?: unknown): void => {
203
+ reader.cancel(reason).catch(() => undefined);
204
+ };
205
+ if (signal?.aborted) {
206
+ cancel(signal.reason);
207
+ throw abortError(signal);
208
+ }
209
+ const onAbort = (): void => cancel(signal?.reason);
210
+ signal?.addEventListener('abort', onAbort, { once: true });
122
211
  const chunks: Uint8Array[] = [];
123
212
  let total = 0;
124
- for (;;) {
125
- const { done, value } = await reader.read();
126
- if (done) break;
127
- total += value.byteLength;
128
- if (total > maxBytes) {
129
- void reader.cancel();
130
- throw new Error(
131
- `File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
132
- );
213
+ try {
214
+ for (;;) {
215
+ const { done, value } = await reader.read();
216
+ // A cancel settles the pending read as `done`: not a complete file.
217
+ if (signal?.aborted) throw abortError(signal);
218
+ if (done) break;
219
+ total += value.byteLength;
220
+ if (total > maxBytes) {
221
+ cancel();
222
+ throw tooLarge(maxBytes);
223
+ }
224
+ chunks.push(value);
133
225
  }
134
- chunks.push(value);
226
+ } finally {
227
+ signal?.removeEventListener('abort', onAbort);
135
228
  }
136
229
  return concat(chunks, total);
137
230
  }
@@ -168,8 +261,13 @@ export async function downloadFromUrl(
168
261
  }
169
262
  break;
170
263
  }
171
- if (!response || (response.status >= 300 && response.status < 400)) {
172
- throw new Error('Too many redirects');
264
+ if (!response) throw new Error('Too many redirects');
265
+ if (response.status >= 300 && response.status < 400) {
266
+ throw new Error(
267
+ response.headers.get('location')
268
+ ? 'Too many redirects'
269
+ : `HTTP ${response.status} redirect without a Location header from ${currentUrl}`,
270
+ );
173
271
  }
174
272
  if (!response.ok) {
175
273
  throw new Error(`HTTP ${response.status} downloading ${url}`);
@@ -178,13 +276,13 @@ export async function downloadFromUrl(
178
276
  response.headers.get('content-type')?.split(';')[0]?.trim() ?? undefined;
179
277
  const contentLength = response.headers.get('content-length');
180
278
  if (contentLength && parseInt(contentLength, 10) > maxBytes) {
181
- throw new Error(
279
+ throw new AttachmentTooLargeError(
182
280
  `File too large: server reports ${Math.round(parseInt(contentLength, 10) / 1024 / 1024)} MB (limit: ${Math.round(maxBytes / 1024 / 1024)} MB)`,
183
281
  );
184
282
  }
185
283
  if (!response.body) throw new Error('Response body is not readable');
186
284
  return {
187
- data: await readBytesCapped(response.body, maxBytes),
285
+ data: await readBytesCapped(response.body, maxBytes, bound.signal),
188
286
  contentType,
189
287
  ...(currentUrl !== url ? { finalUrl: currentUrl } : {}),
190
288
  };
@@ -195,16 +293,20 @@ export async function downloadFromUrl(
195
293
 
196
294
  /**
197
295
  * Bytes for one attachment from whichever source it names, with the per-file
198
- * cap enforced before and after the transfer. `mimetype` is the best-known
199
- * type: content sniffing, then the HTTP `content-type`, then the client's
200
- * claim.
296
+ * cap (`opts.maxBytes`, default `MAX_FILE_SIZE`) enforced before and during
297
+ * the transfer and the `DOWNLOAD_TIMEOUT_MS` deadline (or `opts.timeoutMs`)
298
+ * on every source — Matrix media included. `mimetype` is the best-known
299
+ * type: content sniffing (`sniffed` is then true; a container signature
300
+ * consistent with the claim, such as a zip for a `.docx`, keeps the claimed
301
+ * type), then the HTTP `content-type`, then the client's claim. An empty
302
+ * file is refused.
201
303
  */
202
304
  export async function loadAttachmentBytes(
203
305
  attachment: AttachmentInput,
204
306
  roomId: string | undefined,
205
307
  source: MatrixMediaSource,
206
308
  opts: DownloadOptions = {},
207
- ): Promise<{ bytes: Uint8Array; mimetype: string }> {
309
+ ): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
208
310
  if (!attachment.eventId && !attachment.mxcUri) {
209
311
  throw new Error('Either mxcUri or eventId must be provided');
210
312
  }
@@ -213,26 +315,44 @@ export async function loadAttachmentBytes(
213
315
  }
214
316
  const maxBytes = opts.maxBytes ?? MAX_FILE_SIZE;
215
317
  if (attachment.size && attachment.size > maxBytes) {
216
- throw new Error('File exceeds maximum size');
318
+ throw new AttachmentTooLargeError('File exceeds maximum size');
217
319
  }
218
320
 
219
321
  let bytes: Uint8Array;
220
322
  let httpType: string | undefined;
221
- if (attachment.eventId) {
222
- if (!roomId)
223
- throw new Error(
224
- `Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
225
- );
226
- const media = await source.downloadEvent(
227
- roomId,
228
- attachment.eventId,
229
- maxBytes,
323
+ if (attachment.eventId || attachment.mxcUri!.startsWith('mxc://')) {
324
+ const bound = boundedSignal(
325
+ opts.signal,
326
+ opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
230
327
  );
231
- if (!media) throw new Error(`Matrix event ${attachment.eventId} not found`);
232
- bytes = media.bytes;
233
- httpType = media.mimetype;
234
- } else if (attachment.mxcUri!.startsWith('mxc://')) {
235
- bytes = await source.downloadMxc(attachment.mxcUri!, maxBytes);
328
+ try {
329
+ if (attachment.eventId) {
330
+ if (!roomId)
331
+ throw new Error(
332
+ `Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
333
+ );
334
+ const media = await untilAborted(
335
+ source.downloadEvent(
336
+ roomId,
337
+ attachment.eventId,
338
+ maxBytes,
339
+ bound.signal,
340
+ ),
341
+ bound.signal,
342
+ );
343
+ if (!media)
344
+ throw new Error(`Matrix event ${attachment.eventId} not found`);
345
+ bytes = media.bytes;
346
+ httpType = media.mimetype;
347
+ } else {
348
+ bytes = await untilAborted(
349
+ source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
350
+ bound.signal,
351
+ );
352
+ }
353
+ } finally {
354
+ bound.release();
355
+ }
236
356
  } else {
237
357
  const result = await downloadFromUrl(attachment.mxcUri!, {
238
358
  ...opts,
@@ -241,10 +361,12 @@ export async function loadAttachmentBytes(
241
361
  bytes = result.data;
242
362
  httpType = result.contentType;
243
363
  }
244
- if (bytes.length > maxBytes) {
245
- throw new Error('File exceeds maximum size');
246
- }
364
+ if (bytes.length > maxBytes) throw tooLarge(maxBytes);
365
+ if (bytes.length === 0) throw new AttachmentRejectedError('File is empty');
247
366
  const sniffed = detectMimeFromMagicBytes(bytes);
367
+ if (sniffed && containerMatchesClaim(sniffed, attachment.mimetype)) {
368
+ return { bytes, mimetype: attachment.mimetype, sniffed: true };
369
+ }
248
370
  return {
249
371
  bytes,
250
372
  mimetype:
@@ -252,5 +374,6 @@ export async function loadAttachmentBytes(
252
374
  (httpType && httpType !== 'application/octet-stream'
253
375
  ? httpType
254
376
  : attachment.mimetype),
377
+ sniffed: sniffed !== null,
255
378
  };
256
379
  }
@@ -5,6 +5,9 @@ export {
5
5
  } from './content-blocks';
6
6
  export {
7
7
  ALLOWED_URI_SCHEMES,
8
+ AttachmentRejectedError,
9
+ AttachmentTooLargeError,
10
+ DOWNLOAD_TIMEOUT_MS,
8
11
  downloadFromUrl,
9
12
  loadAttachmentBytes,
10
13
  MAX_FILE_SIZE,
@@ -83,3 +86,9 @@ export {
83
86
  type AttachmentViewSurface,
84
87
  type ViewAttachmentDeps,
85
88
  } from './view';
89
+ export {
90
+ AttachmentTextCacheStore,
91
+ LOCAL_TEXT_EXTRACTION,
92
+ VIEW_CACHE_MAX_CHARS,
93
+ type AttachmentTextCache,
94
+ } from './view-cache';
@@ -56,6 +56,38 @@ const MAGIC_MIME_CATEGORIES: Record<string, FileCategory[]> = {
56
56
  'video/mp4': ['video', 'audio'],
57
57
  };
58
58
 
59
+ /**
60
+ * Signatures that mark a container rather than one format: the bytes are
61
+ * consistent with every claim listed here, and the claim is the more precise
62
+ * type (a `.docx` is a zip archive, a HEIC photo an ISO-BMFF `ftyp` box).
63
+ */
64
+ const CONTAINER_CLAIMS: Record<string, (claimed: string) => boolean> = {
65
+ 'application/zip': (claimed) =>
66
+ claimed.startsWith('application/vnd.openxmlformats-officedocument.') ||
67
+ claimed.startsWith('application/vnd.oasis.opendocument.') ||
68
+ claimed === 'application/epub+zip',
69
+ 'application/msword': (claimed) =>
70
+ claimed === 'application/vnd.ms-excel' ||
71
+ claimed === 'application/vnd.ms-powerpoint',
72
+ 'video/mp4': (claimed) =>
73
+ /^image\/(heic|heif|avif)(-sequence)?$/.test(claimed) ||
74
+ /^audio\/(mp4|m4a|x-m4a)$/.test(claimed) ||
75
+ /^video\/(quicktime|3gpp2?)$/.test(claimed),
76
+ 'image/webp': (claimed) =>
77
+ /^audio\/(wav|wave|x-wav)$/.test(claimed) ||
78
+ /^video\/(avi|x-msvideo)$/.test(claimed),
79
+ 'video/webm': (claimed) =>
80
+ claimed === 'audio/webm' || /^(audio|video)\/x-matroska$/.test(claimed),
81
+ };
82
+
83
+ /** Whether a sniffed container type is consistent with the client's more precise claim. */
84
+ export function containerMatchesClaim(
85
+ sniffed: string,
86
+ claimed: string,
87
+ ): boolean {
88
+ return CONTAINER_CLAIMS[sniffed]?.(claimed.toLowerCase()) ?? false;
89
+ }
90
+
59
91
  /** The MIME a file's magic bytes announce, or null when no signature matches. */
60
92
  export function detectMimeFromMagicBytes(bytes: Uint8Array): string | null {
61
93
  if (bytes.length < 4) return null;
@@ -124,6 +156,7 @@ export function verifyMagicBytes(
124
156
  );
125
157
  return;
126
158
  }
159
+ if (containerMatchesClaim(detected, attachment.mimetype)) return;
127
160
  const allowed = MAGIC_MIME_CATEGORIES[detected];
128
161
  if (!allowed || !allowed.includes(claimedCategory)) {
129
162
  throw new Error(
@@ -6,18 +6,23 @@
6
6
  * 1. classify + route each attachment by the selected model's native input
7
7
  * capabilities (`send-native` / `parse-local` / `model-extract`);
8
8
  * 2. NATIVE lane: download, sniff, base64 into LangChain content blocks on
9
- * the human message (cumulative 50 MB budget; any failure or budget
10
- * overflow falls that file back to extraction so a bad download never
11
- * drops the whole message); the original is archived to the sandbox in
12
- * the background;
13
- * 3. EXTRACT lane: sequential (so the running download total is enforced
14
- * before the next fetch), plain text read locally, everything else
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;
15
+ * 3. EXTRACT lane: sequential, plain text read locally, everything else
15
16
  * described / transcribed / extracted by the helper model, then archived
16
17
  * to the sandbox — when the archive succeeds the text handed to the model
17
18
  * is truncated to 500 chars and points at the sandbox copy (the agent's
18
19
  * file tools read the rest), exactly as on Node;
19
20
  * 4. every attachment's metadata lands on the human message so transcripts
20
21
  * render file chips regardless of which lane consumed it.
22
+ *
23
+ * Both lanes share one `MAX_TOTAL_SIZE` download budget (`DownloadBudget`):
24
+ * declared sizes are checked against what is left before a download, and
25
+ * each download is cut off at what is left.
21
26
  */
22
27
  import type { MessageContent } from '@langchain/core/messages';
23
28
  import type { ModelInputCapabilities } from '../core/llm';
@@ -31,6 +36,8 @@ import {
31
36
  } from './content-blocks';
32
37
  import {
33
38
  ALLOWED_URI_SCHEMES,
39
+ AttachmentRejectedError,
40
+ AttachmentTooLargeError,
34
41
  loadAttachmentBytes,
35
42
  MAX_FILE_SIZE,
36
43
  MAX_TOTAL_SIZE,
@@ -118,22 +125,67 @@ function errorMessage(error: unknown): string {
118
125
  return error instanceof Error ? error.message : String(error);
119
126
  }
120
127
 
121
- /** One attachment through the extract lane (Node's `processAttachment`). */
128
+ const BUDGET_EXCEEDED = 'Total attachment size budget exceeded';
129
+
130
+ /**
131
+ * The turn's download budget: `MAX_TOTAL_SIZE` across every attachment,
132
+ * both lanes. A file is checked against what is left before its download
133
+ * (declared size) and fetched under a cap of what is left, so a download
134
+ * never runs past the budget and an over-budget file is never fetched twice.
135
+ */
136
+ class DownloadBudget {
137
+ private used = 0;
138
+
139
+ get remaining(): number {
140
+ return Math.max(0, MAX_TOTAL_SIZE - this.used);
141
+ }
142
+
143
+ /** The bytes of `attachment`, counted against the budget. */
144
+ async load(
145
+ attachment: AttachmentInput,
146
+ params: PrepareAttachmentsParams,
147
+ deps: AttachmentPipelineDeps,
148
+ ): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
149
+ if (attachment.size && attachment.size > MAX_FILE_SIZE)
150
+ throw new AttachmentTooLargeError('File exceeds maximum size');
151
+ const remaining = this.remaining;
152
+ if (remaining === 0 || (attachment.size && attachment.size > remaining))
153
+ throw new AttachmentRejectedError(BUDGET_EXCEEDED);
154
+ const cap = Math.min(MAX_FILE_SIZE, remaining);
155
+ try {
156
+ const loaded = await loadAttachmentBytes(
157
+ attachment,
158
+ params.roomId,
159
+ deps.source,
160
+ { fetchImpl: deps.fetchImpl, signal: deps.signal, maxBytes: cap },
161
+ );
162
+ this.used += loaded.bytes.length;
163
+ return loaded;
164
+ } catch (error) {
165
+ // Cut off by the budget rather than by the per-file cap.
166
+ if (error instanceof AttachmentTooLargeError && cap < MAX_FILE_SIZE)
167
+ throw new AttachmentRejectedError(BUDGET_EXCEEDED);
168
+ throw error;
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * One attachment through the extract lane (Node's `processAttachment`).
175
+ * `preloaded` is the file the native lane already downloaded.
176
+ */
122
177
  async function extractOne(
123
178
  attachment: AttachmentInput,
124
- currentTotal: number,
179
+ budget: DownloadBudget,
125
180
  params: PrepareAttachmentsParams,
126
181
  deps: AttachmentPipelineDeps,
127
- ): Promise<{ text: string; downloaded: number; usage?: ExtractionUsage }> {
182
+ preloaded?: Uint8Array,
183
+ ): Promise<{ text: string; usage?: ExtractionUsage }> {
128
184
  const safeName = sanitizeAttachmentFilename(attachment.filename);
129
185
  if (!attachment.eventId && !attachment.mxcUri)
130
186
  throw new Error('Either mxcUri or eventId must be provided');
131
187
  if (attachment.mxcUri && !ALLOWED_URI_SCHEMES.test(attachment.mxcUri))
132
188
  throw new Error('Invalid URI scheme');
133
- if (attachment.size && attachment.size > MAX_FILE_SIZE)
134
- throw new Error('File exceeds maximum size');
135
- if (attachment.size && currentTotal + attachment.size > MAX_TOTAL_SIZE)
136
- throw new Error('Total attachment size budget exceeded');
137
189
 
138
190
  const category: FileCategory = categorizeFile(attachment.mimetype);
139
191
  if (category === 'unsupported') {
@@ -142,16 +194,11 @@ async function extractOne(
142
194
  );
143
195
  return {
144
196
  text: `[File "${safeName}" (${attachment.mimetype}) is not a supported file type and could not be processed]`,
145
- downloaded: 0,
146
197
  };
147
198
  }
148
199
 
149
- const { bytes } = await loadAttachmentBytes(
150
- attachment,
151
- params.roomId,
152
- deps.source,
153
- { fetchImpl: deps.fetchImpl, signal: deps.signal },
154
- );
200
+ const bytes =
201
+ preloaded ?? (await budget.load(attachment, params, deps)).bytes;
155
202
  verifyMagicBytes(bytes, category, attachment, (m) =>
156
203
  deps.logger.warn(`[attachments] ${m}`),
157
204
  );
@@ -198,7 +245,7 @@ async function extractOne(
198
245
  }
199
246
 
200
247
  if (!deps.sandbox) {
201
- return { text, downloaded: bytes.length, ...(usage ? { usage } : {}) };
248
+ return { text, ...(usage ? { usage } : {}) };
202
249
  }
203
250
  const destPath = `${SANDBOX_OUTPUT_PREFIX}/${safeName}`;
204
251
  try {
@@ -245,7 +292,6 @@ async function extractOne(
245
292
  : `\n\n[Full file saved to sandbox at ${actualPath}]`;
246
293
  return {
247
294
  text: text.slice(0, SANDBOX_TRUNCATE_LIMIT) + paths,
248
- downloaded: bytes.length,
249
295
  ...(usage ? { usage } : {}),
250
296
  };
251
297
  }
@@ -254,7 +300,6 @@ async function extractOne(
254
300
  : `\n\n[File also saved to sandbox at ${actualPath}]`;
255
301
  return {
256
302
  text: text + suffix,
257
- downloaded: bytes.length,
258
303
  ...(usage ? { usage } : {}),
259
304
  };
260
305
  } catch (error) {
@@ -263,7 +308,6 @@ async function extractOne(
263
308
  );
264
309
  return {
265
310
  text: `${text}\n\n[Warning: sandbox upload failed — file content is included above]`,
266
- downloaded: bytes.length,
267
311
  ...(usage ? { usage } : {}),
268
312
  };
269
313
  }
@@ -290,7 +334,14 @@ export async function prepareAttachments(
290
334
  }
291
335
 
292
336
  const nativeCandidates: AttachmentInput[] = [];
293
- const extractQueue: AttachmentInput[] = [];
337
+ // A file the native lane already refused (empty, over budget) carries the
338
+ // reason and is noted; one it downloaded but will not send carries its
339
+ // bytes. Neither is downloaded again.
340
+ const extractQueue: Array<{
341
+ attachment: AttachmentInput;
342
+ refused?: Error;
343
+ preloaded?: Uint8Array;
344
+ }> = [];
294
345
  for (const attachment of params.attachments) {
295
346
  const kind = classifyAttachment(attachment);
296
347
  const strategy = routeAttachment(kind, params.caps);
@@ -298,31 +349,33 @@ export async function prepareAttachments(
298
349
  `[attachments] "${attachment.filename}" (${attachment.mimetype}) kind=${kind} model=${params.model} → ${strategy}`,
299
350
  );
300
351
  if (strategy === 'send-native') nativeCandidates.push(attachment);
301
- else extractQueue.push(attachment);
352
+ else extractQueue.push({ attachment });
302
353
  }
303
354
 
355
+ const budget = new DownloadBudget();
304
356
  const natives: NativeAttachment[] = [];
305
- let nativeBytesTotal = 0;
306
357
  for (const attachment of nativeCandidates) {
307
358
  try {
308
- const { bytes, mimetype } = await loadAttachmentBytes(
359
+ const { bytes, mimetype, sniffed } = await budget.load(
309
360
  attachment,
310
- params.roomId,
311
- deps.source,
312
- { fetchImpl: deps.fetchImpl, signal: deps.signal },
361
+ params,
362
+ deps,
313
363
  );
314
- if (nativeBytesTotal + bytes.length > MAX_TOTAL_SIZE) {
364
+ // Only a file whose bytes are what it claims goes to the model as a
365
+ // block: a sniffed type is classified on its own (a PDF named `.png`
366
+ // is a document), and a kind other than the claimed one goes through
367
+ // the extract lane, whose content check notes the mismatch.
368
+ const kind = classifyAttachment({
369
+ mimetype,
370
+ filename: sniffed ? '' : attachment.filename,
371
+ });
372
+ if (kind !== classifyAttachment(attachment)) {
315
373
  deps.logger.warn(
316
- `[attachments] native budget (${Math.round(MAX_TOTAL_SIZE / 1024 / 1024)} MB) exceeded at "${attachment.filename}" — falling back to extraction`,
374
+ `[attachments] "${attachment.filename}" claims ${attachment.mimetype} but is ${mimetype} — not sent natively`,
317
375
  );
318
- extractQueue.push(attachment);
376
+ extractQueue.push({ attachment, preloaded: bytes });
319
377
  continue;
320
378
  }
321
- nativeBytesTotal += bytes.length;
322
- const kind = classifyAttachment({
323
- mimetype,
324
- filename: attachment.filename,
325
- });
326
379
  natives.push({
327
380
  kind: kind === 'image' ? 'image' : 'file',
328
381
  mimeType: mimetype,
@@ -349,10 +402,17 @@ export async function prepareAttachments(
349
402
  });
350
403
  }
351
404
  } catch (error) {
405
+ if (error instanceof AttachmentRejectedError) {
406
+ deps.logger.warn(
407
+ `[attachments] "${attachment.filename}" refused: ${errorMessage(error)}`,
408
+ );
409
+ extractQueue.push({ attachment, refused: error });
410
+ continue;
411
+ }
352
412
  deps.logger.warn(
353
413
  `[attachments] native load failed for "${attachment.filename}", falling back to extraction: ${errorMessage(error)}`,
354
414
  );
355
- extractQueue.push(attachment);
415
+ extractQueue.push({ attachment });
356
416
  }
357
417
  }
358
418
 
@@ -366,23 +426,19 @@ export async function prepareAttachments(
366
426
  '[attachments] sandbox archive unavailable for this turn — originals are not archived',
367
427
  );
368
428
  }
369
- let downloadedTotal = 0;
370
- for (const attachment of extractQueue) {
429
+ for (const { attachment, refused, preloaded } of extractQueue) {
371
430
  const meta = metaFor(attachment);
372
431
  let text: string;
373
432
  try {
433
+ if (refused) throw refused;
374
434
  const result = await extractOne(
375
435
  attachment,
376
- downloadedTotal,
436
+ budget,
377
437
  params,
378
438
  deps,
439
+ preloaded,
379
440
  );
380
- downloadedTotal += result.downloaded;
381
- if (downloadedTotal > MAX_TOTAL_SIZE) {
382
- throw new Error(
383
- `Total downloaded size (${Math.round(downloadedTotal / 1024 / 1024)} MB) exceeds budget (${Math.round(MAX_TOTAL_SIZE / 1024 / 1024)} MB)`,
384
- );
385
- }
441
+ // Counted as soon as it is paid for, whatever happens next.
386
442
  if (result.usage) {
387
443
  usage.cost += result.usage.cost ?? 0;
388
444
  usage.promptTokens += result.usage.promptTokens ?? 0;