@ixo/oracle-runtime-workers 0.37.2 → 0.39.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 (111) hide show
  1. package/package.json +4 -4
  2. package/src/attachments/channel-links.ts +74 -7
  3. package/src/attachments/channel.ts +15 -4
  4. package/src/attachments/content-blocks.ts +19 -16
  5. package/src/attachments/download.ts +173 -50
  6. package/src/attachments/index.ts +6 -5
  7. package/src/attachments/pipeline.ts +117 -49
  8. package/src/attachments/resize.ts +142 -0
  9. package/src/attachments/retention.ts +26 -70
  10. package/src/attachments/room-media.ts +643 -0
  11. package/src/attachments/route.ts +47 -4
  12. package/src/attachments/sandbox-archive.ts +15 -4
  13. package/src/attachments/sanitize.ts +24 -13
  14. package/src/attachments/view.ts +82 -38
  15. package/src/channels/replies.ts +8 -4
  16. package/src/core/capability-expiry.ts +62 -0
  17. package/src/core/capability-router.ts +126 -95
  18. package/src/core/context-budget.ts +116 -4
  19. package/src/core/domain-context/document.ts +74 -2
  20. package/src/core/domain-context/index.ts +1 -0
  21. package/src/core/domain-context/resolver.ts +9 -6
  22. package/src/core/domain-context/turn-context.ts +62 -13
  23. package/src/core/domain-context/types.ts +17 -0
  24. package/src/core/env.ts +21 -2
  25. package/src/core/index.ts +4 -0
  26. package/src/core/llm.ts +29 -0
  27. package/src/core/main-agent-types.ts +53 -4
  28. package/src/core/main-agent.ts +228 -125
  29. package/src/core/manifest.ts +109 -24
  30. package/src/core/mcp-result.ts +56 -0
  31. package/src/core/meta-tools.ts +258 -110
  32. package/src/core/middlewares/attachment-recall.ts +502 -0
  33. package/src/core/middlewares/capability-gate.ts +77 -28
  34. package/src/core/middlewares/context-guard.ts +154 -21
  35. package/src/core/middlewares/index.ts +5 -0
  36. package/src/core/middlewares/result-cap.ts +1 -1
  37. package/src/core/middlewares/summarization.ts +9 -0
  38. package/src/core/middlewares/tool-marks.ts +11 -2
  39. package/src/core/middlewares/turn-boundary.ts +22 -12
  40. package/src/core/operating-guides.ts +126 -0
  41. package/src/core/plugins/skills/skills-tools.ts +6 -31
  42. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  43. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  44. package/src/core/prompt-composer.ts +188 -111
  45. package/src/core/read-result-tool.ts +48 -14
  46. package/src/core/registries.ts +13 -5
  47. package/src/core/state.ts +4 -3
  48. package/src/core/sub-agent-fallback.ts +1 -0
  49. package/src/core/subagent-as-tool.ts +4 -1
  50. package/src/core/tool-access.ts +3 -32
  51. package/src/core/wrap-plugin-tool.ts +2 -1
  52. package/src/do/contracts.ts +64 -24
  53. package/src/do/room-mirror.ts +144 -20
  54. package/src/do/transcript.ts +41 -12
  55. package/src/do/turn-attachments.ts +45 -6
  56. package/src/do/turn-metadata.ts +16 -4
  57. package/src/do/user-oracle-do.ts +639 -152
  58. package/src/index.ts +1 -0
  59. package/src/llm/provider-error.ts +5 -2
  60. package/src/llm/stream-liveness.ts +3 -2
  61. package/src/matrix/gateway-do.ts +164 -102
  62. package/src/matrix/ingest.ts +12 -3
  63. package/src/matrix/media-deadline.ts +5 -47
  64. package/src/memory/session-history-indexer.ts +60 -14
  65. package/src/plugin-api/oracle-plugin.ts +4 -5
  66. package/src/plugin-api/types.ts +32 -3
  67. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  68. package/src/plugins/agui/agui.plugin.ts +1 -0
  69. package/src/plugins/attachments/attachments.plugin.ts +3 -3
  70. package/src/plugins/attachments/view-attachment-tool.ts +6 -4
  71. package/src/plugins/compact-schema.ts +80 -0
  72. package/src/plugins/composio/composio.plugin.ts +1 -0
  73. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  74. package/src/plugins/editor/editor.plugin.ts +4 -0
  75. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  76. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  77. package/src/plugins/flows/flows.plugin.ts +37 -6
  78. package/src/plugins/flows/prompts.ts +1 -1
  79. package/src/plugins/flows/tools/authoring.ts +5 -7
  80. package/src/plugins/flows/tools/discovery.ts +1 -4
  81. package/src/plugins/flows/tools/forms.ts +2 -8
  82. package/src/plugins/flows/tools/linkage.ts +1 -4
  83. package/src/plugins/flows/tools/settings.ts +1 -4
  84. package/src/plugins/flows/translator.ts +16 -5
  85. package/src/plugins/flows/types.ts +27 -48
  86. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  87. package/src/plugins/index.ts +2 -2
  88. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  89. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  90. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  91. package/src/plugins/ixo-chain/queries.ts +10 -1
  92. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  93. package/src/plugins/matrix-group-chats/index.ts +1 -0
  94. package/src/plugins/memory/memory-tools.ts +4 -4
  95. package/src/plugins/memory/memory.plugin.ts +27 -1
  96. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  97. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  98. package/src/plugins/portal/portal.plugin.ts +1 -0
  99. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  100. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  101. package/src/plugins/tasks/manifest.ts +1 -0
  102. package/src/plugins/tasks/tasks-tools.ts +14 -19
  103. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  104. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  105. package/src/plugins/vfs/vfs-tools.ts +6 -6
  106. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  107. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  108. package/src/sqlite/inline-payloads.ts +121 -0
  109. package/src/sqlite/serialization.ts +12 -0
  110. package/src/sqlite/sqlite-saver.ts +258 -42
  111. package/src/tasks/schedule-input.ts +5 -22
