@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Turn-time attachment handling — the port of the Node runtime's
3
+ * `MessagesService` attachment section plus `FileProcessingService.
4
+ * processAttachments`:
5
+ *
6
+ * 1. classify + route each attachment by the selected model's native input
7
+ * capabilities (`send-native` / `parse-local` / `model-extract`);
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
15
+ * described / transcribed / extracted by the helper model, then archived
16
+ * to the sandbox — when the archive succeeds the text handed to the model
17
+ * is truncated to 500 chars and points at the sandbox copy (the agent's
18
+ * file tools read the rest), exactly as on Node;
19
+ * 4. every attachment's metadata lands on the human message so transcripts
20
+ * render file chips regardless of which lane consumed it.
21
+ */
22
+ import type { MessageContent } from '@langchain/core/messages';
23
+ import type { ModelInputCapabilities } from '../core/llm';
24
+ import { bytesToBase64 } from '../plugins/base64';
25
+ import type { Logger } from '../plugin-api/types';
26
+ import type { AttachmentMeta } from '../sqlite/serialization';
27
+ import { classifyAttachment } from './classify';
28
+ import {
29
+ buildUserMessageContent,
30
+ type NativeAttachment,
31
+ } from './content-blocks';
32
+ import {
33
+ ALLOWED_URI_SCHEMES,
34
+ loadAttachmentBytes,
35
+ MAX_FILE_SIZE,
36
+ MAX_TOTAL_SIZE,
37
+ type MatrixMediaSource,
38
+ } from './download';
39
+ import {
40
+ aiProcessFromUrl,
41
+ extractText,
42
+ formatContent,
43
+ type ExtractionProvider,
44
+ type ExtractionUsage,
45
+ } from './extract';
46
+ import { categorizeFile, verifyMagicBytes, type FileCategory } from './magic';
47
+ import { routeAttachment } from './route';
48
+ import {
49
+ buildAnalysisMarkdown,
50
+ uploadToSandbox,
51
+ type SandboxUploadConfig,
52
+ } from './sandbox-archive';
53
+ import {
54
+ sanitizeAttachmentFilename,
55
+ sanitizeSandboxPath,
56
+ SANDBOX_OUTPUT_PREFIX,
57
+ } from './sanitize';
58
+ import type { AttachmentInput } from './types';
59
+
60
+ /** Node's `SANDBOX_TRUNCATE_LIMIT`: what the model sees inline once the file is archived. */
61
+ export const SANDBOX_TRUNCATE_LIMIT = 500;
62
+
63
+ export interface AttachmentPipelineDeps {
64
+ source: MatrixMediaSource;
65
+ /** Helper-model provider for the extract lane; null → media cannot be extracted here. */
66
+ extraction: ExtractionProvider | null;
67
+ /** Sandbox archive target; null → originals are not archived (logged once per turn). */
68
+ sandbox: SandboxUploadConfig | null;
69
+ fetchImpl?: typeof fetch;
70
+ signal?: AbortSignal;
71
+ logger: Logger;
72
+ }
73
+
74
+ export interface ExtractedAttachment {
75
+ /** Model-facing text, prefixed with a `[source: …]` reference like Node's. */
76
+ text: string;
77
+ meta: AttachmentMeta;
78
+ }
79
+
80
+ export interface PreparedAttachments {
81
+ /** The human message content: the text, plus native blocks when any. */
82
+ content: MessageContent;
83
+ /** Metadata for every attachment, in request order (chips in transcripts). */
84
+ metas: AttachmentMeta[];
85
+ /** Extraction-lane results, to append as hidden context messages. */
86
+ extracted: ExtractedAttachment[];
87
+ usage: { cost: number; promptTokens: number; completionTokens: number };
88
+ }
89
+
90
+ export interface PrepareAttachmentsParams {
91
+ text: string;
92
+ attachments: AttachmentInput[];
93
+ /** Room for `eventId` downloads (the user↔oracle room, or the Matrix room the turn came from). */
94
+ roomId?: string;
95
+ /** The model the turn will run on and its native input capabilities. */
96
+ model: string;
97
+ caps: ModelInputCapabilities;
98
+ }
99
+
100
+ function metaFor(attachment: AttachmentInput): AttachmentMeta {
101
+ return {
102
+ filename: attachment.filename,
103
+ mimetype: attachment.mimetype,
104
+ ...(attachment.size !== undefined ? { size: attachment.size } : {}),
105
+ ...(attachment.mxcUri ? { mxcUri: attachment.mxcUri } : {}),
106
+ ...(attachment.eventId ? { eventId: attachment.eventId } : {}),
107
+ category: classifyAttachment(attachment),
108
+ };
109
+ }
110
+
111
+ function sourceRef(attachment: AttachmentInput): string {
112
+ if (attachment.eventId) return `[source: eventId="${attachment.eventId}"]`;
113
+ if (attachment.mxcUri) return `[source: url="${attachment.mxcUri}"]`;
114
+ return '';
115
+ }
116
+
117
+ function errorMessage(error: unknown): string {
118
+ return error instanceof Error ? error.message : String(error);
119
+ }
120
+
121
+ /** One attachment through the extract lane (Node's `processAttachment`). */
122
+ async function extractOne(
123
+ attachment: AttachmentInput,
124
+ currentTotal: number,
125
+ params: PrepareAttachmentsParams,
126
+ deps: AttachmentPipelineDeps,
127
+ ): Promise<{ text: string; downloaded: number; usage?: ExtractionUsage }> {
128
+ const safeName = sanitizeAttachmentFilename(attachment.filename);
129
+ if (!attachment.eventId && !attachment.mxcUri)
130
+ throw new Error('Either mxcUri or eventId must be provided');
131
+ if (attachment.mxcUri && !ALLOWED_URI_SCHEMES.test(attachment.mxcUri))
132
+ 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
+
138
+ const category: FileCategory = categorizeFile(attachment.mimetype);
139
+ if (category === 'unsupported') {
140
+ deps.logger.warn(
141
+ `[attachments] unsupported file type ${attachment.mimetype} for ${attachment.filename}`,
142
+ );
143
+ return {
144
+ text: `[File "${safeName}" (${attachment.mimetype}) is not a supported file type and could not be processed]`,
145
+ downloaded: 0,
146
+ };
147
+ }
148
+
149
+ const { bytes } = await loadAttachmentBytes(
150
+ attachment,
151
+ params.roomId,
152
+ deps.source,
153
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
154
+ );
155
+ verifyMagicBytes(bytes, category, attachment, (m) =>
156
+ deps.logger.warn(`[attachments] ${m}`),
157
+ );
158
+
159
+ // Public http(s) images/videos: let the provider fetch the URL itself.
160
+ const isHttpUrl =
161
+ Boolean(attachment.mxcUri) &&
162
+ !attachment.eventId &&
163
+ /^https?:\/\//i.test(attachment.mxcUri ?? '');
164
+ let text: string | undefined;
165
+ let usage: ExtractionUsage | undefined;
166
+ if (
167
+ isHttpUrl &&
168
+ deps.extraction &&
169
+ (category === 'image' || category === 'video')
170
+ ) {
171
+ try {
172
+ const passthrough = await aiProcessFromUrl(
173
+ attachment.mxcUri!,
174
+ category,
175
+ deps.extraction,
176
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
177
+ );
178
+ if (passthrough.content.trim().length > 0) {
179
+ text = formatContent('Description', safeName, passthrough.content);
180
+ usage = passthrough.usage;
181
+ }
182
+ } catch (error) {
183
+ deps.logger.warn(
184
+ `[attachments] URL passthrough failed for "${attachment.filename}", falling back to bytes: ${errorMessage(error)}`,
185
+ );
186
+ }
187
+ }
188
+ if (text === undefined) {
189
+ const extracted = await extractText(
190
+ bytes,
191
+ attachment,
192
+ category,
193
+ deps.extraction,
194
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
195
+ );
196
+ text = extracted.text;
197
+ usage = extracted.usage;
198
+ }
199
+
200
+ if (!deps.sandbox) {
201
+ return { text, downloaded: bytes.length, ...(usage ? { usage } : {}) };
202
+ }
203
+ const destPath = `${SANDBOX_OUTPUT_PREFIX}/${safeName}`;
204
+ try {
205
+ await uploadToSandbox(
206
+ bytes,
207
+ safeName,
208
+ destPath,
209
+ deps.sandbox,
210
+ attachment.mimetype,
211
+ deps.fetchImpl,
212
+ );
213
+ const actualPath = sanitizeSandboxPath(destPath);
214
+ let analysisPath: string | undefined;
215
+ if (category === 'image' || category === 'video' || category === 'audio') {
216
+ const analysisFilename = `${safeName.replace(/\.[^.]+$/, '')}-analysis.md`;
217
+ const analysisDest = `${SANDBOX_OUTPUT_PREFIX}/${analysisFilename}`;
218
+ try {
219
+ await uploadToSandbox(
220
+ new TextEncoder().encode(
221
+ buildAnalysisMarkdown(
222
+ safeName,
223
+ attachment.mimetype,
224
+ bytes.length,
225
+ category,
226
+ text,
227
+ ),
228
+ ),
229
+ analysisFilename,
230
+ analysisDest,
231
+ deps.sandbox,
232
+ 'text/markdown',
233
+ deps.fetchImpl,
234
+ );
235
+ analysisPath = sanitizeSandboxPath(analysisDest);
236
+ } catch (error) {
237
+ deps.logger.warn(
238
+ `[attachments] analysis .md upload failed for "${attachment.filename}": ${errorMessage(error)}`,
239
+ );
240
+ }
241
+ }
242
+ if (text.length > SANDBOX_TRUNCATE_LIMIT) {
243
+ const paths = analysisPath
244
+ ? `\n\n[Full analysis saved to sandbox at ${analysisPath}]\n[Original file saved to sandbox at ${actualPath}]`
245
+ : `\n\n[Full file saved to sandbox at ${actualPath}]`;
246
+ return {
247
+ text: text.slice(0, SANDBOX_TRUNCATE_LIMIT) + paths,
248
+ downloaded: bytes.length,
249
+ ...(usage ? { usage } : {}),
250
+ };
251
+ }
252
+ const suffix = analysisPath
253
+ ? `\n\n[Analysis saved to sandbox at ${analysisPath}]\n[File also saved to sandbox at ${actualPath}]`
254
+ : `\n\n[File also saved to sandbox at ${actualPath}]`;
255
+ return {
256
+ text: text + suffix,
257
+ downloaded: bytes.length,
258
+ ...(usage ? { usage } : {}),
259
+ };
260
+ } catch (error) {
261
+ deps.logger.warn(
262
+ `[attachments] sandbox upload failed for "${attachment.filename}": ${errorMessage(error)}`,
263
+ );
264
+ return {
265
+ text: `${text}\n\n[Warning: sandbox upload failed — file content is included above]`,
266
+ downloaded: bytes.length,
267
+ ...(usage ? { usage } : {}),
268
+ };
269
+ }
270
+ }
271
+
272
+ export async function prepareAttachments(
273
+ params: PrepareAttachmentsParams,
274
+ deps: AttachmentPipelineDeps,
275
+ ): Promise<PreparedAttachments> {
276
+ const metas = params.attachments.map(metaFor);
277
+ const usage = { cost: 0, promptTokens: 0, completionTokens: 0 };
278
+ if (params.attachments.length === 0) {
279
+ return { content: params.text, metas, extracted: [], usage };
280
+ }
281
+
282
+ const reportedTotal = params.attachments.reduce(
283
+ (sum, a) => sum + (a.size ?? 0),
284
+ 0,
285
+ );
286
+ if (reportedTotal > MAX_TOTAL_SIZE) {
287
+ throw new Error(
288
+ `Total attachment size (${Math.round(reportedTotal / 1024 / 1024)} MB) exceeds budget (${Math.round(MAX_TOTAL_SIZE / 1024 / 1024)} MB)`,
289
+ );
290
+ }
291
+
292
+ const nativeCandidates: AttachmentInput[] = [];
293
+ const extractQueue: AttachmentInput[] = [];
294
+ for (const attachment of params.attachments) {
295
+ const kind = classifyAttachment(attachment);
296
+ const strategy = routeAttachment(kind, params.caps);
297
+ deps.logger.log(
298
+ `[attachments] "${attachment.filename}" (${attachment.mimetype}) kind=${kind} model=${params.model} → ${strategy}`,
299
+ );
300
+ if (strategy === 'send-native') nativeCandidates.push(attachment);
301
+ else extractQueue.push(attachment);
302
+ }
303
+
304
+ const natives: NativeAttachment[] = [];
305
+ let nativeBytesTotal = 0;
306
+ for (const attachment of nativeCandidates) {
307
+ try {
308
+ const { bytes, mimetype } = await loadAttachmentBytes(
309
+ attachment,
310
+ params.roomId,
311
+ deps.source,
312
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
313
+ );
314
+ if (nativeBytesTotal + bytes.length > MAX_TOTAL_SIZE) {
315
+ deps.logger.warn(
316
+ `[attachments] native budget (${Math.round(MAX_TOTAL_SIZE / 1024 / 1024)} MB) exceeded at "${attachment.filename}" — falling back to extraction`,
317
+ );
318
+ extractQueue.push(attachment);
319
+ continue;
320
+ }
321
+ nativeBytesTotal += bytes.length;
322
+ const kind = classifyAttachment({
323
+ mimetype,
324
+ filename: attachment.filename,
325
+ });
326
+ natives.push({
327
+ kind: kind === 'image' ? 'image' : 'file',
328
+ mimeType: mimetype,
329
+ base64: bytesToBase64(bytes),
330
+ filename: attachment.filename,
331
+ });
332
+ deps.logger.log(
333
+ `[attachments] NATIVE → "${attachment.filename}" sent directly to ${params.model} (${bytes.length} bytes, ${kind})`,
334
+ );
335
+ if (deps.sandbox) {
336
+ const safeName = sanitizeAttachmentFilename(attachment.filename);
337
+ // Off the hot path — the agent's file tools can reach the original later.
338
+ void uploadToSandbox(
339
+ bytes,
340
+ safeName,
341
+ `${SANDBOX_OUTPUT_PREFIX}/${safeName}`,
342
+ deps.sandbox,
343
+ mimetype,
344
+ deps.fetchImpl,
345
+ ).catch((error: unknown) => {
346
+ deps.logger.warn(
347
+ `[attachments] background archive failed for "${attachment.filename}": ${errorMessage(error)}`,
348
+ );
349
+ });
350
+ }
351
+ } catch (error) {
352
+ deps.logger.warn(
353
+ `[attachments] native load failed for "${attachment.filename}", falling back to extraction: ${errorMessage(error)}`,
354
+ );
355
+ extractQueue.push(attachment);
356
+ }
357
+ }
358
+
359
+ const extracted: ExtractedAttachment[] = [];
360
+ if (extractQueue.length > 0) {
361
+ deps.logger.log(
362
+ `[attachments] EXTRACT ${extractQueue.length} attachment(s) (local parse for text, helper model otherwise)`,
363
+ );
364
+ if (!deps.sandbox) {
365
+ deps.logger.warn(
366
+ '[attachments] sandbox archive unavailable for this turn — originals are not archived',
367
+ );
368
+ }
369
+ let downloadedTotal = 0;
370
+ for (const attachment of extractQueue) {
371
+ const meta = metaFor(attachment);
372
+ let text: string;
373
+ try {
374
+ const result = await extractOne(
375
+ attachment,
376
+ downloadedTotal,
377
+ params,
378
+ deps,
379
+ );
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
+ }
386
+ if (result.usage) {
387
+ usage.cost += result.usage.cost ?? 0;
388
+ usage.promptTokens += result.usage.promptTokens ?? 0;
389
+ usage.completionTokens += result.usage.completionTokens ?? 0;
390
+ }
391
+ text = result.text;
392
+ } catch (error) {
393
+ deps.logger.error(
394
+ `[attachments] failed to process ${attachment.filename}: ${errorMessage(error)}`,
395
+ );
396
+ text = `[File "${sanitizeAttachmentFilename(attachment.filename)}" (${attachment.mimetype}) failed to process: ${errorMessage(error)}]`;
397
+ }
398
+ const ref = sourceRef(attachment);
399
+ extracted.push({ text: ref ? `${ref}\n${text}` : text, meta });
400
+ }
401
+ }
402
+
403
+ return {
404
+ content: buildUserMessageContent(params.text, natives),
405
+ metas,
406
+ extracted,
407
+ usage,
408
+ };
409
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Attachment payload retention.
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.
16
+ */
17
+ import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
18
+ import type { AttachmentMeta } from '../sqlite/serialization';
19
+
20
+ /** Newest user turns whose inline attachment payloads stay in the messages. */
21
+ export const ATTACHMENT_PAYLOAD_TURNS = 2;
22
+
23
+ /**
24
+ * `additional_kwargs.lc_source` of the human message `view_attachment` adds
25
+ * to re-attach a payload — hidden from transcripts and not a user turn.
26
+ */
27
+ export const ATTACHMENT_VIEW_SOURCE = 'attachment_view';
28
+
29
+ /** Every placeholder text block starts with this (transcripts drop it). */
30
+ export const ATTACHMENT_PLACEHOLDER_PREFIX = '[attachment offloaded]';
31
+
32
+ /**
33
+ * Substring every inline payload block serialises to (`message_content`
34
+ * holds the JSON of an array content), for a SQL `LIKE` pre-filter that
35
+ * finds candidate rows without inflating message blobs.
36
+ */
37
+ export const INLINE_PAYLOAD_NEEDLE = '"source_type":"base64"';
38
+
39
+ export const VIEW_ATTACHMENT_TOOL_NAME = 'view_attachment';
40
+
41
+ interface InlinePayloadBlock {
42
+ type: string;
43
+ source_type: 'base64';
44
+ data: string;
45
+ mime_type?: string;
46
+ filename?: string;
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === 'object' && value !== null;
51
+ }
52
+
53
+ function isAttachmentMeta(value: unknown): value is AttachmentMeta {
54
+ return (
55
+ isRecord(value) &&
56
+ typeof value.filename === 'string' &&
57
+ typeof value.mimetype === 'string' &&
58
+ typeof value.category === 'string'
59
+ );
60
+ }
61
+
62
+ /** A LangChain data content block carrying base64 bytes (`image` / `file`). */
63
+ export function isInlinePayloadBlock(
64
+ block: unknown,
65
+ ): block is InlinePayloadBlock {
66
+ return (
67
+ isRecord(block) &&
68
+ typeof block.type === 'string' &&
69
+ block.source_type === 'base64' &&
70
+ typeof block.data === 'string'
71
+ );
72
+ }
73
+
74
+ export function hasInlinePayload(message: BaseMessage): boolean {
75
+ return (
76
+ Array.isArray(message.content) &&
77
+ message.content.some((block) => isInlinePayloadBlock(block))
78
+ );
79
+ }
80
+
81
+ export function isAttachmentPlaceholderText(text: string): boolean {
82
+ return text.startsWith(ATTACHMENT_PLACEHOLDER_PREFIX);
83
+ }
84
+
85
+ /** The re-attachment message `view_attachment` injects (not a user turn). */
86
+ export function isAttachmentViewMessage(message: BaseMessage): boolean {
87
+ return (
88
+ message.type === 'human' &&
89
+ message.additional_kwargs?.lc_source === ATTACHMENT_VIEW_SOURCE
90
+ );
91
+ }
92
+
93
+ /** The reference `view_attachment` takes: the media event id, else the URI. */
94
+ export function attachmentRef(meta: AttachmentMeta): string | undefined {
95
+ return meta.eventId ?? meta.mxcUri;
96
+ }
97
+
98
+ /** The attachment metadata a message carries (`attachments`, else `attachment`). */
99
+ export function attachmentMetas(message: BaseMessage): AttachmentMeta[] {
100
+ const kw = message.additional_kwargs ?? {};
101
+ if (Array.isArray(kw.attachments)) {
102
+ return kw.attachments.filter(isAttachmentMeta);
103
+ }
104
+ return isAttachmentMeta(kw.attachment) ? [kw.attachment] : [];
105
+ }
106
+
107
+ function formatSize(size: number | undefined): string {
108
+ if (size === undefined) return '';
109
+ if (size < 1024) return `, ${size} B`;
110
+ if (size < 1024 * 1024) return `, ${Math.round(size / 1024)} KB`;
111
+ return `, ${(size / 1024 / 1024).toFixed(1)} MB`;
112
+ }
113
+
114
+ /** The text block that replaces a message's inline payload blocks. */
115
+ export function buildPlaceholderText(metas: AttachmentMeta[]): string {
116
+ const lines = metas.map((meta, i) => {
117
+ const ref = attachmentRef(meta);
118
+ return `${i + 1}. "${meta.filename}" (${meta.mimetype}${formatSize(meta.size)})${
119
+ ref ? ` — ref: ${ref}` : ' — no reference; it cannot be fetched again'
120
+ }`;
121
+ });
122
+ const listing =
123
+ lines.length > 0
124
+ ? `Attachments of this message:\n${lines.join('\n')}\n`
125
+ : 'This message had an attachment whose metadata was not recorded; it cannot be fetched again.\n';
126
+ return (
127
+ `${ATTACHMENT_PLACEHOLDER_PREFIX} The attachment bytes of this message are no longer inline ` +
128
+ `(only the newest ${ATTACHMENT_PAYLOAD_TURNS} user turns keep them). ${listing}` +
129
+ `To look at one again, call ${VIEW_ATTACHMENT_TOOL_NAME} with its ref.`
130
+ );
131
+ }
132
+
133
+ /**
134
+ * A copy of `message` with every inline payload block replaced by one
135
+ * placeholder text block (id, kwargs and response metadata preserved), or
136
+ * null when the message carries no inline payload.
137
+ */
138
+ export function offloadInlinePayloads(
139
+ message: BaseMessage,
140
+ ): BaseMessage | null {
141
+ if (!Array.isArray(message.content)) return null;
142
+ if (!message.content.some((block) => isInlinePayloadBlock(block)))
143
+ return null;
144
+ const placeholder = {
145
+ type: 'text',
146
+ text: buildPlaceholderText(attachmentMetas(message)),
147
+ };
148
+ let placed = false;
149
+ const content: typeof message.content = [];
150
+ for (const block of message.content) {
151
+ if (!isInlinePayloadBlock(block)) {
152
+ content.push(block);
153
+ continue;
154
+ }
155
+ if (!placed) {
156
+ content.push(placeholder);
157
+ placed = true;
158
+ }
159
+ }
160
+ return new HumanMessage({
161
+ id: message.id,
162
+ content,
163
+ additional_kwargs: message.additional_kwargs,
164
+ response_metadata: message.response_metadata,
165
+ });
166
+ }
167
+
168
+ export interface AttachmentRetentionResult {
169
+ /** Ids of the messages whose inline payload stays (inside the window). */
170
+ retainedIds: Set<string>;
171
+ /** Rewritten copies of the messages that fell out of the window. */
172
+ rewrites: BaseMessage[];
173
+ }
174
+
175
+ /**
176
+ * Decide, for the thread's current messages (oldest first), which inline
177
+ * payloads stay and which get a placeholder. A payload belongs to the user
178
+ * turn it was posted in — a re-attachment message belongs to the turn of the
179
+ * user message before it — and stays while that turn is one of the newest
180
+ * `keepTurns` user turns. Messages without an id cannot be rewritten in
181
+ * place and are left alone.
182
+ */
183
+ export function applyAttachmentRetention(
184
+ messages: readonly BaseMessage[],
185
+ keepTurns: number = ATTACHMENT_PAYLOAD_TURNS,
186
+ ): AttachmentRetentionResult {
187
+ const retainedIds = new Set<string>();
188
+ const rewrites: BaseMessage[] = [];
189
+ let turnsSeen = 0;
190
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
191
+ const message = messages[i]!;
192
+ if (message.type !== 'human') continue;
193
+ const userTurn = !isAttachmentViewMessage(message);
194
+ if (userTurn) turnsSeen += 1;
195
+ if (!hasInlinePayload(message) || !message.id) continue;
196
+ const turnIndex = userTurn ? turnsSeen : turnsSeen + 1;
197
+ if (turnIndex <= keepTurns) {
198
+ retainedIds.add(message.id);
199
+ continue;
200
+ }
201
+ const rewritten = offloadInlinePayloads(message);
202
+ if (rewritten) rewrites.push(rewritten);
203
+ }
204
+ return { retainedIds, rewrites };
205
+ }
@@ -0,0 +1,41 @@
1
+ import type { ModelInputCapabilities } from '../core/llm';
2
+ import type { AttachmentKind } from './classify';
3
+
4
+ /**
5
+ * How an attachment reaches the model (verbatim port of the Node rule):
6
+ * - `parse-local` — read to text ourselves (free). Plain-text files only.
7
+ * - `send-native` — attach the raw bytes to the user message; the selected
8
+ * model reads it directly. Only when the model accepts that
9
+ * modality.
10
+ * - `model-extract` — the helper (vision) model turns it into text first. Used
11
+ * for anything the selected model can't accept natively.
12
+ */
13
+ export type AttachmentStrategy =
14
+ | 'parse-local'
15
+ | 'send-native'
16
+ | 'model-extract';
17
+
18
+ /**
19
+ * Decide, for one attachment, how to get it to the selected model — the single
20
+ * cost-aware, capability-aware routing rule. Pure and total.
21
+ */
22
+ export function routeAttachment(
23
+ kind: AttachmentKind,
24
+ caps: ModelInputCapabilities,
25
+ ): AttachmentStrategy {
26
+ switch (kind) {
27
+ case 'text':
28
+ return 'parse-local';
29
+ case 'image':
30
+ return caps.image ? 'send-native' : 'model-extract';
31
+ case 'document':
32
+ return caps.file ? 'send-native' : 'model-extract';
33
+ case 'audio':
34
+ return caps.audio ? 'send-native' : 'model-extract';
35
+ case 'video':
36
+ return caps.video ? 'send-native' : 'model-extract';
37
+ case 'unknown':
38
+ default:
39
+ return 'model-extract';
40
+ }
41
+ }