@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- 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
|
+
}
|