@@ -6,13 +6,8 @@ import type {
6
6
  PluginTool,
7
7
  } from '../plugin-api/types';
8
8
  import type { CapabilityRequirement } from './manifest';
9
- import type {
10
- RegisteredSubAgent,
11
- SubAgentRegistry,
12
- ToolRegistry,
13
- } from './registries';
9
+ import type { RegisteredSubAgent } from './registries';
14
10
  import { computeSubAgentToolName } from './subagent-as-tool';
15
- import { NOOP_LOGGER } from './utils';
16
11
 
17
12
  /** `ctx.ucan.hasCapability` bound to the turn's delegation. */
18
13
  export type HasCapability = (resource: string, action: string) => boolean;
@@ -53,8 +48,8 @@ export interface TurnToolAccess<
53
48
  /**
54
49
  * Plugins that contributed tools or sub-agents of which none is left.
55
50
  * Treated like a plugin whose `requires` are unmet (left out of the
56
- * prompt, refused by `load_capability`, never preloaded) except that they
57
- * are hidden outright: `list_capabilities` does not list them.
51
+ * prompt, refused by `load_capability`, not offered to the router) except
52
+ * that they are hidden outright: `list_capabilities` does not list them.
58
53
  */
59
54
  hiddenPlugins: ReadonlySet<string>;
60
55
  }
@@ -145,30 +140,6 @@ export function resolveTurnToolAccess<
145
140
  return { tools, subAgents, withheldToolNames, hiddenPlugins };
146
141
  }
147
142
 
148
- /**
149
- * `hiddenPlugins` from the boot-time contributions alone, for the
150
- * capability router, which predicts before the turn's request-time tools are
151
- * collected. A plugin whose request-time tools would make it usable may be
152
- * left out here (the router does not offer it; `load_capability` still
153
- * can); one whose request-time tools are all withheld may be offered, and
154
- * the turn's build voids that preload (`createMainAgent`).
155
- */
156
- export async function bootHiddenPlugins(input: {
157
- registries: { tools: ToolRegistry; subAgents: SubAgentRegistry };
158
- buildCtx: PluginContext;
159
- has: HasCapability;
160
- }): Promise<ReadonlySet<string>> {
161
- const { registries, buildCtx, has } = input;
162
- return resolveTurnToolAccess({
163
- tools: await registries.tools.collectBoot(buildCtx),
164
- subAgents: registries.subAgents.collectBoot(buildCtx),
165
- buildCtx,
166
- has,
167
- // A failing sub-agent is reported by the turn's build, once.
168
- logger: NOOP_LOGGER,
169
- }).hiddenPlugins;
170
- }
171
-
172
143
  /**
173
144
  * The manifest as this turn may show it: `examples` naming a withheld tool
174
145
  * removed, so neither the Tier-1 prompt nor `load_capability` teaches the
@@ -1,5 +1,6 @@
1
1
  import { ToolMessage } from '@langchain/core/messages';
2
2
  import { Command } from '@langchain/langgraph';
3
+ import { withoutStructuredCopy } from './mcp-result';
3
4
  import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
4
5
  import { tool } from '@langchain/core/tools';
5
6
  import type { StructuredTool } from 'langchain';
@@ -125,7 +126,7 @@ export function wrapPluginTool(
125
126
  );
126
127
  if (pluginName !== undefined)
127
128
  requireToolPlane(ctx, pluginName, pluginTool);
128
- const output = await pluginTool.handler(args, ctx);
129
+ const output = withoutStructuredCopy(await pluginTool.handler(args, ctx));
129
130
  // A tool's own message (a result that says the tool did not run) is
130
131
  // returned whole, never capped into text.
131
132
  if (
@@ -248,6 +248,13 @@ export interface OracleWorkerEnv {
248
248
  * `"ai": { "binding": "AI" }` in the wrangler config.
249
249
  */
