@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
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @ixo/oracle-runtime-workers
2
+
3
+ Run a QiForge oracle on **Cloudflare Workers** — no server, no disk, no Redis.
4
+ The same plugin API, wire protocol and UCAN auth as `@ixo/oracle-runtime`,
5
+ re-hosted on Durable Objects.
6
+
7
+ ```ts
8
+ // src/index.ts of your oracle Worker
9
+ import { createOracleWorker, WeatherPlugin } from '@ixo/oracle-runtime-workers';
10
+
11
+ const oracle = createOracleWorker({
12
+ config: { name: 'My Oracle', org: 'IXO', description: '…' },
13
+ plugins: [new WeatherPlugin()],
14
+ });
15
+
16
+ export const { UserOracleDO, MatrixGatewayDO } = oracle;
17
+ export default oracle; // { fetch, scheduled }
18
+ ```
19
+
20
+ Deploying is `wrangler deploy` with two Durable Object bindings and a
21
+ handful of secrets. [`apps/qiforge-workers-example`](../../apps/qiforge-workers-example)
22
+ is the complete, runnable reference: the single-script `wrangler.jsonc` for
23
+ local development, the two-script devnet configs, `.dev.vars.example`, and
24
+ the test suites.
25
+
26
+ ## How it works
27
+
28
+ One Worker deployment = one oracle.
29
+
30
+ ```mermaid
31
+ graph LR
32
+ Client[Portal / SDK / curl] -->|HTTP + SSE, UCAN auth| Shell[Hono shell]
33
+ Shell -->|per user DID| UserDO[UserOracleDO × N users]
34
+ Matrix[(Matrix homeserver)] <-->|/sync, E2EE| Gateway[MatrixGatewayDO × 1]
35
+ Gateway -->|decrypted turn| UserDO
36
+ UserDO -->|reply / media| Gateway
37
+ UserDO -->|export .db.gz| Owner[User-owned file in the IXO VFS]
38
+ ```
39
+
40
+ - **`UserOracleDO`** — one per user DID. The user's SQLite database
41
+ (checkpoints, sessions, transcript) lives in Durable Object storage behind
42
+ wa-sqlite; the object runs the agent turn and streams SSE itself.
43
+ - **`MatrixGatewayDO`** — one per oracle. A subclass of `MatrixBotDO` from
44
+ [`@ixo/matrix-bot-workers-sdk`](https://www.npmjs.com/package/@ixo/matrix-bot-workers-sdk),
45
+ which owns the bot identity, E2EE, the sync loop, paced durable sends,
46
+ catch-up after a restart and device rotation. The subclass adds the
47
+ oracle parts: room messages → user turns, room ↔ user routing, snapshot
48
+ media, the account-room secrets key, the plugins' second device.
49
+ - **The user owns the file.** The object holds a working copy; the durable
50
+ file is `/.oracles/<oracleDid>/state.db.gz` in the user's IXO VFS,
51
+ exported on a debounced alarm and re-imported on a cold boot.
52
+ - **Two scripts in production.** The gateway runs in its own Worker script
53
+ so its memory footprint never competes with user objects; the scripts
54
+ talk over cross-script Durable Object bindings.
55
+
56
+ ## Deploy in five steps
57
+
58
+ 1. Copy the example's `wrangler.gateway.<env>.jsonc` and
59
+ `wrangler.<env>.jsonc`; set the names, `ORACLE_DID`, `ORACLE_ENTITY_DID`,
60
+ `MATRIX_*` vars and the plugin URLs.
61
+ 2. Deploy the gateway script, then its secrets
62
+ (`MATRIX_ORACLE_ADMIN_PASSWORD`, `MATRIX_RECOVERY_PHRASE`,
63
+ `MATRIX_VALUE_PIN`).
64
+ 3. Deploy the oracle script with its secrets (`OPEN_ROUTER_API_KEY`, the
65
+ same Matrix secrets, `ORACLE_SIGNING_MNEMONIC`).
66
+ 4. `POST /matrix/start`, then `GET /matrix/status` until `running` and
67
+ `cryptoReady` are true.
68
+ 5. Run the feature matrix against it (see [testing](docs/testing.md)).
69
+
70
+ Every variable, binding and migration is explained in
71
+ [configuration](docs/configuration.md).
72
+
73
+ ## Operate
74
+
75
+ | Route | Purpose |
76
+ | ----------------------------------------------------- | ----------------------------------------------------- |
77
+ | `GET /health`, `GET /health/matrix` | Liveness; the gateway's health (503 when not running) |
78
+ | `GET /matrix/status`, `POST /matrix/start` | Gateway status and start |
79
+ | `POST/GET /sessions`, `POST/GET /messages/:id` | The chat API |
80
+ | `/delegation`, `/models`, `/socket.io/`, `/byo-llm/*` | Delegation, models, realtime channel, BYO LLM |
81
+ | `/debug/*` (with `ORACLE_DEBUG_ROUTES=true`) | Storage, tasks, sockets, outbox, gateway restart |
82
+
83
+ The status fields, the runbook and the behaviour of both objects over their
84
+ lifetime are in [operations](docs/operations.md).
85
+
86
+ ## Documentation
87
+
88
+ | Page | What it covers |
89
+ | -------------------------------------- | ---------------------------------------------------------------------------------------- |
90
+ | [architecture](docs/architecture.md) | The objects, the gateway split, self-sovereign storage, cost model, the rules of workerd |
91
+ | [node-parity](docs/node-parity.md) | What is identical to the Node runtime, what is ported differently, what is not ported |
92
+ | [configuration](docs/configuration.md) | Wrangler config, migrations, every env var, first-time setup, the bot's send rate |
93
+ | [operations](docs/operations.md) | Routes, status fields, gateway and user-object lifecycle, the ChatGPT proxy, the runbook |
94
+ | [testing](docs/testing.md) | Unit, harness e2e, the devnet feature matrix, stress |
95
+ | [load-tests](docs/load-tests.md) | Measurements and the findings that shaped the gateway |
96
+
97
+ ## Status (September 2026)
98
+
99
+ - Deployed on devnet as `mike-devnet-oracle` + `mike-devnet-oracle-gateway`
100
+ and exercised by the full feature matrix and the 18-user stress run after
101
+ the move to `@ixo/matrix-bot-workers-sdk`; 0.1.3 is the first release fit
102
+ for production (0.1.0–0.1.2 could send plaintext into an encrypted room
103
+ after a lazy-member sync delta, and queued session markers behind
104
+ background replays — both fixed in 0.1.3 and re-measured, see
105
+ [load-tests](docs/load-tests.md#session-creation-is-the-one-funnel)).
106
+ - Open: the memory engine's recall ranking on accounts with a long history;
107
+ the storage cost of task-holding users at scale (R2 page tier, see
108
+ [architecture](docs/architecture.md#storage-cost-planning)).
109
+ - Not ported on purpose: the Slack transport, the commerce lane and Matrix
110
+ group chats — see [node-parity](docs/node-parity.md#not-ported).
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@ixo/oracle-runtime-workers",
3
+ "version": "0.2.0",
4
+ "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
+ "private": false,
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/ixoworld/qiforge.git"
12
+ },
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "vitest run",
20
+ "test:core": "vitest run --config vitest.core.config.ts",
21
+ "lint": "eslint 'src/**/*.ts'"
22
+ },
23
+ "main": "src/index.ts",
24
+ "types": "src/index.ts",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./gateway": "./src/gateway-worker.ts"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "!src/**/*.test.ts",
32
+ "!src/**/__snapshots__/",
33
+ "!src/**/fixtures/",
34
+ "!src/**/test-do.ts",
35
+ "!src/core/test-fixtures.ts"
36
+ ],
37
+ "dependencies": {
38
+ "@blocknote/server-util": "0.29.1",
39
+ "@composio/core": "0.10.0",
40
+ "@composio/langchain": "0.9.2",
41
+ "@ixo/editor": "6.0.1",
42
+ "@ixo/matrix-bot-workers-sdk": "0.1.3",
43
+ "@ixo/matrix-crdt": "^1.2.5",
44
+ "@ixo/ucan": "workspace:*",
45
+ "@langchain/core": "1.1.48",
46
+ "@langchain/langgraph": "1.3.2",
47
+ "@langchain/langgraph-checkpoint": "1.0.2",
48
+ "@langchain/mcp-adapters": "1.1.3",
49
+ "@langchain/openai": "1.4.7",
50
+ "@noble/hashes": "^1.8.0",
51
+ "cron-parser": "^5.5.0",
52
+ "gray-matter": "^4.0.3",
53
+ "hono": "^4.13.4",
54
+ "jose": "^6.0.0",
55
+ "langchain": "1.4.2",
56
+ "langsmith": "^0.5.26",
57
+ "linkedom": "^0.18.12",
58
+ "marked": "^16.4.0",
59
+ "matrix-js-sdk": "^42.2.0",
60
+ "superjson": "1.13.3",
61
+ "wa-sqlite": "1.0.0",
62
+ "yjs": "^13.6.27",
63
+ "zod": "^4.4.3"
64
+ },
65
+ "devDependencies": {
66
+ "@cloudflare/vitest-pool-workers": "^0.22.0",
67
+ "@cloudflare/workers-types": "^5.20260825.1",
68
+ "@ixo/typescript-config": "workspace:*",
69
+ "happy-dom": "^19.0.0",
70
+ "typescript": "^5.9.3",
71
+ "vitest": "^4.0.0",
72
+ "wrangler": "^4.125.0"
73
+ },
74
+ "peerDependencies": {
75
+ "typescript": ">=5.0.0"
76
+ }
77
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Classify an attachment into a coarse `kind` from its mimetype (primary) and
3
+ * filename extension (fallback for generic mimetypes like
4
+ * `application/octet-stream`). The kind drives routing in `route.ts`.
5
+ * Verbatim port of the Node runtime's `attachments/classify.ts`.
6
+ *
7
+ * `text` means "plain text we can read ourselves for free" (csv/txt/json/md/…)
8
+ * — deliberately separate from `document` (pdf/office), which is richer and
9
+ * either sent to the model natively or extracted by the helper model.
10
+ */
11
+
12
+ export type AttachmentKind =
13
+ | 'text'
14
+ | 'document'
15
+ | 'image'
16
+ | 'audio'
17
+ | 'video'
18
+ | 'unknown';
19
+
20
+ const TEXT_MIMES = new Set([
21
+ 'application/json',
22
+ 'application/ld+json',
23
+ 'application/xml',
24
+ 'application/rtf',
25
+ 'application/yaml',
26
+ 'application/x-yaml',
27
+ 'application/csv',
28
+ ]);
29
+ const TEXT_EXTENSIONS = new Set([
30
+ 'txt',
31
+ 'csv',
32
+ 'tsv',
33
+ 'json',
34
+ 'md',
35
+ 'markdown',
36
+ 'xml',
37
+ 'yaml',
38
+ 'yml',
39
+ 'log',
40
+ 'html',
41
+ 'htm',
42
+ 'rtf',
43
+ ]);
44
+
45
+ const DOCUMENT_MIMES = new Set([
46
+ 'application/pdf',
47
+ 'application/msword',
48
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
49
+ 'application/vnd.ms-excel',
50
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
51
+ 'application/vnd.ms-powerpoint',
52
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ ]);
54
+ const DOCUMENT_EXTENSIONS = new Set([
55
+ 'pdf',
56
+ 'doc',
57
+ 'docx',
58
+ 'xls',
59
+ 'xlsx',
60
+ 'ppt',
61
+ 'pptx',
62
+ ]);
63
+
64
+ const IMAGE_EXTENSIONS = new Set([
65
+ 'jpg',
66
+ 'jpeg',
67
+ 'png',
68
+ 'gif',
69
+ 'webp',
70
+ 'bmp',
71
+ 'tiff',
72
+ 'tif',
73
+ 'heic',
74
+ 'svg',
75
+ ]);
76
+ const AUDIO_EXTENSIONS = new Set([
77
+ 'mp3',
78
+ 'wav',
79
+ 'ogg',
80
+ 'oga',
81
+ 'm4a',
82
+ 'flac',
83
+ 'aac',
84
+ ]);
85
+ const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
86
+
87
+ function extensionOf(filename: string): string {
88
+ const dot = filename.lastIndexOf('.');
89
+ return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '';
90
+ }
91
+
92
+ export function classifyAttachment(input: {
93
+ mimetype: string;
94
+ filename: string;
95
+ }): AttachmentKind {
96
+ const mime = input.mimetype.toLowerCase();
97
+ const ext = extensionOf(input.filename);
98
+
99
+ // Plain text first — cheap to read locally, and it should win over a generic
100
+ // `application/*` classification (e.g. a `.csv` sent as octet-stream).
101
+ if (
102
+ mime.startsWith('text/') ||
103
+ TEXT_MIMES.has(mime) ||
104
+ TEXT_EXTENSIONS.has(ext)
105
+ ) {
106
+ return 'text';
107
+ }
108
+ if (mime.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) return 'image';
109
+ if (mime.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) return 'audio';
110
+ if (mime.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) return 'video';
111
+ if (DOCUMENT_MIMES.has(mime) || DOCUMENT_EXTENSIONS.has(ext))
112
+ return 'document';
113
+ return 'unknown';
114
+ }
@@ -0,0 +1,51 @@
1
+ import type { MessageContent } from '@langchain/core/messages';
2
+
3
+ /**
4
+ * An attachment we send to the model natively (base64). Only `image` and `file`
5
+ * are wired — the proven LangChain→OpenRouter path (same as the Node runtime).
6
+ */
7
+ export interface NativeAttachment {
8
+ kind: 'image' | 'file';
9
+ mimeType: string;
10
+ base64: string;
11
+ filename: string;
12
+ }
13
+
14
+ /**
15
+ * Build the user message `content`. With no native attachments it stays a plain
16
+ * string (unchanged behaviour). Otherwise it becomes a LangChain "data content
17
+ * block" array: the text, then one block per attachment.
18
+ *
19
+ * The blocks MUST use `source_type: 'base64'` + snake_case `mime_type` — that is
20
+ * the only shape the `@langchain/openai` completions converter (the OpenRouter
21
+ * path) recognises; it emits `image_url: data:<mime>;base64,<data>` for images
22
+ * and `file_data` for files. camelCase or a missing `source_type` silently
23
+ * drops the attachment.
24
+ */
25
+ export function buildUserMessageContent(
26
+ text: string,
27
+ natives: NativeAttachment[],
28
+ ): MessageContent {
29
+ if (natives.length === 0) return text;
30
+
31
+ const content: MessageContent = [
32
+ { type: 'text', text },
33
+ ...natives.map((native) =>
34
+ native.kind === 'image'
35
+ ? {
36
+ type: 'image',
37
+ source_type: 'base64',
38
+ mime_type: native.mimeType,
39
+ data: native.base64,
40
+ }
41
+ : {
42
+ type: 'file',
43
+ source_type: 'base64',
44
+ mime_type: native.mimeType,
45
+ data: native.base64,
46
+ filename: native.filename,
47
+ },
48
+ ),
49
+ ];
50
+ return content;
51
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Fetching attachment bytes — Matrix media (through the gateway, which holds
3
+ * the authenticated client and the room keys) and plain `http(s)` URLs with
4
+ * the Node runtime's SSRF guard, manual redirect validation and streaming
5
+ * size cap.
6
+ */
7
+ import { detectMimeFromMagicBytes } from './magic';
8
+ import type { AttachmentInput } from './types';
9
+
10
+ export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
11
+ export const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50 MB across all attachments
12
+ export const ALLOWED_URI_SCHEMES = /^(mxc|https?):\/\//i;
13
+ const MAX_REDIRECT_COUNT = 5;
14
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
15
+
16
+ /** Blocks redirects to internal / cloud-metadata addresses (SSRF). */
17
+ const BLOCKED_HOST_PATTERNS = [
18
+ /^localhost$/i,
19
+ /^127\.\d+\.\d+\.\d+$/,
20
+ /^10\.\d+\.\d+\.\d+$/,
21
+ /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
22
+ /^192\.168\.\d+\.\d+$/,
23
+ /^169\.254\.\d+\.\d+$/, // AWS/cloud metadata
24
+ /^0\.0\.0\.0$/,
25
+ /^\[::1?\]$/, // IPv6 loopback (bracketed)
26
+ /^\[::ffff:[^\]]+\]$/i, // IPv4-mapped IPv6
27
+ /^\[f[cd][0-9a-f]{2}:.*\]$/i, // IPv6 unique-local (fc00::/7)
28
+ /^\[fe[89ab][0-9a-f]:.*\]$/i, // IPv6 link-local (fe80::/10)
29
+ /^metadata\.google\.internal$/i,
30
+ ];
31
+
32
+ /** Throws when the URL is not http(s) or points at a blocked internal host. */
33
+ export function validateUrlTarget(targetUrl: string): void {
34
+ let parsed: URL;
35
+ try {
36
+ parsed = new URL(targetUrl);
37
+ } catch {
38
+ throw new Error('Invalid URL');
39
+ }
40
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
41
+ throw new Error(`Blocked URL scheme: ${parsed.protocol}`);
42
+ }
43
+ for (const pattern of BLOCKED_HOST_PATTERNS) {
44
+ if (pattern.test(parsed.hostname)) {
45
+ throw new Error('URL points to a blocked internal address');
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Matrix media access the pipeline needs. Implemented over the gateway
52
+ * Durable Object in the user object; tests pass an in-memory fake.
53
+ */
54
+ export interface MatrixMediaSource {
55
+ /** Raw bytes behind an `mxc://` URI (authenticated media download). */
56
+ downloadMxc(mxc: string): Promise<Uint8Array>;
57
+ /**
58
+ * Bytes of the media event `eventId` in `roomId`, decrypted when the event
59
+ * carries an encrypted `file`. Null when the event does not exist.
60
+ */
61
+ downloadEvent(
62
+ roomId: string,
63
+ eventId: string,
64
+ ): Promise<{
65
+ bytes: Uint8Array;
66
+ mimetype?: string;
67
+ filename?: string;
68
+ } | null>;
69
+ }
70
+
71
+ export interface DownloadOptions {
72
+ fetchImpl?: typeof fetch;
73
+ signal?: AbortSignal;
74
+ timeoutMs?: number;
75
+ maxBytes?: number;
76
+ }
77
+
78
+ /** A controller that aborts on `signal` OR after `timeoutMs`, whichever first. */
79
+ function boundedSignal(
80
+ signal: AbortSignal | undefined,
81
+ timeoutMs: number,
82
+ ): { signal: AbortSignal; release: () => void } {
83
+ const controller = new AbortController();
84
+ const onAbort = (): void => controller.abort();
85
+ if (signal?.aborted) controller.abort();
86
+ signal?.addEventListener('abort', onAbort);
87
+ const timer = setTimeout(onAbort, timeoutMs);
88
+ return {
89
+ signal: controller.signal,
90
+ release: () => {
91
+ clearTimeout(timer);
92
+ signal?.removeEventListener('abort', onAbort);
93
+ },
94
+ };
95
+ }
96
+
97
+ function concat(chunks: Uint8Array[], total: number): Uint8Array {
98
+ const out = new Uint8Array(total);
99
+ let offset = 0;
100
+ for (const chunk of chunks) {
101
+ out.set(chunk, offset);
102
+ offset += chunk.length;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Download an http(s) URL: every redirect hop is SSRF-validated, the body is
109
+ * streamed with a running size check so an oversized file never lands in
110
+ * memory whole.
111
+ */
112
+ export async function downloadFromUrl(
113
+ url: string,
114
+ opts: DownloadOptions = {},
115
+ ): Promise<{ data: Uint8Array; contentType?: string; finalUrl?: string }> {
116
+ validateUrlTarget(url);
117
+ const fetchImpl = opts.fetchImpl ?? fetch;
118
+ const maxBytes = opts.maxBytes ?? MAX_FILE_SIZE;
119
+ const bound = boundedSignal(
120
+ opts.signal,
121
+ opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
122
+ );
123
+ try {
124
+ let currentUrl = url;
125
+ let response: Response | undefined;
126
+ for (let i = 0; i <= MAX_REDIRECT_COUNT; i += 1) {
127
+ response = await fetchImpl(currentUrl, {
128
+ signal: bound.signal,
129
+ redirect: 'manual',
130
+ });
131
+ const location = response.headers.get('location');
132
+ if (response.status >= 300 && response.status < 400 && location) {
133
+ currentUrl = new URL(location, currentUrl).toString();
134
+ validateUrlTarget(currentUrl);
135
+ continue;
136
+ }
137
+ break;
138
+ }
139
+ if (!response || (response.status >= 300 && response.status < 400)) {
140
+ throw new Error('Too many redirects');
141
+ }
142
+ if (!response.ok) {
143
+ throw new Error(`HTTP ${response.status} downloading ${url}`);
144
+ }
145
+ const contentType =
146
+ response.headers.get('content-type')?.split(';')[0]?.trim() ?? undefined;
147
+ const contentLength = response.headers.get('content-length');
148
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) {
149
+ throw new Error(
150
+ `File too large: server reports ${Math.round(parseInt(contentLength, 10) / 1024 / 1024)} MB (limit: ${Math.round(maxBytes / 1024 / 1024)} MB)`,
151
+ );
152
+ }
153
+ const reader = response.body?.getReader();
154
+ if (!reader) throw new Error('Response body is not readable');
155
+ const chunks: Uint8Array[] = [];
156
+ let total = 0;
157
+ for (;;) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ total += value.byteLength;
161
+ if (total > maxBytes) {
162
+ void reader.cancel();
163
+ throw new Error(
164
+ `File exceeds maximum size (${Math.round(maxBytes / 1024 / 1024)} MB) — download aborted`,
165
+ );
166
+ }
167
+ chunks.push(value);
168
+ }
169
+ return {
170
+ data: concat(chunks, total),
171
+ contentType,
172
+ ...(currentUrl !== url ? { finalUrl: currentUrl } : {}),
173
+ };
174
+ } finally {
175
+ bound.release();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Bytes for one attachment from whichever source it names, with the per-file
181
+ * cap enforced before and after the transfer. `mimetype` is the best-known
182
+ * type: content sniffing, then the HTTP `content-type`, then the client's
183
+ * claim.
184
+ */
185
+ export async function loadAttachmentBytes(
186
+ attachment: AttachmentInput,
187
+ roomId: string | undefined,
188
+ source: MatrixMediaSource,
189
+ opts: DownloadOptions = {},
190
+ ): Promise<{ bytes: Uint8Array; mimetype: string }> {
191
+ if (!attachment.eventId && !attachment.mxcUri) {
192
+ throw new Error('Either mxcUri or eventId must be provided');
193
+ }
194
+ if (attachment.mxcUri && !ALLOWED_URI_SCHEMES.test(attachment.mxcUri)) {
195
+ throw new Error('Invalid URI scheme');
196
+ }
197
+ const maxBytes = opts.maxBytes ?? MAX_FILE_SIZE;
198
+ if (attachment.size && attachment.size > maxBytes) {
199
+ throw new Error('File exceeds maximum size');
200
+ }
201
+
202
+ let bytes: Uint8Array;
203
+ let httpType: string | undefined;
204
+ if (attachment.eventId) {
205
+ if (!roomId)
206
+ throw new Error(
207
+ `Cannot fetch event ${attachment.eventId}: no Matrix room for this turn`,
208
+ );
209
+ const media = await source.downloadEvent(roomId, attachment.eventId);
210
+ if (!media) throw new Error(`Matrix event ${attachment.eventId} not found`);
211
+ bytes = media.bytes;
212
+ httpType = media.mimetype;
213
+ } else if (attachment.mxcUri!.startsWith('mxc://')) {
214
+ bytes = await source.downloadMxc(attachment.mxcUri!);
215
+ } else {
216
+ const result = await downloadFromUrl(attachment.mxcUri!, {
217
+ ...opts,
218
+ maxBytes,
219
+ });
220
+ bytes = result.data;
221
+ httpType = result.contentType;
222
+ }
223
+ if (bytes.length > maxBytes) {
224
+ throw new Error('File exceeds maximum size');
225
+ }
226
+ const sniffed = detectMimeFromMagicBytes(bytes);
227
+ return {
228
+ bytes,
229
+ mimetype:
230
+ sniffed ??
231
+ (httpType && httpType !== 'application/octet-stream'
232
+ ? httpType
233
+ : attachment.mimetype),
234
+ };
235
+ }