@ixo/oracle-runtime-workers 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -91,6 +91,11 @@ export const DEFAULT_REQUIRE_POWER_LEVEL = 0;
91
91
  export const COMPACT_BUFFER_THRESHOLD = 20;
92
92
  export const COMPACT_JIT_MIN = 5;
93
93
  export const COMPACT_JIT_TIMEOUT_MS = 3000;
94
+ /** The oldest messages one summary covers at most; a longer buffer is compacted over several runs. */
95
+ export const COMPACT_BATCH_MAX = 50;
96
+ /** After a failed summary a room's automatic compaction waits this long, doubling per failure up to the max. */
97
+ export const COMPACT_RETRY_MIN_MS = 60_000;
98
+ export const COMPACT_RETRY_MAX_MS = 30 * 60_000;
94
99
  export const RECALL_DEFAULT_CHUNKS = 10;
95
100
  export const RECALL_MAX_CHUNKS = 30;
96
101
  export const PINNED_FACT_MAX_CHARS = 500;
@@ -508,13 +513,13 @@ export class GroupChatStore {
508
513
  }
509
514
 
510
515
  deletePinnedFact(roomId: string, factId: string): boolean {
511
- const before = this.countFacts(roomId);
512
- this.sql.exec(
513
- `DELETE FROM group_pinned_facts WHERE room_id = ? AND id = ?`,
514
- roomId,
515
- factId,
516
+ return (
517
+ this.sql.exec(
518
+ `DELETE FROM group_pinned_facts WHERE room_id = ? AND id = ?`,
519
+ roomId,
520
+ factId,
521
+ ).rowsWritten > 0
516
522
  );
517
- return this.countFacts(roomId) < before;
518
523
  }
519
524
 
520
525
  countFacts(roomId?: string): number {
@@ -568,21 +573,23 @@ export class GroupChatStore {
568
573
 
569
574
  /** Buffer a message for compaction; false when the event was already buffered (a replay). */
570
575
  bufferAppend(roomId: string, msg: ObservedMessage): boolean {
571
- const before = this.bufferCount(roomId);
572
- this.sql.exec(
573
- `INSERT OR IGNORE INTO group_message_buffer
576
+ // An ignored duplicate writes nothing; an insert writes its row (and
577
+ // its index entries).
578
+ return (
579
+ this.sql.exec(
580
+ `INSERT OR IGNORE INTO group_message_buffer
574
581
  (event_id, room_id, thread_id, sender_did, sender_user_id, display_name, body, ts)
575
582
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
576
- msg.eventId,
577
- roomId,
578
- msg.threadId,
579
- msg.senderDid,
580
- msg.senderMatrixUserId,
581
- msg.senderDisplayName,
582
- msg.body,
583
- msg.timestamp,
583
+ msg.eventId,
584
+ roomId,
585
+ msg.threadId,
586
+ msg.senderDid,
587
+ msg.senderMatrixUserId,
588
+ msg.senderDisplayName,
589
+ msg.body,
590
+ msg.timestamp,
591
+ ).rowsWritten > 0
584
592
  );
585
- return this.bufferCount(roomId) > before;
586
593
  }
587
594
 
588
595
  bufferCount(roomId?: string): number {
@@ -601,12 +608,13 @@ export class GroupChatStore {
601
608
  return Number(rows[0]?.n ?? 0);
602
609
  }
603
610
 
604
- /** The room's buffered messages, oldest first. */
605
- bufferRows(roomId: string): ObservedMessage[] {
611
+ /** The room's buffered messages, oldest first (at most `limit`). */
612
+ bufferRows(roomId: string, limit = COMPACT_BATCH_MAX): ObservedMessage[] {
606
613
  return this.sql
607
614
  .exec<BufferRow>(
608
- `SELECT * FROM group_message_buffer WHERE room_id = ? ORDER BY ts ASC, rowid ASC`,
615
+ `SELECT * FROM group_message_buffer WHERE room_id = ? ORDER BY ts ASC, rowid ASC LIMIT ?`,
609
616
  roomId,
617
+ limit,
610
618
  )
611
619
  .toArray()
612
620
  .map(rowToObserved);
@@ -760,6 +768,11 @@ export class GroupChatService {
760
768
  >();
761
769
  private readonly activeThreads = new Map<string, number>();
762
770
  private readonly compacting = new Map<string, Promise<void>>();
771
+ /** Rooms whose last summary failed: no automatic compaction before `until`. */
772
+ private readonly compactBackoff = new Map<
773
+ string,
774
+ { until: number; delayMs: number }
775
+ >();
763
776
  private readonly now: () => number;
764
777
 
765
778
  constructor(
@@ -1013,7 +1026,10 @@ export class GroupChatService {
1013
1026
 
1014
1027
  observe(roomId: string, message: ObservedMessage): void {
1015
1028
  if (!this.store.bufferAppend(roomId, message)) return;
1016
- if (this.store.bufferCount(roomId) >= COMPACT_BUFFER_THRESHOLD)
1029
+ if (
1030
+ this.compactDue(roomId) &&
1031
+ this.store.bufferCount(roomId) >= COMPACT_BUFFER_THRESHOLD
1032
+ )
1017
1033
  this.deps.keepAlive(
1018
1034
  this.compact(roomId).catch((err: unknown) =>
1019
1035
  this.deps.log(
@@ -1027,6 +1043,7 @@ export class GroupChatService {
1027
1043
 
1028
1044
  /** Compact a buffer of ≥ 5 before the bot answers, bounded so the reply is not held up. */
1029
1045
  async compactJustInTime(roomId: string): Promise<void> {
1046
+ if (!this.compactDue(roomId)) return;
1030
1047
  if (this.store.bufferCount(roomId) < COMPACT_JIT_MIN) return;
1031
1048
  const work = this.compact(roomId).catch((err: unknown) =>
1032
1049
  this.deps.log(
@@ -1053,19 +1070,45 @@ export class GroupChatService {
1053
1070
  return work;
1054
1071
  }
1055
1072
 
1073
+ /**
1074
+ * Whether automatic compaction (threshold, just in time) may run for the
1075
+ * room: not while it backs off after a failed summary, so a model outage
1076
+ * costs one summary call per back-off window, not one per message.
1077
+ */
1078
+ private compactDue(roomId: string): boolean {
1079
+ const backoff = this.compactBackoff.get(roomId);
1080
+ return !backoff || backoff.until <= this.now();
1081
+ }
1082
+
1083
+ private noteCompactFailure(roomId: string): void {
1084
+ const previous = this.compactBackoff.get(roomId)?.delayMs;
1085
+ const delayMs = previous
1086
+ ? Math.min(previous * 2, COMPACT_RETRY_MAX_MS)
1087
+ : COMPACT_RETRY_MIN_MS;
1088
+ this.compactBackoff.set(roomId, { until: this.now() + delayMs, delayMs });
1089
+ }
1090
+
1056
1091
  private async compactInner(roomId: string): Promise<void> {
1057
1092
  const drained = this.store.bufferRows(roomId);
1058
1093
  const first = drained[0];
1059
1094
  const last = drained[drained.length - 1];
1060
1095
  if (!first || !last) return;
1061
- const summary = await this.deps.summarize(drained);
1096
+ let summary: string | null;
1097
+ try {
1098
+ summary = await this.deps.summarize(drained);
1099
+ } catch (err) {
1100
+ this.noteCompactFailure(roomId);
1101
+ throw err;
1102
+ }
1062
1103
  if (!summary) {
1104
+ this.noteCompactFailure(roomId);
1063
1105
  this.deps.log(
1064
1106
  'warn',
1065
1107
  `[group-chat] summary unavailable; ${drained.length} messages stay buffered for ${roomId}`,
1066
1108
  );
1067
1109
  return;
1068
1110
  }
1111
+ this.compactBackoff.delete(roomId);
1069
1112
  const chunk: ChannelMemoryChunk = {
1070
1113
  id: crypto.randomUUID(),
1071
1114
  roomId,
@@ -16,7 +16,7 @@
16
16
  * Cost shape (SQLite-backed Durable Object storage bills rows written and
17
17
  * read): one row written on receipt, one delete when the turn ends.
18
18
  */
19
- import type { InboundAttachment, InboundMessage } from './ingest';
19
+ import type { InboundAttachment, InboundMessage, OfferOutcome } from './ingest';
20
20
  import { matrixTxnId } from './txn-id';
21
21
 
22
22
  export interface InboxRow extends InboundMessage {
@@ -103,6 +103,21 @@ export function deleteInboxRows(sql: SqlStorage, eventIds: string[]): void {
103
103
  sql.exec(`DELETE FROM turn_inbox WHERE event_id = ?`, id);
104
104
  }
105
105
 
106
+ /**
107
+ * The inbox row of a message after its offer: a queued message keeps it until
108
+ * its turn ends, an unverified one (the sender's homeserver could not be
109
+ * looked up) keeps it for the next replay, and a dropped one leaves at once.
110
+ */
111
+ export function settleOfferedRow(
112
+ sql: SqlStorage,
113
+ eventId: string,
114
+ outcome: OfferOutcome,
115
+ ): 'kept' | 'deleted' {
116
+ if (outcome === 'queued' || outcome === 'unverified') return 'kept';
117
+ deleteInboxRows(sql, [eventId]);
118
+ return 'deleted';
119
+ }
120
+
106
121
  export function bumpInboxAttempts(sql: SqlStorage, eventIds: string[]): void {
107
122
  for (const id of eventIds)
108
123
  sql.exec(
@@ -75,6 +75,15 @@ export interface IngestTurn {
75
75
  export interface IngestDeps {
76
76
  /** The DID that forms the oracle half of the user↔oracle room alias. */
77
77
  oracleDid: string;
78
+ /**
79
+ * The Matrix server name the user's DID document registers, lowercased;
80
+ * null when it is not known. Synchronous like `canonicalAlias`: the gateway
81
+ * resolves it before the offer. A message whose attributed identity was
82
+ * minted on any other server is dropped (`'foreign'`); with no known server
83
+ * there is no verdict (`'unverified'`) and the gateway keeps the message
84
+ * for a later attempt.
85
+ */
86
+ userServerName(userDid: string): string | null;
78
87
  /**
79
88
  * The bot's Matrix user id. Its first occurrence in a message is rewritten
80
89
  * to `(USER MENTIONED YOU @AI_AGENT)` before the agent sees it, as the Node
@@ -129,25 +138,65 @@ export function userDidFromRoomAlias(
129
138
  return aliasPartToDid(local.slice(0, -suffix.length));
130
139
  }
131
140
 
141
+ /** The server name of a Matrix user id or room alias (`@a:server` → `server`), lowercased; null without one. */
142
+ export function serverNameOf(matrixId: string): string | null {
143
+ const colon = matrixId.indexOf(':');
144
+ if (colon === -1) return null;
145
+ const server = matrixId.slice(colon + 1).toLowerCase();
146
+ return server || null;
147
+ }
148
+
149
+ /** Whose turn a message is, and the server that identity was minted on. */
150
+ export interface UserAttribution {
151
+ userDid: string;
152
+ /**
153
+ * The server the identity came from: the sender's for a DID-shaped sender,
154
+ * the alias's for a non-DID sender attributed to the room's owner. It must
155
+ * be the user's registered homeserver — anyone can register
156
+ * `@did-ixo-<someone>` on a server of their own, and a Matrix localpart
157
+ * says nothing about who controls the DID.
158
+ */
159
+ server: string | null;
160
+ }
161
+
132
162
  /**
133
163
  * Who is the user for this message? The canonical alias names the room's
134
164
  * owner; the sender's localpart names the speaker. They agree in a 1:1
135
165
  * oracle room. When the speaker is DID-shaped but differs from the room
136
166
  * owner (a third member invited into the room), the SPEAKER wins — a turn
137
167
  * must never run inside another user's object. A non-DID sender in an
138
- * owner's room is attributed to the owner (legacy / non-DID accounts).
168
+ * owner's room is attributed to the owner (legacy / non-DID accounts), but
169
+ * only when the sender's account lives on the alias's own server: an
170
+ * account on any other server is someone else, whatever the room says.
171
+ *
172
+ * `alias` must be one the caller verified (the alias resolves to this very
173
+ * room on its homeserver — `room-alias.ts`): a room's canonical-alias state
174
+ * is written by its members and proves nothing on its own.
139
175
  */
176
+ export function attributeUser(opts: {
177
+ alias: string | null;
178
+ sender: string;
179
+ oracleDid: string;
180
+ }): UserAttribution | null {
181
+ const fromSender = matrixUserIdToDid(opts.sender);
182
+ if (fromSender) {
183
+ return { userDid: fromSender, server: serverNameOf(opts.sender) };
184
+ }
185
+ if (!opts.alias) return null;
186
+ const fromAlias = userDidFromRoomAlias(opts.alias, opts.oracleDid);
187
+ const aliasServer = serverNameOf(opts.alias);
188
+ if (!fromAlias || !aliasServer) return null;
189
+ if (serverNameOf(opts.sender) !== aliasServer) return null;
190
+ return { userDid: fromAlias, server: aliasServer };
191
+ }
192
+
193
+ /** The DID `attributeUser` names, without the server it came from. */
140
194
  export function resolveUserDid(opts: {
141
195
  alias: string | null;
142
196
  sender: string;
143
197
  oracleDid: string;
144
198
  }): string | null {
145
- const fromSender = matrixUserIdToDid(opts.sender);
146
- const fromAlias = opts.alias
147
- ? userDidFromRoomAlias(opts.alias, opts.oracleDid)
148
- : null;
149
- if (fromSender && fromAlias && fromSender !== fromAlias) return fromSender;
150
- return fromAlias ?? fromSender;
199
+ return attributeUser(opts)?.userDid ?? null;
151
200
  }
152
201
 
153
202
  /** The thread a message belongs to: its resolved root, else the message itself. */
@@ -157,6 +206,13 @@ export function threadRootIdOf(
157
206
  return msg.threadRootId ?? msg.eventId;
158
207
  }
159
208
 
209
+ export type OfferOutcome =
210
+ | 'queued'
211
+ | 'unverified'
212
+ | 'empty'
213
+ | 'unmapped'
214
+ | 'foreign';
215
+
160
216
  export class IngestPipeline {
161
217
  private readonly pending = new Map<string, Pending>();
162
218
  private readonly debounceMs: number;
@@ -166,19 +222,28 @@ export class IngestPipeline {
166
222
  }
167
223
 
168
224
  /**
169
- * Offer a decrypted message. Returns the reason it was dropped, or
170
- * `'queued'` when it entered the debounce buffer.
225
+ * Offer a decrypted message. Returns the reason it was dropped, `'queued'`
226
+ * when it entered the debounce buffer, or `'unverified'` when the sender's
227
+ * registered homeserver is not known — neither queued nor dropped.
228
+ * `alias`: the room's verified alias as the caller just checked it (null
229
+ * for none); omitted, `canonicalAlias` is asked.
171
230
  */
172
- offer(msg: InboundMessage): 'queued' | 'empty' | 'unmapped' {
231
+ offer(msg: InboundMessage, alias?: string | null): OfferOutcome {
173
232
  if (!msg.body.trim() && !msg.attachment) return 'empty';
174
- const userDid = resolveUserDid({
175
- alias: this.deps.canonicalAlias(msg.roomId),
233
+ const attribution = attributeUser({
234
+ alias: alias === undefined ? this.deps.canonicalAlias(msg.roomId) : alias,
176
235
  sender: msg.sender,
177
236
  oracleDid: this.deps.oracleDid,
178
237
  });
179
- if (!userDid) return 'unmapped';
238
+ if (!attribution) return 'unmapped';
239
+ const { userDid } = attribution;
240
+ const registered = this.deps.userServerName(userDid);
241
+ if (!registered) return 'unverified';
242
+ if (attribution.server !== registered) return 'foreign';
180
243
 
181
- const key = `${msg.roomId}|${threadRootIdOf(msg)}`;
244
+ // Per speaker as well as per thread: two members typing in one thread of
245
+ // a group room are two turns, each in its own user's object.
246
+ const key = `${msg.roomId}|${threadRootIdOf(msg)}|${userDid}`;
182
247
  const existing = this.pending.get(key);
183
248
  if (existing) {
184
249
  clearTimeout(existing.timer);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Deadlines for the gateway's media downloads. A homeserver that stops
3
+ * sending mid-body would otherwise keep the reading side — a user object
4
+ * assembling an attachment for a turn — waiting for as long as the
5
+ * connection stays open.
6
+ *
7
+ * The bound covers the whole transfer: opening the download and every byte
8
+ * of the body. When it passes (or the caller's own signal aborts), the
9
+ * source is cancelled and the stream handed out errors with the reason.
10
+ */
11
+
12
+ export interface BoundedSignal {
13
+ signal: AbortSignal;
14
+ /** Stop the timer (the transfer finished or failed on its own). */
15
+ clear(): void;
16
+ }
17
+
18
+ /** Error a download fails with when its deadline passes. */
19
+ export class MediaDownloadTimeoutError extends Error {
20
+ constructor(timeoutMs: number) {
21
+ super(`media download timed out after ${timeoutMs} ms`);
22
+ this.name = 'MediaDownloadTimeoutError';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * A signal that aborts after `timeoutMs`, or as soon as `signal` does. The
28
+ * timer is a plain timeout so it can be cleared the moment the transfer ends
29
+ * (a pending timer keeps a Durable Object resident).
30
+ */
31
+ export function boundedSignal(
32
+ timeoutMs: number,
33
+ signal?: AbortSignal,
34
+ ): BoundedSignal {
35
+ const controller = new AbortController();
36
+ const onCallerAbort = (): void => controller.abort(signal?.reason);
37
+ const timer = setTimeout(
38
+ () => controller.abort(new MediaDownloadTimeoutError(timeoutMs)),
39
+ timeoutMs,
40
+ );
41
+ const clear = (): void => {
42
+ clearTimeout(timer);
43
+ signal?.removeEventListener('abort', onCallerAbort);
44
+ };
45
+ if (signal?.aborted) {
46
+ clear();
47
+ controller.abort(signal.reason);
48
+ } else signal?.addEventListener('abort', onCallerAbort, { once: true });
49
+ controller.signal.addEventListener('abort', clear, { once: true });
50
+ return { signal: controller.signal, clear };
51
+ }
52
+
53
+ /** The signal's reason as an Error (a `DOMException` reason already is one). */
54
+ function abortReason(signal: AbortSignal): Error {
55
+ const reason: unknown = signal.reason;
56
+ return reason instanceof Error ? reason : new Error(String(reason));
57
+ }
58
+
59
+ /**
60
+ * Settle with `work`, or reject with the signal's reason when it aborts
61
+ * first. A result that arrives after the abort is handed to `discard` so a
62
+ * stream opened too late is still released.
63
+ */
64
+ export function raceSignal<T>(
65
+ work: Promise<T>,
66
+ signal: AbortSignal,
67
+ discard: (late: T) => void,
68
+ ): Promise<T> {
69
+ if (signal.aborted) {
70
+ void work.then(discard, () => undefined);
71
+ return Promise.reject(abortReason(signal));
72
+ }
73
+ return new Promise<T>((resolve, reject) => {
74
+ const onAbort = (): void => {
75
+ void work.then(discard, () => undefined);
76
+ reject(abortReason(signal));
77
+ };
78
+ signal.addEventListener('abort', onAbort, { once: true });
79
+ work.then(
80
+ (value) => {
81
+ signal.removeEventListener('abort', onAbort);
82
+ if (signal.aborted) discard(value);
83
+ else resolve(value);
84
+ },
85
+ (err: unknown) => {
86
+ signal.removeEventListener('abort', onAbort);
87
+ reject(err instanceof Error ? err : new Error(String(err)));
88
+ },
89
+ );
90
+ });
91
+ }
92
+
93
+ /**
94
+ * `source` behind the deadline: the returned stream carries the same bytes,
95
+ * and errors with the signal's reason (the source cancelled) when the bound
96
+ * passes before the last byte. The timer is cleared once the transfer ends
97
+ * either way, including when the reader cancels.
98
+ */
99
+ export function streamWithDeadline(
100
+ source: ReadableStream<Uint8Array>,
101
+ bound: BoundedSignal,
102
+ ): ReadableStream<Uint8Array> {
103
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
104
+ const writer = writable.getWriter();
105
+ const reader = source.getReader();
106
+ const stop = (reason: unknown): void => {
107
+ void writer.abort(reason).catch(() => undefined);
108
+ void reader.cancel(reason).catch(() => undefined);
109
+ };
110
+ const onAbort = (): void => stop(bound.signal.reason);
111
+ if (bound.signal.aborted) onAbort();
112
+ else bound.signal.addEventListener('abort', onAbort, { once: true });
113
+ void (async () => {
114
+ try {
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done || bound.signal.aborted) break;
118
+ await writer.write(value);
119
+ }
120
+ if (!bound.signal.aborted) await writer.close();
121
+ } catch (err) {
122
+ // The reader went away (cancelled) or the source failed: end both.
123
+ stop(err);
124
+ } finally {
125
+ bound.signal.removeEventListener('abort', onAbort);
126
+ bound.clear();
127
+ }
128
+ })();
129
+ return readable;
130
+ }
@@ -2,9 +2,11 @@
2
2
  * Room replay formatting — a port of `@ixo/matrix`'s `formatMsg`. The Node
3
3
  * runtime replays every HTTP/SSE turn into the user's oracle room as a thread
4
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.
5
+ * reply prefixed with the oracle's name, both rendered to HTML with marked —
6
+ * with raw HTML escaped, as for every room reply (`reply-parts.ts`): the
7
+ * text may come from a model or a user and must not become room markup.
6
8
  */
7
- import { marked } from 'marked';
9
+ import { renderRoomMarkdown } from './reply-parts';
8
10
 
9
11
  export interface FormatReplayParams {
10
12
  message: string;
@@ -29,6 +31,6 @@ export function formatReplay({
29
31
  const body = disablePrefix
30
32
  ? message
31
33
  : `**${isOracle ? oracleName : 'You'}:**\n${message}`;
32
- const formattedBody = marked.parse(body, { gfm: true, async: false });
34
+ const formattedBody = renderRoomMarkdown(body);
33
35
  return { body, formattedBody };
34
36
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What the gateway posts in a Matrix room for one finished turn. Without a
3
+ * Reply Plan (chat style off, the default) it is the reply text as one
4
+ * plain `m.text`, exactly as before chat delivery existed. With a plan it is
5
+ * one `m.text` per part, in the thread, rendered to HTML with raw HTML from
6
+ * the model escaped. Each part has its own transaction id, so a replayed
7
+ * turn is deduplicated part by part.
8
+ */
9
+ import { Marked } from 'marked';
10
+ import type { TurnResult } from '../do/contracts';
11
+ import { parseReplyPlan } from '../delivery/schema';
12
+ import type { ReplyPart } from '../delivery/types';
13
+ import { matrixTxnId } from './txn-id';
14
+
15
+ function escapeHtml(value: string): string {
16
+ return value
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;');
21
+ }
22
+
23
+ /** URL schemes a link or image in a room message may use. */
24
+ const ALLOWED_URL = /^(?:https?|mailto|mxc):/i;
25
+
26
+ /**
27
+ * Markdown → `formatted_body` for anything the oracle posts to a room. Raw
28
+ * HTML in the text (block or inline) is shown as text, never passed through
29
+ * as markup, and a link or image whose URL is not http(s), mailto or mxc
30
+ * (`javascript:`, `data:`, a relative path) is shown as its text. Returning
31
+ * `false` hands an allowed one to marked's own renderer.
32
+ */
33
+ const safeMarkdown = new Marked({
34
+ gfm: true,
35
+ async: false,
36
+ renderer: {
37
+ html: ({ text }) => escapeHtml(text),
38
+ link({ href, tokens }) {
39
+ return ALLOWED_URL.test(href.trim())
40
+ ? false
41
+ : this.parser.parseInline(tokens);
42
+ },
43
+ image({ href, text }) {
44
+ return ALLOWED_URL.test(href.trim()) ? false : escapeHtml(text);
45
+ },
46
+ },
47
+ });
48
+
49
+ /** Markdown rendered to room HTML with raw HTML escaped. */
50
+ export function renderRoomMarkdown(text: string): string {
51
+ return safeMarkdown.parse(text, { async: false });
52
+ }
53
+
54
+ export function replyPartContent(part: ReplyPart): {
55
+ body: string;
56
+ formattedBody: string;
57
+ } {
58
+ if (part.kind === 'text')
59
+ return {
60
+ body: part.text,
61
+ formattedBody: renderRoomMarkdown(part.text),
62
+ };
63
+ const { title, url } = part.artifact;
64
+ return {
65
+ body: `${title}\n${url}`,
66
+ formattedBody: `<p><strong>${escapeHtml(title)}</strong><br><a href="${escapeHtml(url)}">Open document</a></p>`,
67
+ };
68
+ }
69
+
70
+ export function replyPartTxnId(eventId: string, partId: string): string {
71
+ return matrixTxnId('reply', eventId, partId);
72
+ }
73
+
74
+ /** One room message of a turn's reply; `partId` is set for a plan part. */
75
+ export interface RoomReplyMessage {
76
+ body: string;
77
+ formattedBody?: string;
78
+ partId?: string;
79
+ }
80
+
81
+ /** The messages a finished room turn posts, in order; none for an empty reply. */
82
+ export function roomReplyMessages(
83
+ result: Pick<TurnResult, 'text' | 'plan'>,
84
+ ): RoomReplyMessage[] {
85
+ const plan = parseReplyPlan(result.plan);
86
+ if (plan && plan.parts.length > 0)
87
+ return plan.parts.map((part) => ({
88
+ ...replyPartContent(part),
89
+ partId: part.partId,
90
+ }));
91
+ return result.text.trim() ? [{ body: result.text }] : [];
92
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The user↔oracle alias a room really carries.
3
+ *
4
+ * A room's `m.room.canonical_alias` is ordinary room state: any member with
5
+ * the power to set it can write any alias there, and a homeserver does not
6
+ * check aliases that live on another server. A room created on a server of
7
+ * an attacker's own can therefore claim `#<victim>_<oracle>:<victim's
8
+ * server>`. What cannot be forged is the alias directory of the alias's own
9
+ * server, so an alias counts only when that server resolves it to the very
10
+ * same room. Anything else — no alias, an alias that resolves elsewhere or
11
+ * nowhere — is "no alias".
12
+ *
13
+ * Verdicts are memoised per room for a TTL and dropped when the room's alias
14
+ * state changes. A lookup that fails (homeserver error, rate limit) is not a
15
+ * verdict: it throws, nothing is memoised, and the caller retries later.
16
+ */
17
+
18
+ export interface VerifiedRoomAliasDeps {
19
+ /**
20
+ * The room's canonical alias when it is one that matters (a user↔oracle
21
+ * alias of this oracle), null otherwise. Throws when the state cannot be read.
22
+ */
23
+ readCanonicalAlias(roomId: string): Promise<string | null>;
24
+ /** The room an alias points at in its server's directory; null when it points nowhere. Throws on any other failure. */
25
+ resolveAlias(alias: string): Promise<string | null>;
26
+ /** How long a verdict is served without asking again. */
27
+ ttlMs: number;
28
+ /** Upper bound on the rooms remembered at once (oldest dropped first). */
29
+ maxRooms?: number;
30
+ now?: () => number;
31
+ }
32
+
33
+ interface Verdict {
34
+ alias: string | null;
35
+ at: number;
36
+ }
37
+
38
+ const DEFAULT_MAX_ROOMS = 5_000;
39
+
40
+ export class VerifiedRoomAliases {
41
+ private readonly verdicts = new Map<string, Verdict>();
42
+ /** Bumped by `invalidate`, so a lookup that started before a change is not memoised after it. */
43
+ private readonly generations = new Map<string, number>();
44
+ private readonly now: () => number;
45
+
46
+ constructor(private readonly deps: VerifiedRoomAliasDeps) {
47
+ this.now = deps.now ?? Date.now;
48
+ }
49
+
50
+ /** The memoised verified alias of a room; null when there is none or no fresh verdict. */
51
+ known(roomId: string): string | null {
52
+ return this.fresh(roomId)?.alias ?? null;
53
+ }
54
+
55
+ /** The room's verified alias (null = none). Throws when the homeserver could not be asked. */
56
+ async verify(roomId: string): Promise<string | null> {
57
+ const memo = this.fresh(roomId);
58
+ if (memo) return memo.alias;
59
+ const generation = this.generations.get(roomId) ?? 0;
60
+ const claimed = await this.deps.readCanonicalAlias(roomId);
61
+ const alias =
62
+ claimed !== null && (await this.deps.resolveAlias(claimed)) === roomId
63
+ ? claimed
64
+ : null;
65
+ if ((this.generations.get(roomId) ?? 0) === generation)
66
+ this.remember(roomId, alias);
67
+ return alias;
68
+ }
69
+
70
+ /** Forget the room's verdict (its alias state changed). */
71
+ invalidate(roomId: string): void {
72
+ this.verdicts.delete(roomId);
73
+ this.generations.set(roomId, (this.generations.get(roomId) ?? 0) + 1);
74
+ }
75
+
76
+ private fresh(roomId: string): Verdict | undefined {
77
+ const verdict = this.verdicts.get(roomId);
78
+ if (!verdict) return undefined;
79
+ if (this.now() - verdict.at >= this.deps.ttlMs) {
80
+ this.verdicts.delete(roomId);
81
+ return undefined;
82
+ }
83
+ return verdict;
84
+ }
85
+
86
+ private remember(roomId: string, alias: string | null): void {
87
+ this.verdicts.delete(roomId);
88
+ const max = this.deps.maxRooms ?? DEFAULT_MAX_ROOMS;
89
+ while (this.verdicts.size >= max) {
90
+ const oldest = this.verdicts.keys().next().value;
91
+ if (oldest === undefined) break;
92
+ this.verdicts.delete(oldest);
93
+ }
94
+ this.verdicts.set(roomId, { alias, at: this.now() });
95
+ }
96
+ }