250
250
  AI?: Ai;
251
+ /**
252
+ * Cloudflare Images binding. Optional: images sent natively to a model are
253
+ * downscaled to a 1568 px long edge as JPEG first (`attachments/resize.ts`); the
254
+ * sandbox keeps the original. Absent = images go as they are. Declare it
255
+ * with `"images": { "binding": "IMAGES" }` in the wrangler config.
256
+ */
257
+ IMAGES?: ImagesBinding;
251
258
 
252
259
  // --- llm ----------------------------------------------------------------
253
260
  OPEN_ROUTER_API_KEY: string;
@@ -264,6 +271,8 @@ export interface OracleWorkerEnv {
264
271
  LLM_STREAM_IDLE_TIMEOUT_MS?: string;
265
272
  LLM_BYO_STREAM_IDLE_TIMEOUT_MS?: string;
266
273
  LLM_STREAM_RETRIES?: string;
274
+ /** Most tokens one platform model call may generate, reasoning included (default 16384; 0 = the model's limit). */
275
+ LLM_MAX_OUTPUT_TOKENS?: string;
267
276
  /** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
268
277
  TURN_RECURSION_LIMIT?: string;
269
278
  /** Turn budget: tool attempts (default 120) and deadline in ms (600000). */
@@ -288,7 +297,12 @@ export interface OracleWorkerEnv {
288
297
  MODEL_CONTEXT_TOKENS?: string;
289
298
  MODEL_CONTEXT_OVERRIDES?: string;
290
299
  CONTEXT_SUMMARIZE_FRACTION?: string;
300
+ CONTEXT_SUMMARIZE_MAX_TOKENS?: string;
291
301
  CONTEXT_PRUNE_FRACTION?: string;
302
+ CONTEXT_PRUNE_MAX_TOKENS?: string;
303
+ CONTEXT_STALE_RESULT_TURNS?: string;
304
+ CONTEXT_CACHE_COLD_MS?: string;
305
+ CONTEXT_PLUGIN_IDLE_TURNS?: string;
292
306
  CONTEXT_RESULT_CAP_FRACTION?: string;
293
307
  CONTEXT_RESULT_CAP_MAX_CHARS?: string;
294
308
  CONTEXT_REQUEST_FRACTION?: string;
@@ -320,9 +334,14 @@ export interface OracleWorkerEnv {
320
334
  /**
321
335
  * Capability router (needs a `DECISION_PROVIDER`). `off` (default) never
322
336
  * evaluates; `shadow` evaluates and logs the verdict without preloading;
323
- * `on` preloads the predicted on-demand plugin's tools for the turn.
337
+ * `on` loads the predicted plugins on the thread before the first model
338
+ * call.
324
339
  */
325
340
  CAPABILITY_ROUTER?: CapabilityRouterMode;
341
+ /** The score a preload needs on a warm turn (default 0.8; above 1, never). */
342
+ CAPABILITY_ROUTER_WARM_THRESHOLD?: string;
343
+ /** The score a preload needs on a cold turn (default 0.7). */
344
+ CAPABILITY_ROUTER_COLD_THRESHOLD?: string;
326
345
  /** Applies exclusively to authenticated channel admission; Portal/Matrix turns never route. */
327
346
  CHANNEL_SEMANTIC_ROUTING?: 'off' | 'shadow' | 'on';
328
347
  CHANNEL_SEMANTIC_ROUTING_BINDINGS?: string;
@@ -982,10 +1001,9 @@ export interface CreateRoomOptions {
982
1001
  }
983
1002
 
984
1003
  /**
985
- * Deadline for one gateway media download, from opening it to the last byte
986
- * (the gateway's default applies when omitted). Only the timeout crosses the
987
- * RPC: an `AbortSignal` cannot, so the caller stops a transfer early by
988
- * cancelling the returned stream.
1004
+ * Deadline for one gateway media event lookup (`eventMediaInfo`; the
1005
+ * gateway's default applies when omitted). Only the timeout crosses the RPC:
1006
+ * an `AbortSignal` cannot.
989
1007
  */
990
1008
  export interface MediaDownloadDeadline {
991
1009
  timeoutMs?: number;
@@ -1000,22 +1018,20 @@ export interface MediaDownloadDeadline {
1000
1018
  */
1001
1019
  export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1002
1020
  /**
1003
- * A media event's content as a stream, AS STORED: in an E2EE room the
1004
- * ciphertext plus the `EncryptedFile` fields to pipe through the SDK's
1005
- * `createAttachmentDecryptor` on the caller's side (a stream that errors
1006
- * on the far side of an RPC only surfaces here as a disconnect, so the
1007
- * hash check belongs to the consumer). Null when unknown or redacted.
1021
+ * Where the media of event `eventId` in `roomId` lives, as small JSON: the
1022
+ * gateway fetches and decrypts the EVENT (only it holds the room keys),
1023
+ * never the media. The caller downloads `mxc` from the homeserver itself
1024
+ * (`openHomeserverMedia`, with the plugin device's token) and decrypts it
1025
+ * with `file` (`createAttachmentDecryptor`), so no media byte crosses the
1026
+ * shared gateway object. Bounded by `opts.timeoutMs` (default the media
1027
+ * download deadline). Null when the event is unknown, redacted or carries
1028
+ * no media.
1008
1029
  */
1009
- downloadEventMediaStream(
1030
+ eventMediaInfo(
1010
1031
  roomId: string,
1011
1032
  eventId: string,
1012
1033
  opts?: MediaDownloadDeadline,
1013
- ): Promise<MediaStream | null>;
1014
- /** Authenticated media download of an `mxc://` URI, as a stream. */
1015
- downloadMxcMediaStream(
1016
- mxc: string,
1017
- opts?: MediaDownloadDeadline,
1018
- ): Promise<ReadableStream<Uint8Array>>;
1034
+ ): Promise<EventMediaInfo | null>;
1019
1035
  /** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
1020
1036
  debugAbortObject(): Promise<void>;
1021
1037
  /** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
@@ -1042,11 +1058,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1042
1058
  listOutbox(): Promise<OutboxRow[]>;
1043
1059
  /**
1044
1060
  * Credentials of the oracle's second, crypto-less device for plugins that
1045
- * run their own Matrix client (editor, flows). Logged in once with the
1046
- * account password, kept in the gateway's storage, re-logged in with the
1047
- * same device id if the homeserver ever rejects the token.
1061
+ * run their own Matrix client (editor, flows) and for the user objects'
1062
+ * own media transfers (channel file uploads, Matrix media downloads).
1063
+ * Logged in once with the account password, kept in the gateway's
1064
+ * storage; concurrent first calls share one login. A caller whose
1065
+ * request the homeserver answered 401 passes the token it used as
1066
+ * `rejected`: when that is still the cached token, the cache is dropped and
1067
+ * the stored token re-validated (`whoami`), else the device is logged in
1068
+ * again with the same device id — once, for every caller waiting.
1048
1069
  */
1049
- botCredentials(): Promise<BotCredentials>;
1070
+ botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
1050
1071
  status(): Promise<GatewayStatus>;
1051
1072
  /** Send a text message (encrypted when the room is). Returns the event id. */
1052
1073
  sendText(
@@ -1075,12 +1096,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1075
1096
  * event id. `txnId` pins the transaction id, as for `sendText`: the
1076
1097
  * homeserver deduplicates by it per device, so a send retried after a lost
1077
1098
  * response returns the original event instead of posting a second one.
1099
+ * `priority: 'background'` (channel text mirrors, channel file events)
1100
+ * goes through the gateway's small background lane, so such sends never
1101
+ * hold more than a few of the send path's places ahead of a reply
1102
+ * (`interactive`, the default). Content carrying an attachment key
1103
+ * (`file.key`) is refused for a room the gateway does not see as
1104
+ * end-to-end encrypted.
1078
1105
  */
1079
1106
  sendEvent(
1080
1107
  roomId: string,
1081
1108
  type: string,
1082
1109
  content: JsonString,
1083
- opts?: { txnId?: string },
1110
+ opts?: { txnId?: string; priority?: 'interactive' | 'background' },
1084
1111
  ): Promise<string>;
1085
1112
  /** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
1086
1113
  resolveUserRoom(
@@ -1105,7 +1132,7 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1105
1132
  ): Promise<{ eventId: string }>;
1106
1133
  /**
1107
1134
  * The latest snapshot for `storageKey` as a stream, as stored (see
1108
- * `downloadEventMediaStream`), or null when none exists.
1135
+ * `MediaStream`), or null when none exists.
1109
1136
  */
1110
1137
  downloadUserSnapshotStream(
1111
1138
  userDid: string,
@@ -1200,7 +1227,6 @@ export interface OutboxRow {
1200
1227
  enqueuedAt: number;
1201
1228
  }
1202
1229
 
1203
- /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
1204
1230
  /**
1205
1231
  * Media handed across the Durable Object boundary as a stream, as stored on
1206
1232
  * the homeserver: `file` is present when the event carried an encrypted
@@ -1216,6 +1242,19 @@ export interface MediaStream {
1216
1242
  filename?: string;
1217
1243
  }
1218
1244
 
1245
+ /**
1246
+ * Where a media event's file lives (`MatrixGatewayObject.eventMediaInfo`):
1247
+ * its `mxc://` URI and, for an encrypted attachment, the `EncryptedFile`
1248
+ * fields to decrypt it with. `size` is the sender's `info.size` when set.
1249
+ */
1250
+ export interface EventMediaInfo {
1251
+ mxc: string;
1252
+ file?: EncryptedFileInfo;
1253
+ mimetype?: string;
1254
+ filename?: string;
1255
+ size?: number;
1256
+ }
1257
+
1219
1258
  /** `MediaStream` of a user's snapshot, plus the media event that carries it. */
1220
1259
  export interface SnapshotStream extends MediaStream {
1221
1260
  eventId: string;
@@ -1229,6 +1268,7 @@ export interface GroupRoomInfo {
1229
1268
  groupLane: boolean;
1230
1269
  }
1231
1270
 
1271
+ /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
1232
1272
  export interface GatewayStatus extends BotStatus {
1233
1273
  turns: { inFlight: number; waiting: number };
1234
1274
  /** Channel memory of group rooms: messages awaiting compaction, summary chunks, pinned facts. */
@@ -19,9 +19,20 @@ import {
19
19
  * - sends are serialised per session (parallel across sessions), so a reply
20
20
  * being retried can never overtake the message it answers, and a later
21
21
  * turn's mirrors queue behind an earlier turn's.
22
+ *
23
+ * The same chain carries the media events of a channel turn's files
24
+ * (`sendMedia`, built by `attachments/room-media.ts`): the owner object
25
+ * queues them in file order right after the person's text, so each is
26
+ * posted after that text and before the reply, whenever its upload ends.
22
27
  */
28
+ import type { EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
29
+ import { abortError, untilAborted } from '../attachments/download';
23
30
  import { matrixTxnId } from '../matrix/txn-id';
24
- import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
31
+ import {
32
+ isTransientGatewayError,
33
+ retryGateway,
34
+ type RetryGatewayOptions,
35
+ } from './gateway-retry';
25
36
 
26
37
  export type MirrorAuthor = 'user' | 'oracle';
27
38
 
@@ -53,8 +64,43 @@ export interface MirrorSend {
53
64
  };
54
65
  }
55
66
 
67
+ /** The `org.ixo.qi.origin` input of a channel mirror. */
68
+ export type MirrorOrigin = NonNullable<MirrorSend['origin']>;
69
+
70
+ /**
71
+ * The media part of an `m.room.message` file event (`m.image` / `m.video` /
72
+ * `m.audio` / `m.file`): what the room shows, without the thread relation
73
+ * and the channel origin, which `mediaEventContent` adds.
74
+ */
75
+ export interface MediaMessage {
76
+ msgtype: 'm.image' | 'm.video' | 'm.audio' | 'm.file';
77
+ body: string;
78
+ filename: string;
79
+ info: { mimetype: string; size: number };
80
+ /**
81
+ * The encrypted attachment: its `mxc://` plus the `EncryptedFile` fields.
82
+ * Always encrypted: a file is only ever posted into a Megolm room.
83
+ */
84
+ file: { url: string } & EncryptedFileInfo;
85
+ }
86
+
87
+ export interface MediaMirrorSend {
88
+ roomId: string;
89
+ /** The session's marker event — the thread the file is posted under. */
90
+ threadId: string;
91
+ txnId: string;
92
+ origin?: MirrorOrigin;
93
+ media: MediaMessage;
94
+ }
95
+
56
96
  export interface RoomMirrorDeps {
57
97
  sendText(send: MirrorSend): Promise<string>;
98
+ /**
99
+ * Send one file event (`content` JSON-encoded) with a pinned transaction
100
+ * id, at background priority (`MatrixGatewayObject.sendEvent`); returns
101
+ * its event id.
102
+ */
103
+ sendEvent(roomId: string, content: string, txnId: string): Promise<string>;
58
104
  /** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
59
105
  keepAlive(work: Promise<unknown>): void;
60
106
  log(message: string): void;
@@ -63,6 +109,16 @@ export interface RoomMirrorDeps {
63
109
  retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
64
110
  }
65
111
 
112
+ /** The relation that posts an event in the thread of `threadId` (with the reply fallback for clients without threads). */
113
+ function threadRelation(threadId: string) {
114
+ return {
115
+ rel_type: 'm.thread',
116
+ event_id: threadId,
117
+ is_falling_back: true,
118
+ 'm.in_reply_to': { event_id: threadId },
119
+ };
120
+ }
121
+
66
122
  export function mirrorEventContent(send: MirrorSend) {
67
123
  return {
68
124
  msgtype: 'm.text',
@@ -70,12 +126,7 @@ export function mirrorEventContent(send: MirrorSend) {
70
126
  ...(send.formattedBody
71
127
  ? { format: 'org.matrix.custom.html', formatted_body: send.formattedBody }
72
128
  : {}),
73
- 'm.relates_to': {
74
- rel_type: 'm.thread',
75
- event_id: send.threadId,
76
- is_falling_back: true,
77
- 'm.in_reply_to': { event_id: send.threadId },
78
- },
129
+ 'm.relates_to': threadRelation(send.threadId),
79
130
  ...(send.origin
80
131
  ? {
81
132
  'org.ixo.qi.origin': channelOrigin(send.origin),
@@ -87,6 +138,19 @@ export function mirrorEventContent(send: MirrorSend) {
87
138
  };
88
139
  }
89
140
 
141
+ /**
142
+ * A file event in the session thread: the media part, the thread relation
143
+ * of the text mirrors and their `org.ixo.qi.origin` (never the quote, which
144
+ * the text mirror of the same message carries).
145
+ */
146
+ export function mediaEventContent(send: MediaMirrorSend) {
147
+ return {
148
+ ...send.media,
149
+ 'm.relates_to': threadRelation(send.threadId),
150
+ ...(send.origin ? { 'org.ixo.qi.origin': channelOrigin(send.origin) } : {}),
151
+ };
152
+ }
153
+
90
154
  const errorText = (err: unknown): string =>
91
155
  err instanceof Error ? err.message : String(err);
92
156
 
@@ -115,38 +179,98 @@ export class RoomMirror {
115
179
  label: string,
116
180
  required = false,
117
181
  ): Promise<void> {
118
- const previous = this.chains.get(sessionId) ?? Promise.resolve();
119
- // Starts once the previous mirror settled either way: a required mirror
120
- // that failed (and rejected its own caller) must not skip this one.
121
- const tracked: Promise<void> = previous
122
- .catch(() => undefined)
123
- .then(() => prepare())
124
- .then((send) => (send ? this.send(send, label) : undefined))
125
- .catch((err: unknown) => {
182
+ return this.chain(sessionId, async () => {
183
+ try {
184
+ const send = await prepare();
185
+ if (send)
186
+ await this.send(() => this.deps.sendText(send), send.threadId, label);
187
+ } catch (err) {
126
188
  if (required) throw err;
127
189
  this.deps.warn(
128
190
  `[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
129
191
  );
130
- })
192
+ }
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Queue one file event behind the session's previous mirrors. When its
198
+ * turn in the chain comes, `prepare` builds the event (it waits for the
199
+ * file's upload; `null` = nothing to post) and the send follows with the
200
+ * transient retries; resolves with the event id, or null when nothing was
201
+ * posted. `signal` is honoured throughout: checked before `prepare` and
202
+ * before every send attempt, and it ends the wait on `prepare` — once it
203
+ * aborts no further attempt is made and the call rejects with its reason.
204
+ * A send that fails after the retries rejects.
205
+ */
206
+ sendMedia(
207
+ sessionId: string,
208
+ prepare: () => Promise<MediaMirrorSend | null>,
209
+ label: string,
210
+ signal?: AbortSignal,
211
+ ): Promise<string | null> {
212
+ return this.chain(sessionId, async () => {
213
+ if (signal?.aborted) throw abortError(signal);
214
+ const send = await (signal ? untilAborted(prepare(), signal) : prepare());
215
+ if (!send) return null;
216
+ const content = JSON.stringify(mediaEventContent(send));
217
+ return this.send(
218
+ () => {
219
+ if (signal?.aborted) return Promise.reject(abortError(signal));
220
+ return this.deps.sendEvent(send.roomId, content, send.txnId);
221
+ },
222
+ send.threadId,
223
+ label,
224
+ signal,
225
+ );
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Run `work` once the session's previous mirrors have settled either way
231
+ * (a required mirror that failed, and rejected its own caller, must not
232
+ * skip the next one), keep it alive past the request, and drop the chain
233
+ * once it drains.
234
+ */
235
+ private chain<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
236
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
237
+ // Settles (as `work` did) once the chain entry is gone, so a caller that
238
+ // awaited it sees the session's chain drained.
239
+ const done = previous
240
+ .catch(() => undefined)
241
+ .then(work)
131
242
  .finally(() => {
132
243
  if (this.chains.get(sessionId) === tracked)
133
244
  this.chains.delete(sessionId);
134
245
  });
246
+ const tracked: Promise<void> = done.then(
247
+ () => undefined,
248
+ () => undefined,
249
+ );
135
250
  this.chains.set(sessionId, tracked);
136
251
  this.deps.keepAlive(tracked);
137
- return tracked;
252
+ return done;
138
253
  }
139
254
 
140
- private async send(send: MirrorSend, label: string): Promise<void> {
141
- const eventId = await retryGateway(() => this.deps.sendText(send), {
255
+ private async send(
256
+ post: () => Promise<string>,
257
+ threadId: string,
258
+ label: string,
259
+ signal?: AbortSignal,
260
+ ): Promise<string> {
261
+ const isTransient = this.deps.retry?.isTransient ?? isTransientGatewayError;
262
+ const eventId = await retryGateway(post, {
142
263
  ...this.deps.retry,
264
+ // An abort is final: the transient check would otherwise retry it.
265
+ isTransient: (err) => !signal?.aborted && isTransient(err),
143
266
  onRetry: (err, attempt, delayMs) =>
144
267
  this.deps.warn(
145
268
  `[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
146
269
  ),
147
270
  });
148
271
  this.deps.log(
149
- `[user-do] Matrix replay (${label}) → ${eventId} in thread ${send.threadId}`,
272
+ `[user-do] Matrix replay (${label}) → ${eventId} in thread ${threadId}`,
150
273
  );
274
+ return eventId;
151
275
  }
152
276
  }
@@ -111,7 +111,7 @@ export function contentToText(content: BaseMessage['content']): string {
111
111
  if (typeof part === 'string') return part;
112
112
  if (part && typeof part === 'object' && 'text' in part) {
113
113
  const text = (part as { text?: unknown }).text;
114
- // The retention placeholder is model-facing only; the transcript
114
+ // The attachment placeholder is model-facing only; the transcript
115
115
  // keeps listing the attachment from its metadata.
116
116
  return typeof text === 'string' && !isAttachmentPlaceholderText(text)
117
117
  ? text
@@ -140,6 +140,45 @@ export function visibleText(message: BaseMessage): string {
140
140
  return contentToText(visibleContent(message));
141
141
  }
142
142
 
143
+ /**
144
+ * Whether a stored message is one the user sees in the transcript. System and
145
+ * tool messages never are, nor a failed reply, nor anything the runtime wrote
146
+ * for the model about an attachment: the extracted text of a file (an AI
147
+ * message with an `attachment`) and `view_attachment` re-attaching a payload,
148
+ * nor a chat gateway's delivery notice.
149
+ */
150
+ export function visibleToUser(message: BaseMessage): boolean {
151
+ if (message.type === 'system' || message.type === 'tool') return false;
152
+ const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
153
+ if (kw.isError) return false;
154
+ if (message.type === 'ai' && Boolean(kw.attachment)) return false;
155
+ // A chat gateway's delivery notice: the model's, never a user message.
156
+ return !isAttachmentViewMessage(message) && !isDeliveryNoticeMessage(message);
157
+ }
158
+
159
+ /**
160
+ * The latest turns as the user saw them, oldest first and each as
161
+ * `user: …` or `assistant: …` cut to `maxChars`, from messages listed newest
162
+ * first. Only what `visibleToUser` lets through, without summaries and rows
163
+ * with no visible text.
164
+ */
165
+ export function recentTurnTexts(
166
+ newestFirst: readonly BaseMessage[],
167
+ limit: number,
168
+ maxChars: number,
169
+ ): string[] {
170
+ const turns: string[] = [];
171
+ for (const message of newestFirst) {
172
+ if (turns.length === limit) break;
173
+ if (isSummarizationMessage(message) || !visibleToUser(message)) continue;
174
+ const text = visibleText(message).trim();
175
+ if (!text) continue;
176
+ const speaker = message.type === 'human' ? 'user' : 'assistant';
177
+ turns.push(`${speaker}: ${text.slice(0, maxChars)}`);
178
+ }
179
+ return turns.reverse();
180
+ }
181
+
143
182
  /**
144
183
  * The summarization middleware's bookkeeping message — never shown to users.
145
184
  * LangChain 1.4 writes it as a HUMAN message tagged
@@ -171,18 +210,8 @@ export async function transformTranscript(
171
210
  for (const message of messages) {
172
211
  const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
173
212
  const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
174
- const isExtractionContext = message.type === 'ai' && Boolean(kw.attachment);
175
213
 
176
- if (
177
- message.type !== 'system' &&
178
- message.type !== 'tool' &&
179
- !kw.isError &&
180
- !isExtractionContext &&
181
- // `view_attachment` re-attaching an offloaded payload — model-facing.
182
- !isAttachmentViewMessage(message) &&
183
- // A chat gateway's delivery notice — the model's, never a user message.
184
- !isDeliveryNoticeMessage(message)
185
- ) {
214
+ if (visibleToUser(message)) {
186
215
  const reasoning = reasoningToText(kw.reasoning);
187
216
  const attachments =
188
217
  message.type === 'human'
@@ -15,6 +15,7 @@ import {
15
15
  type ChannelMediaSource,
16
16
  type PreparedAttachments,
17
17
  } from '../attachments';
18
+ import type { ChannelThreadFile } from '../attachments/room-media';
18
19
  import { parseChannelServiceDids } from '../channels/auth';
19
20
  import {
20
21
  channelAttachmentInputs,
@@ -58,26 +59,64 @@ export function channelTurnLinks(
58
59
  }));
59
60
  }
60
61
 
62
+ /**
63
+ * A channel turn's files as `room-media.ts` posts them to the session
64
+ * thread: the first occurrence of each file (a file sent twice in one
65
+ * message is posted once), with its position in the message.
66
+ */
67
+ export function channelThreadFiles(req: TurnFiles): ChannelThreadFile[] {
68
+ if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
69
+ const bindingId = req.channel.bindingId;
70
+ const seen = new Set<string>();
71
+ const files: ChannelThreadFile[] = [];
72
+ req.channel.attachments.forEach((file, index) => {
73
+ const ref = channelAttachmentRef(file.sha256);
74
+ if (seen.has(ref)) return;
75
+ seen.add(ref);
76
+ files.push({
77
+ ref,
78
+ index,
79
+ link: { url: file.url, sha256: file.sha256, bindingId },
80
+ filename: file.filename,
81
+ mimetype: file.mimetype,
82
+ size: file.size,
83
+ kind: file.kind,
84
+ });
85
+ });
86
+ return files;
87
+ }
88
+
89
+ /** The https origins channel files are fetched from: the gateways of `CHANNEL_SERVICE_DID`. */
90
+ export function channelGatewayOrigins(
91
+ channelServiceDid: string | undefined,
92
+ ): string[] {
93
+ return channelMediaOrigins(parseChannelServiceDids(channelServiceDid));
94
+ }
95
+
61
96
  /**
62
97
  * The channel source of a turn in `sessionId`: a reference resolves to the
63
98
  * turn's own files first, then to the links kept for the session; only the
64
- * gateways of `CHANNEL_SERVICE_DID` are fetched from.
99
+ * gateways of `CHANNEL_SERVICE_DID` are fetched from. The store also names
100
+ * the room copy of a file posted to the session thread, which serves it
101
+ * when its link fails or has expired (`loadAttachmentBytes`).
65
102
  */
66
103
  export function turnChannelMediaSource(input: {
67
104
  sessionId: string;
68
105
  turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
69
- store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
106
+ store: Pick<ChannelAttachmentLinkStore, 'get' | 'eventIdOf'> | null;
70
107
  channelServiceDid: string | undefined;
71
108
  }): ChannelMediaSource {
109
+ const store = input.store;
72
110
  return createChannelMediaSource({
73
- origins: channelMediaOrigins(
74
- parseChannelServiceDids(input.channelServiceDid),
75
- ),
111
+ origins: channelGatewayOrigins(input.channelServiceDid),
76
112
  resolve: async (ref) => {
77
113
  const own = input.turnLinks.find((link) => link.ref === ref);
78
114
  if (own) return own;
79
- return input.store?.get(input.sessionId, ref);
115
+ return store?.get(input.sessionId, ref);
80
116
  },
117
+ ...(store
118
+ ? { roomEventOf: (ref: string) => store.eventIdOf(input.sessionId, ref) }
119
+ : {}),
81
120
  });
82
121
  }
83
122
 
@@ -62,6 +62,16 @@ export interface PriorMetadataState {
62
62
  sessionRunId?: string;
63
63
  currentEntityDid?: string;
64
64
  loadedPlugins?: string[];
65
+ /** The plugins whose operating guides the prompt carried (`operating-guides.ts`). */
66
+ promptGuides?: string[];
67
+ /** The plugins whose usage the thread's messages carry (`operating-guides.ts`). */
68
+ capabilityUsage?: string[];
69
+ }
70
+
71
+ function strings(value: unknown): string[] | undefined {
72
+ return Array.isArray(value)
73
+ ? value.filter((entry): entry is string => typeof entry === 'string')
74
+ : undefined;
65
75
  }
66
76
 
67
77
  function priorString(
@@ -75,15 +85,17 @@ function priorString(
75
85
  export function priorMetadataState(
76
86
  prior: Record<string, unknown>,
77
87
  ): PriorMetadataState {
78
- const loaded = prior['loadedPlugins'];
88
+ const loaded = strings(prior['loadedPlugins']);
89
+ const promptGuides = strings(prior['promptGuides']);
90
+ const capabilityUsage = strings(prior['capabilityUsage']);
79
91
  return {
80
92
  editorRoomId: priorString(prior, 'editorRoomId'),
81
93
  spaceId: priorString(prior, 'spaceId'),
82
94
  sessionRunId: priorString(prior, 'sessionRunId'),
83
95
  currentEntityDid: priorString(prior, 'currentEntityDid'),
84
- ...(Array.isArray(loaded)
85
- ? { loadedPlugins: loaded.filter((p) => typeof p === 'string') }
86
- : {}),
96
+ ...(loaded ? { loadedPlugins: loaded } : {}),
97
+ ...(promptGuides ? { promptGuides } : {}),
98
+ ...(capabilityUsage ? { capabilityUsage } : {}),
87
99
  };
88
100
  }
89
101