@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,209 @@
1
+ /**
2
+ * Inbound message pipeline — the transport-agnostic half of the gateway.
3
+ *
4
+ * Mirrors the Node runtime's `MatrixListenerBridge` (per-thread debounce,
5
+ * thread handling, attachments) with the room→user mapping the Workers
6
+ * topology needs: each turn is dispatched to the user's own Durable Object,
7
+ * so the sender must resolve to a user DID before anything else happens.
8
+ * Own-message, stale and duplicate gating happens upstream, in the bot SDK's
9
+ * pipeline; `gateway-do.ts` adapts its `BotMessage`s into `InboundMessage`s.
10
+ */
11
+
12
+ import { didToAliasPart } from '../do/contracts';
13
+
14
+ /** A media message (`m.image` / `m.file` / `m.audio` / `m.video`) as the turn sees it. */
15
+ export interface InboundAttachment {
16
+ eventId: string;
17
+ filename: string;
18
+ mimetype: string;
19
+ size?: number;
20
+ }
21
+
22
+ export interface InboundMessage {
23
+ eventId: string;
24
+ roomId: string;
25
+ /** Full Matrix user id of the sender (`@did-ixo-…:server`). */
26
+ sender: string;
27
+ /** `origin_server_ts`. */
28
+ ts: number;
29
+ /** Text body; empty for a media-only message. */
30
+ body: string;
31
+ /** Thread root when the message was sent inside a thread. */
32
+ threadRootId?: string;
33
+ /** Present for media messages — fetched and decrypted at turn time. */
34
+ attachment?: InboundAttachment;
35
+ }
36
+
37
+ export interface IngestTurn {
38
+ userDid: string;
39
+ matrixUserId: string;
40
+ roomId: string;
41
+ /** Present when the user wrote inside a thread; replies go there too. */
42
+ threadId?: string;
43
+ /** `matrix:<roomId>` (main timeline) or `thread:<threadRootId>`. */
44
+ sessionId: string;
45
+ /** Debounced messages joined with newlines, oldest first. */
46
+ message: string;
47
+ eventIds: string[];
48
+ /** Media shared in the same debounce window, oldest first. */
49
+ attachments?: InboundAttachment[];
50
+ }
51
+
52
+ export interface IngestDeps {
53
+ /** The DID that forms the oracle half of the user↔oracle room alias. */
54
+ oracleDid: string;
55
+ debounceMs?: number;
56
+ /** `m.room.canonical_alias` of the room, when known. */
57
+ canonicalAlias(roomId: string): string | null;
58
+ dispatch(turn: IngestTurn): Promise<void>;
59
+ onError?(err: unknown, context: string): void;
60
+ }
61
+
62
+ interface Pending {
63
+ timer: ReturnType<typeof setTimeout>;
64
+ messages: InboundMessage[];
65
+ userDid: string;
66
+ }
67
+
68
+ export const DEFAULT_DEBOUNCE_MS = 500;
69
+
70
+ /** `did-ixo-ixo1abc` → `did:ixo:ixo1abc`. Only the first two dashes are separators. */
71
+ export function aliasPartToDid(part: string): string | null {
72
+ const m = /^did-([a-z0-9]+)-(.+)$/.exec(part);
73
+ if (!m) return null;
74
+ return `did:${m[1]}:${m[2]}`;
75
+ }
76
+
77
+ /** Matrix user id → DID when the localpart is DID-shaped, else null. */
78
+ export function matrixUserIdToDid(userId: string): string | null {
79
+ if (!userId.startsWith('@')) return null;
80
+ const colon = userId.indexOf(':');
81
+ const localpart = colon === -1 ? userId.slice(1) : userId.slice(1, colon);
82
+ return aliasPartToDid(localpart);
83
+ }
84
+
85
+ /**
86
+ * `#<userDidDashed>_<oracleDidDashed>:server` → user DID, or null when the
87
+ * alias is not a user↔oracle room alias for THIS oracle.
88
+ */
89
+ export function userDidFromRoomAlias(
90
+ alias: string,
91
+ oracleDid: string,
92
+ ): string | null {
93
+ if (!alias.startsWith('#')) return null;
94
+ const colon = alias.indexOf(':');
95
+ const local = colon === -1 ? alias.slice(1) : alias.slice(1, colon);
96
+ const suffix = `_${didToAliasPart(oracleDid)}`;
97
+ if (!local.endsWith(suffix)) return null;
98
+ return aliasPartToDid(local.slice(0, -suffix.length));
99
+ }
100
+
101
+ /**
102
+ * Who is the user for this message? The canonical alias names the room's
103
+ * owner; the sender's localpart names the speaker. They agree in a 1:1
104
+ * oracle room. When the speaker is DID-shaped but differs from the room
105
+ * owner (a third member invited into the room), the SPEAKER wins — a turn
106
+ * must never run inside another user's object. A non-DID sender in an
107
+ * owner's room is attributed to the owner (legacy / non-DID accounts).
108
+ */
109
+ export function resolveUserDid(opts: {
110
+ alias: string | null;
111
+ sender: string;
112
+ oracleDid: string;
113
+ }): string | null {
114
+ const fromSender = matrixUserIdToDid(opts.sender);
115
+ const fromAlias = opts.alias
116
+ ? userDidFromRoomAlias(opts.alias, opts.oracleDid)
117
+ : null;
118
+ if (fromSender && fromAlias && fromSender !== fromAlias) return fromSender;
119
+ return fromAlias ?? fromSender;
120
+ }
121
+
122
+ export function sessionIdFor(roomId: string, threadRootId?: string): string {
123
+ return threadRootId ? `thread:${threadRootId}` : `matrix:${roomId}`;
124
+ }
125
+
126
+ export class IngestPipeline {
127
+ private readonly pending = new Map<string, Pending>();
128
+ private readonly debounceMs: number;
129
+
130
+ constructor(private readonly deps: IngestDeps) {
131
+ this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
132
+ }
133
+
134
+ /**
135
+ * Offer a decrypted message. Returns the reason it was dropped, or
136
+ * `'queued'` when it entered the debounce buffer.
137
+ */
138
+ offer(msg: InboundMessage): 'queued' | 'empty' | 'unmapped' {
139
+ if (!msg.body.trim() && !msg.attachment) return 'empty';
140
+ const userDid = resolveUserDid({
141
+ alias: this.deps.canonicalAlias(msg.roomId),
142
+ sender: msg.sender,
143
+ oracleDid: this.deps.oracleDid,
144
+ });
145
+ if (!userDid) return 'unmapped';
146
+
147
+ const key = `${msg.roomId}|${sessionIdFor(msg.roomId, msg.threadRootId)}`;
148
+ const existing = this.pending.get(key);
149
+ if (existing) {
150
+ clearTimeout(existing.timer);
151
+ existing.messages.push(msg);
152
+ existing.timer = setTimeout(() => void this.flush(key), this.debounceMs);
153
+ } else {
154
+ this.pending.set(key, {
155
+ messages: [msg],
156
+ userDid,
157
+ timer: setTimeout(() => void this.flush(key), this.debounceMs),
158
+ });
159
+ }
160
+ return 'queued';
161
+ }
162
+
163
+ /** Number of debounce buffers waiting to fire. */
164
+ get pendingCount(): number {
165
+ return this.pending.size;
166
+ }
167
+
168
+ /** Cancel every buffer (shutdown). */
169
+ clear(): void {
170
+ for (const p of this.pending.values()) clearTimeout(p.timer);
171
+ this.pending.clear();
172
+ }
173
+
174
+ private async flush(key: string): Promise<void> {
175
+ const entry = this.pending.get(key);
176
+ if (!entry) return;
177
+ this.pending.delete(key);
178
+ const first = entry.messages[0];
179
+ if (!first) return;
180
+ const threadId = first.threadRootId;
181
+ const texts = entry.messages.map((m) => m.body).filter((b) => b.trim());
182
+ const attachments = entry.messages.flatMap((m) =>
183
+ m.attachment ? [m.attachment] : [],
184
+ );
185
+ // A file-only send still needs a user message the agent can act on —
186
+ // the Node bridge's wording.
187
+ const message =
188
+ texts.length > 0
189
+ ? texts.join('\n')
190
+ : attachments.length === 1
191
+ ? `User shared a file: ${attachments[0]?.filename ?? 'file'}`
192
+ : `User shared ${attachments.length} file(s): ${attachments.map((a) => a.filename).join(', ')}`;
193
+ const turn: IngestTurn = {
194
+ userDid: entry.userDid,
195
+ matrixUserId: first.sender,
196
+ roomId: first.roomId,
197
+ sessionId: sessionIdFor(first.roomId, threadId),
198
+ message,
199
+ eventIds: entry.messages.map((m) => m.eventId),
200
+ ...(threadId ? { threadId } : {}),
201
+ ...(attachments.length > 0 ? { attachments } : {}),
202
+ };
203
+ try {
204
+ await this.deps.dispatch(turn);
205
+ } catch (err) {
206
+ this.deps.onError?.(err, `dispatch ${turn.sessionId}`);
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Room replay formatting — a port of `@ixo/matrix`'s `formatMsg`. The Node
3
+ * runtime replays every HTTP/SSE turn into the user's oracle room as a thread
4
+ * under the session's root event: the user message prefixed `**You:**`, the
5
+ * reply prefixed with the oracle's name, both rendered to HTML with marked.
6
+ */
7
+ import { marked } from 'marked';
8
+
9
+ export interface FormatReplayParams {
10
+ message: string;
11
+ /** `true` for the oracle's reply, `false` for the user's message. */
12
+ isOracle: boolean;
13
+ oracleName?: string;
14
+ /** Skip the speaker prefix (Node's `disablePrefix`). */
15
+ disablePrefix?: boolean;
16
+ }
17
+
18
+ export interface FormattedReplay {
19
+ body: string;
20
+ formattedBody: string;
21
+ }
22
+
23
+ export function formatReplay({
24
+ message,
25
+ isOracle,
26
+ oracleName = 'Oracle',
27
+ disablePrefix = false,
28
+ }: FormatReplayParams): FormattedReplay {
29
+ const body = disablePrefix
30
+ ? message
31
+ : `**${isOracle ? oracleName : 'You'}:**\n${message}`;
32
+ const formattedBody = marked.parse(body, { gfm: true, async: false });
33
+ return { body, formattedBody };
34
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Quote-reply → thread resolution — the port of the Node listener bridge's
3
+ * `getThreadRoot`. A client that quote-replies (`m.in_reply_to`) to a
4
+ * message that lives in a thread, without adding the `m.thread` relation
5
+ * itself, still means "continue that thread": walk the reply chain upward
6
+ * until an event that carries `m.thread` (→ its root) or a bare event.
7
+ *
8
+ * Unlike Node — where every bare room message opens its own thread-session —
9
+ * this runtime keeps the main timeline as ONE session (`matrix:<roomId>`), so
10
+ * a chain that ends on a bare event resolves to "no thread" (main timeline)
11
+ * rather than to that event. Results are memoised per event id with a
12
+ * bounded cache; cycles and missing events terminate the walk.
13
+ */
14
+
15
+ export interface MatrixRelatesTo {
16
+ rel_type?: string;
17
+ event_id?: string;
18
+ 'm.in_reply_to'?: { event_id?: string };
19
+ }
20
+
21
+ export function readRelatesTo(content: unknown): MatrixRelatesTo | undefined {
22
+ if (typeof content !== 'object' || content === null) return undefined;
23
+ const rel = (content as Record<string, unknown>)['m.relates_to'];
24
+ return typeof rel === 'object' && rel !== null ? rel : undefined;
25
+ }
26
+
27
+ export function threadRootOf(rel: MatrixRelatesTo | undefined): string | null {
28
+ return rel?.rel_type === 'm.thread' && typeof rel.event_id === 'string'
29
+ ? rel.event_id
30
+ : null;
31
+ }
32
+
33
+ export function inReplyToOf(rel: MatrixRelatesTo | undefined): string | null {
34
+ const id = rel?.['m.in_reply_to']?.event_id;
35
+ return typeof id === 'string' && id.length > 0 ? id : null;
36
+ }
37
+
38
+ /** Bounded event-id → thread-root ('' = main timeline) memo. */
39
+ export class ThreadRootCache {
40
+ private readonly map = new Map<string, string>();
41
+
42
+ constructor(private readonly capacity = 500) {}
43
+
44
+ get(eventId: string): string | undefined {
45
+ return this.map.get(eventId);
46
+ }
47
+
48
+ set(eventId: string, root: string): void {
49
+ if (this.map.size >= this.capacity) {
50
+ const oldest = this.map.keys().next().value;
51
+ if (oldest !== undefined) this.map.delete(oldest);
52
+ }
53
+ this.map.set(eventId, root);
54
+ }
55
+ }
56
+
57
+ export interface ResolveReplyChainInput {
58
+ eventId: string;
59
+ /** The event's own relation (from the clear or wire content). */
60
+ relatesTo: MatrixRelatesTo | undefined;
61
+ /** Fetches a room event's `m.relates_to` (null when the event is unavailable). */
62
+ fetchRelatesTo: (eventId: string) => Promise<MatrixRelatesTo | null>;
63
+ cache: ThreadRootCache;
64
+ /** Upper bound on the walk (Node has none beyond the visited set). */
65
+ maxHops?: number;
66
+ }
67
+
68
+ /**
69
+ * Thread root for the event, or `null` for the main timeline. Never throws:
70
+ * a fetch failure ends the walk at the last known event (→ main timeline).
71
+ */
72
+ export async function resolveReplyChainRoot(
73
+ input: ResolveReplyChainInput,
74
+ ): Promise<string | null> {
75
+ const { eventId, relatesTo, fetchRelatesTo, cache } = input;
76
+ const maxHops = input.maxHops ?? 25;
77
+ const remember = (ids: string[], root: string | null): string | null => {
78
+ for (const id of ids) cache.set(id, root ?? '');
79
+ return root;
80
+ };
81
+
82
+ const own = threadRootOf(relatesTo);
83
+ if (own) return remember([eventId], own);
84
+ const firstParent = inReplyToOf(relatesTo);
85
+ if (!firstParent) return remember([eventId], null);
86
+
87
+ const path = [eventId];
88
+ const visited = new Set<string>([eventId]);
89
+ let cursor: string | null = firstParent;
90
+ let hops = 0;
91
+ while (cursor && !visited.has(cursor) && hops < maxHops) {
92
+ hops += 1;
93
+ visited.add(cursor);
94
+ path.push(cursor);
95
+ const known = cache.get(cursor);
96
+ if (known !== undefined) return remember(path, known === '' ? null : known);
97
+ let parentRel: MatrixRelatesTo | null;
98
+ try {
99
+ parentRel = await fetchRelatesTo(cursor);
100
+ } catch {
101
+ parentRel = null;
102
+ }
103
+ if (parentRel === null) return remember(path, null);
104
+ const parentRoot = threadRootOf(parentRel);
105
+ if (parentRoot) return remember(path, parentRoot);
106
+ cursor = inReplyToOf(parentRel);
107
+ }
108
+ return remember(path, null);
109
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Wire codec for `ixo.room.state` events — byte-compatible with the Node
3
+ * runtime's `@ixo/matrix` `MatrixStateManager`, which is what the Portal,
4
+ * the Node oracle and every other IXO service read and write:
5
+ *
6
+ * content = { data: base64( zlib-deflate( superjson.stringify(value) ) ) }
7
+ *
8
+ * Both runtimes share rooms (a user migrated from Node keeps the same
9
+ * user↔oracle room), so anything this runtime persists as room state MUST
10
+ * round-trip through the Node codec and vice versa — the UCAN delegation
11
+ * (`ucan_delegation`) and the user preferences (`user_prefs`) in particular.
12
+ *
13
+ * Reads are lenient, mirroring the Node manager's fallbacks:
14
+ * 1. `{ data: <compressed superjson> }` — the current format;
15
+ * 2. `{ data: <uncompressed superjson string> }` — the Node legacy format;
16
+ * 3. a plain JSON object — what earlier builds of this runtime wrote
17
+ * (kept readable so nothing deposited before the codec is lost);
18
+ * 4. `{}` / missing → `null` (a cleared key).
19
+ *
20
+ * zlib "deflate" is the format Node's `deflateSync` emits and the Web
21
+ * `CompressionStream('deflate')` produces — the same 2-byte zlib header and
22
+ * Adler-32 trailer — so no Node polyfill is needed on workerd.
23
+ */
24
+ import { parse, stringify } from 'superjson';
25
+ import { base64ToBytes, bytesToBase64 } from '../plugins/base64';
26
+
27
+ /** The state event type every IXO room-state key lives under. */
28
+ export const ROOM_STATE_EVENT_TYPE = 'ixo.room.state';
29
+
30
+ /** The compressed room-state envelope. */
31
+ export interface RoomStateEnvelope {
32
+ data: string;
33
+ }
34
+
35
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
36
+ return new ReadableStream<Uint8Array>({
37
+ start(controller) {
38
+ controller.enqueue(bytes);
39
+ controller.close();
40
+ },
41
+ });
42
+ }
43
+
44
+ async function streamToBytes(
45
+ stream: ReadableStream<Uint8Array>,
46
+ ): Promise<Uint8Array> {
47
+ return new Uint8Array(await new Response(stream).arrayBuffer());
48
+ }
49
+
50
+ /** zlib-format deflate (what Node's `deflateSync` produces). */
51
+ export async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
52
+ return streamToBytes(
53
+ bytesToStream(bytes).pipeThrough(new CompressionStream('deflate')),
54
+ );
55
+ }
56
+
57
+ /** zlib-format inflate. Rejects on malformed input. */
58
+ export async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
59
+ return streamToBytes(
60
+ bytesToStream(bytes).pipeThrough(new DecompressionStream('deflate')),
61
+ );
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ /**
69
+ * superjson's `parse` yields `undefined` for a JSON document that is not a
70
+ * superjson envelope (no `json` key). Treat that — and `null` — as "nothing
71
+ * stored" so callers get one absent value.
72
+ */
73
+ function normalise(value: unknown): unknown {
74
+ return value === undefined ? null : value;
75
+ }
76
+
77
+ /**
78
+ * Encode a value the way the Node runtime does. The result is the state
79
+ * event's `content`.
80
+ */
81
+ export async function encodeRoomStateContent(
82
+ value: unknown,
83
+ ): Promise<RoomStateEnvelope> {
84
+ const serialized = new TextEncoder().encode(stringify(value));
85
+ return { data: bytesToBase64(await deflate(serialized)) };
86
+ }
87
+
88
+ /**
89
+ * Decode a state event's `content` (any of the formats listed above) into
90
+ * the stored value, or `null` when nothing (readable) is stored. Never
91
+ * throws on malformed input: an unreadable payload is reported as absent,
92
+ * matching the Node manager's "return undefined + warn" behaviour.
93
+ */
94
+ export async function decodeRoomStateContent(
95
+ content: unknown,
96
+ ): Promise<unknown> {
97
+ if (!isRecord(content)) return null;
98
+ const data = content['data'];
99
+ if (typeof data === 'string') {
100
+ if (data.length === 0) return null;
101
+ try {
102
+ const inflated = await inflate(base64ToBytes(data));
103
+ return normalise(parse(new TextDecoder().decode(inflated)));
104
+ } catch {
105
+ // Not compressed — fall through to the legacy uncompressed form.
106
+ }
107
+ try {
108
+ return normalise(parse(data));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ // Plain JSON written by earlier builds of this runtime (pre-codec).
114
+ return Object.keys(content).length === 0 ? null : content;
115
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which Matrix homeserver does a user live on?
3
+ *
4
+ * The user↔oracle room alias is `#<user>_<oracle>:<SERVER>` where SERVER is the
5
+ * USER's homeserver, not the oracle's — an oracle on `mx.example` serving a
6
+ * user registered on `devmx.ixo.earth` must look the room up as
7
+ * `#…:devmx.ixo.earth`. The Node runtime reads it per user from the DID
8
+ * document's `MatrixHomeServer` service via Blocksync
9
+ * (`getMatrixHomeServerCroppedForDid`); this is the Workers port, with the
10
+ * same normalisation of the user-supplied `serviceEndpoint`.
11
+ */
12
+
13
+ export const MATRIX_SERVICE_TYPE = 'MatrixHomeServer';
14
+
15
+ export interface DidService {
16
+ id?: string;
17
+ type?: string;
18
+ serviceEndpoint?: string;
19
+ }
20
+
21
+ /**
22
+ * On-chain `serviceEndpoint` values are registration-supplied and have been
23
+ * seen with trailing slashes and stray whitespace/CRLF. Strip whitespace, add
24
+ * `https://` to bare domains, drop trailing slashes. "" for empty input.
25
+ */
26
+ export function normalizeMatrixHomeServerUrl(
27
+ url: string | null | undefined,
28
+ ): string {
29
+ if (!url) return '';
30
+ const trimmed = url.replace(/\s+/g, '');
31
+ if (trimmed === '') return '';
32
+ const withProtocol = /^https?:\/\//.test(trimmed)
33
+ ? trimmed
34
+ : `https://${trimmed}`;
35
+ return withProtocol.replace(/\/+$/, '');
36
+ }
37
+
38
+ /** `https://devmx.ixo.earth/` → `devmx.ixo.earth` (the Matrix server name). */
39
+ export function extractUrlDomain(url: string): string {
40
+ try {
41
+ return new URL(url).hostname;
42
+ } catch {
43
+ const cleaned = url.replace(/^https?:\/\//, '');
44
+ return cleaned.split('/')[0] ?? cleaned;
45
+ }
46
+ }
47
+
48
+ /** The server name from a DID document's services, or null when it has none. */
49
+ export function matrixServerNameFromServices(
50
+ services: readonly DidService[] | null | undefined,
51
+ ): string | null {
52
+ const svc = (services ?? []).find((s) => s.type === MATRIX_SERVICE_TYPE);
53
+ const normalized = normalizeMatrixHomeServerUrl(svc?.serviceEndpoint);
54
+ if (!normalized) return null;
55
+ const domain = extractUrlDomain(normalized);
56
+ return domain ? domain.toLowerCase() : null;
57
+ }
58
+
59
+ const IID_SERVICES_QUERY = `
60
+ query UserMatrixHomeServer($id: String!) {
61
+ iids(filter: { id: { equalTo: $id } }) {
62
+ nodes { id service }
63
+ }
64
+ }
65
+ `;
66
+
67
+ /**
68
+ * Resolve a user's Matrix server name from Blocksync. Returns null when the
69
+ * DID is unknown or carries no MatrixHomeServer service; throws on transport
70
+ * failure so callers can decide whether to fall back.
71
+ */
72
+ export async function fetchUserMatrixServerName(
73
+ blocksyncGraphqlUrl: string,
74
+ userDid: string,
75
+ fetchImpl: typeof fetch = fetch,
76
+ ): Promise<string | null> {
77
+ const res = await fetchImpl(blocksyncGraphqlUrl, {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify({
81
+ query: IID_SERVICES_QUERY,
82
+ variables: { id: userDid },
83
+ }),
84
+ });
85
+ if (!res.ok) {
86
+ throw new Error(
87
+ `Blocksync ${res.status} resolving MatrixHomeServer for ${userDid}`,
88
+ );
89
+ }
90
+ const raw: unknown = await res.json();
91
+ const body = raw as {
92
+ data?: { iids?: { nodes?: Array<{ id: string; service?: unknown }> } };
93
+ errors?: Array<{ message?: string }>;
94
+ };
95
+ if (body.errors?.length) {
96
+ throw new Error(
97
+ `Blocksync error resolving MatrixHomeServer for ${userDid}: ${body.errors[0]?.message ?? 'unknown'}`,
98
+ );
99
+ }
100
+ const node = body.data?.iids?.nodes?.[0];
101
+ if (!node) return null;
102
+ const services: DidService[] = Array.isArray(node.service)
103
+ ? node.service
104
+ : [];
105
+ return matrixServerNameFromServices(services);
106
+ }