@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
@@ -123,19 +123,20 @@ import {
123
123
  type BaseMessage,
124
124
  } from '@langchain/core/messages';
125
125
  import {
126
- applyAttachmentRetention,
127
- ATTACHMENT_PAYLOAD_TURNS,
128
126
  ATTACHMENT_PLACEHOLDER_PREFIX,
127
+ classifyAttachment,
129
128
  createAttachmentViewSurface,
130
129
  findPersistedAttachmentMeta,
131
130
  IngestTextLru,
132
- INLINE_PAYLOAD_NEEDLE,
133
131
  isAttachmentViewMessage,
134
- offloadInlinePayloads,
132
+ loadAttachmentBytes,
135
133
  parseAttachmentInputs,
136
134
  prepareAttachments,
135
+ resizeForModel,
136
+ sanitizeAttachmentFilename,
137
137
  ChannelAttachmentLinkStore,
138
138
  type AttachmentViewSurface,
139
+ type AttachmentInput,
139
140
  type MatrixMediaSource,
140
141
  type ViewAttachmentDeps,
141
142
  type SandboxUploadConfig,
@@ -145,6 +146,26 @@ import {
145
146
  AttachmentTextCacheStore,
146
147
  } from '../attachments';
147
148
  import {
149
+ AttachmentTooLargeError,
150
+ boundedSignal,
151
+ HomeserverUnauthorizedError,
152
+ openHomeserverMedia,
153
+ untilAborted,
154
+ } from '../attachments/download';
155
+ // Not in the attachments index: it loads the bot SDK, which only workerd runs.
156
+ import {
157
+ CHANNEL_MEDIA_SEND_TIMEOUT_MS,
158
+ CHANNEL_MEDIA_TEXT_WAIT_MS,
159
+ CHANNEL_MEDIA_TURN_TIMEOUT_MS,
160
+ channelFilesToKeep,
161
+ channelMediaTxnId,
162
+ startChannelFileUploads,
163
+ type ChannelFileUpload,
164
+ type ThreadFileCopy,
165
+ } from '../attachments/room-media';
166
+ import {
167
+ channelGatewayOrigins,
168
+ channelThreadFiles,
148
169
  channelTurnLinks,
149
170
  prepareTurnAttachments,
150
171
  turnAttachmentInputs,
@@ -165,9 +186,15 @@ import {
165
186
  DEFAULT_MODEL_ID,
166
187
  } from '../core/llm';
167
188
  import { renderTurnTimeNote } from '../core/prompt-composer';
189
+ import type { RegisteredManifest } from '../core/registries';
190
+ import {
191
+ TURN_GUIDE_NOTE_KWARG,
192
+ USAGE_CARRIED_KWARG,
193
+ } from '../core/operating-guides';
168
194
  import { TURN_TIME_NOTE_KWARG, withTurnTimeNote } from '../core/turn-time-note';
169
195
  import {
170
196
  capabilityRouterMode,
197
+ preloadThreshold,
171
198
  createCapabilityRouter,
172
199
  shadowAgreement,
173
200
  type CapabilityRouter,
@@ -182,7 +209,6 @@ import type {
182
209
  RequestAdmissionResult,
183
210
  RequestDisposition,
184
211
  } from '../plugin-api/request-admission';
185
- import { bootHiddenPlugins } from '../core/tool-access';
186
212
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
187
213
  import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
188
214
  import { chatGptBackendFromEnv } from '../llm/byo-client';
@@ -199,6 +225,8 @@ import { parseJwk } from '../secrets/jwe';
199
225
  import { WorkersSecretsService } from '../secrets/secrets-service';
200
226
  import { createSecretsAdapter } from './secrets-adapter';
201
227
  import {
228
+ EDITOR_PLUGIN_NAME,
229
+ editorContextActive,
202
230
  metadataBuildState,
203
231
  metadataGraphInput,
204
232
  parseTurnMetadata,
@@ -274,6 +302,7 @@ import {
274
302
  } from './ambient';
275
303
  import {
276
304
  checkpointStorageKey,
305
+ type BotCredentials,
277
306
  type MatrixGatewayObject,
278
307
  type OracleWorkerEnv,
279
308
  type SessionSummary,
@@ -314,6 +343,11 @@ import { freshStub } from './fresh-stub';
314
343
  import { WorkStatusProducer } from '../matrix/work-status';
315
344
  import { fetchUserMatrixServerName } from '../matrix/user-homeserver';
316
345
  import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
346
+ import {
347
+ RecalledImageCache,
348
+ type RecallImageLoader,
349
+ } from '../core/middlewares/attachment-recall';
350
+ import { bytesToBase64 } from '../plugins/base64';
317
351
  import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
318
352
  import { SqliteUserKv } from '../sqlite/user-kv-store';
319
353
  import {
@@ -321,6 +355,7 @@ import {
321
355
  pageOfMessages,
322
356
  SessionHistoryIndexer,
323
357
  type HistoryEntry,
358
+ conversationReader,
324
359
  type HistoryMessage,
325
360
  type HistoryPage,
326
361
  type IndexableSession,
@@ -428,6 +463,7 @@ import {
428
463
  contentToText,
429
464
  isSummarizationMessage,
430
465
  pageThreadTranscript,
466
+ recentTurnTexts,
431
467
  TranscriptCursorError,
432
468
  transformTranscript,
433
469
  type TranscriptPageOptions,
@@ -544,6 +580,15 @@ type UserContextSettle = (
544
580
  signal: AbortSignal,
545
581
  ) => Promise<UserContextData | undefined>;
546
582
 
583
+ /**
584
+ * What the capability router sees of the thread: enough to route a follow-up
585
+ * (two exchanges, each turn cut short), read from a bounded number of rows so
586
+ * a long thread costs nothing extra.
587
+ */
588
+ const ROUTER_RECENT_TURNS = 4;
589
+ const ROUTER_RECENT_TURN_CHARS = 300;
590
+ const ROUTER_RECENT_ROWS = 12;
591
+
547
592
  /** How long a first turn waits on the user-context prefetch before saying so. */
548
593
  const USER_CONTEXT_PROGRESS_GRACE_MS = 300;
549
594
  const USER_CONTEXT_PROGRESS_STEP = 'Recalling your memories…';
@@ -1183,6 +1228,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1183
1228
  private matrixLedger: MatrixTurnLedger | null = null;
1184
1229
  /** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
1185
1230
  private mirror: RoomMirror | null = null;
1231
+ /** The oracle's plugin device, once known (see `pluginDevice`). */
1232
+ private pluginCredentials: BotCredentials | null = null;
1233
+ /** Its lookup in flight: concurrent callers share one gateway RPC. */
1234
+ private pluginCredentialsLoad: Promise<BotCredentials> | null = null;
1235
+ /**
1236
+ * Channel requests with files whose person's text the mirror pass has
1237
+ * queued on the session chain (value without `waiter`), or whose file
1238
+ * events wait for that (`waiter`); see `afterChannelText`.
1239
+ */
1240
+ private readonly channelTextQueued = new Map<
1241
+ string,
1242
+ { at: number; waiter?: () => void }
1243
+ >();
1244
+ /** Images the recall middleware re-attached lately, kept across this instance's turns. */
1245
+ private readonly recallCache = new RecalledImageCache();
1186
1246
  private channelTurns: ChannelTurns | null = null;
1187
1247
  /** Anonymous-feedback idempotency markers and the user's submission limit. */
1188
1248
  private feedbackMarkers: FeedbackMarkers | null = null;
@@ -4745,7 +4805,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4745
4805
  !isAttachmentViewMessage(m) &&
4746
4806
  !isDeliveryNoticeMessage(m),
4747
4807
  )
4748
- .map((m) => ({ type: m.type, content: visibleText(m) }));
4808
+ .map(conversationReader());
4749
4809
  }
4750
4810
 
4751
4811
  /**
@@ -4768,6 +4828,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4768
4828
  }
4769
4829
  const pageRows = Math.max(1, limit);
4770
4830
  const entries: HistoryEntry[] = [];
4831
+ const read = conversationReader();
4771
4832
  for (;;) {
4772
4833
  const rows = await saver.listThreadMessageRows(sessionId, {
4773
4834
  direction: 'newer',
@@ -4784,7 +4845,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4784
4845
  )
4785
4846
  continue;
4786
4847
  entries.push({
4787
- message: { type: m.type, content: visibleText(m) },
4848
+ message: read(m),
4788
4849
  cursor: row.messageId,
4789
4850
  });
4790
4851
  }
@@ -4979,6 +5040,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4979
5040
  prior: unknown;
4980
5041
  client: TurnRequest['client'];
4981
5042
  roomKind: TurnRequest['roomKind'];
5043
+ /** The provider's cache has expired, so the prompt may change. */
5044
+ coldTurn: boolean;
4982
5045
  }): UserContextSettle | null {
4983
5046
  // A first contact that fetches nothing still prints the one
4984
5047
  // first-contact line (see the settle step below), with its reason, so
@@ -5033,9 +5096,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5033
5096
  // room answer — but only one already in the cache: an upload that
5034
5097
  // just invalidated the room must not make the next message wait for
5035
5098
  // a gather. On a miss the thread keeps its context and the
5036
- // (single-flight) refresh warms the cache for a later turn.
5099
+ // (single-flight) refresh warms the cache for a later turn. The
5100
+ // answer sits in the system prompt, so it waits for a cold turn: a
5101
+ // change there re-reads the whole request at the uncached price.
5037
5102
  const warm =
5038
- input.client === 'channel'
5103
+ input.client === 'channel' && input.coldTurn
5039
5104
  ? fetcher.readCached(input.roomId)
5040
5105
  : undefined;
5041
5106
  keepGathering();
@@ -7136,13 +7201,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7136
7201
  sessionId: string,
7137
7202
  ): Promise<SessionContextStatus | null> {
7138
7203
  if (!(await this.sessions!.getSession(sessionId))) return null;
7139
- const tuple = await this.saver!.getTuple({
7140
- configurable: { thread_id: sessionId },
7141
- });
7142
- const channel: unknown = tuple?.checkpoint.channel_values.messages;
7143
- const context: BaseMessage[] = Array.isArray(channel)
7144
- ? channel.filter(isBaseMessage)
7145
- : [];
7204
+ const context = await this.contextMessages(sessionId);
7146
7205
  const thread = await this.saver!.listThreadMessages(sessionId);
7147
7206
  const stats = await this.ctx.storage.get<SessionContextCounters>(
7148
7207
  contextStatsKey(sessionId),
@@ -7162,6 +7221,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7162
7221
  };
7163
7222
  }
7164
7223
 
7224
+ /** The messages the session's model calls start from (a summary, then the kept tail, once condensed). */
7225
+ private async contextMessages(sessionId: string): Promise<BaseMessage[]> {
7226
+ const tuple = await this.saver!.getTuple({
7227
+ configurable: { thread_id: sessionId },
7228
+ });
7229
+ const channel: unknown = tuple?.checkpoint.channel_values.messages;
7230
+ return Array.isArray(channel) ? channel.filter(isBaseMessage) : [];
7231
+ }
7232
+
7165
7233
  /** Fold a guard event into the session's stored counters (ordered, never throws). */
7166
7234
  private recordContextEvent(
7167
7235
  sessionId: string,
@@ -7180,7 +7248,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7180
7248
  switch (event.kind) {
7181
7249
  case 'prune':
7182
7250
  next.prunes += 1;
7183
- if (event.stage !== 'soft') next.hardPrunes += 1;
7251
+ if (event.stage === 'hard' || event.stage === 'overflow')
7252
+ next.hardPrunes += 1;
7184
7253
  next.prunedResults += event.pruned;
7185
7254
  break;
7186
7255
  case 'overflow-retry':
@@ -7241,52 +7310,81 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7241
7310
  // ── turn plumbing ──────────────────────────────────────────────────────
7242
7311
 
7243
7312
  /**
7244
- * Matrix media access for the attachments pipeline, through the gateway.
7245
- * Both transfers stream across the object boundary and are cut off at
7246
- * the attachment cap; an encrypted attachment is decrypted HERE, not in
7247
- * the gateway (its whole-buffer download grew the crypto WASM heap for
7248
- * good, and a hash mismatch on the far side of the RPC would only reach
7249
- * this object as a disconnect).
7250
- *
7251
- * The gateway bounds each transfer to the attachment download deadline,
7252
- * from opening it to the last byte. An `AbortSignal` cannot cross the
7253
- * Durable Object RPC, so the turn's abort acts on this side instead:
7254
- * `readBytesCapped` cancels the stream it reads, and the cancel travels
7255
- * back over the RPC stream to stop the gateway's download.
7313
+ * Matrix media access for the attachments pipeline. The gateway is asked
7314
+ * only for the media event's small JSON (`eventMediaInfo`: it alone holds
7315
+ * the room keys); the bytes come straight from the homeserver on the
7316
+ * plugin device's token (`openMatrixMedia`) and an encrypted attachment
7317
+ * is decrypted HERE, so no media byte crosses the shared gateway object.
7318
+ * Each transfer is bounded by the attachment download deadline (and the
7319
+ * caller's signal), from opening it to the last byte, and cut off at
7320
+ * `maxBytes`.
7256
7321
  */
7257
7322
  private matrixMediaSource(): MatrixMediaSource {
7258
- const deadline = { timeoutMs: DOWNLOAD_TIMEOUT_MS };
7323
+ const bounded = async <T>(
7324
+ signal: AbortSignal | undefined,
7325
+ work: (signal: AbortSignal) => Promise<T>,
7326
+ ): Promise<T> => {
7327
+ const bound = boundedSignal(
7328
+ signal,
7329
+ DOWNLOAD_TIMEOUT_MS,
7330
+ 'Matrix media download',
7331
+ );
7332
+ try {
7333
+ return await untilAborted(work(bound.signal), bound.signal);
7334
+ } finally {
7335
+ bound.release();
7336
+ }
7337
+ };
7259
7338
  return {
7260
- downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE, signal) =>
7261
- readBytesCapped(
7262
- await this.gateway.downloadMxcMediaStream(mxc, deadline),
7263
- maxBytes,
7264
- signal,
7339
+ downloadMxc: (mxc, maxBytes = MAX_FILE_SIZE, signal) =>
7340
+ bounded(signal, async (bound) =>
7341
+ readBytesCapped(
7342
+ await this.openMatrixMedia(mxc, bound),
7343
+ maxBytes,
7344
+ bound,
7345
+ ),
7265
7346
  ),
7266
- downloadEvent: async (
7267
- roomId,
7268
- eventId,
7269
- maxBytes = MAX_FILE_SIZE,
7270
- signal,
7271
- ) => {
7272
- const media = await this.gateway.downloadEventMediaStream(
7273
- roomId,
7274
- eventId,
7275
- deadline,
7276
- );
7277
- if (!media) return null;
7278
- const plain = media.file
7279
- ? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
7280
- : media.stream;
7281
- return {
7282
- bytes: await readBytesCapped(plain, maxBytes, signal),
7283
- ...(media.mimetype ? { mimetype: media.mimetype } : {}),
7284
- ...(media.filename ? { filename: media.filename } : {}),
7285
- };
7286
- },
7347
+ downloadEvent: (roomId, eventId, maxBytes = MAX_FILE_SIZE, signal) =>
7348
+ bounded(signal, async (bound) => {
7349
+ const info = await this.gateway.eventMediaInfo(roomId, eventId, {
7350
+ timeoutMs: DOWNLOAD_TIMEOUT_MS,
7351
+ });
7352
+ if (!info) return null;
7353
+ const stream = await this.openMatrixMedia(info.mxc, bound);
7354
+ const plain = info.file
7355
+ ? stream.pipeThrough(createAttachmentDecryptor(info.file))
7356
+ : stream;
7357
+ return {
7358
+ bytes: await readBytesCapped(plain, maxBytes, bound),
7359
+ ...(info.mimetype ? { mimetype: info.mimetype } : {}),
7360
+ ...(info.filename ? { filename: info.filename } : {}),
7361
+ };
7362
+ }),
7287
7363
  };
7288
7364
  }
7289
7365
 
7366
+ /**
7367
+ * The media behind `mxc`, opened on the homeserver with the plugin
7368
+ * device's token; a 401 asks the gateway for fresh credentials
7369
+ * (`botCredentials({ rejected })`) and tries once more.
7370
+ */
7371
+ private async openMatrixMedia(
7372
+ mxc: string,
7373
+ signal: AbortSignal,
7374
+ ): Promise<ReadableStream<Uint8Array>> {
7375
+ const device = await untilAborted(this.pluginDevice(), signal);
7376
+ try {
7377
+ return await openHomeserverMedia(mxc, device, { signal });
7378
+ } catch (error) {
7379
+ if (!(error instanceof HomeserverUnauthorizedError)) throw error;
7380
+ const fresh = await untilAborted(
7381
+ this.pluginDevice({ rejected: device.accessToken }),
7382
+ signal,
7383
+ );
7384
+ return openHomeserverMedia(mxc, fresh, { signal });
7385
+ }
7386
+ }
7387
+
7290
7388
  /**
7291
7389
  * Session-scoped attachment access for the `view_attachment` tool: a
7292
7390
  * reference is resolved against the attachments recorded on this
@@ -7314,6 +7412,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7314
7412
  findPersistedAttachmentMeta(saver, input.sessionId, ref);
7315
7413
  const viewDeps = (): ViewAttachmentDeps => ({
7316
7414
  source: this.matrixMediaSource(),
7415
+ ...(this.env.IMAGES ? { images: this.env.IMAGES } : {}),
7317
7416
  channel: turnChannelMediaSource({
7318
7417
  sessionId: input.sessionId,
7319
7418
  turnLinks: input.channelLinks,
@@ -7339,45 +7438,76 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7339
7438
  }
7340
7439
 
7341
7440
  /**
7342
- * Attachment payload retention (`src/attachments/retention.ts`), run
7343
- * once a turn has completed and its checkpoints are written: inline
7344
- * payloads older than the newest `ATTACHMENT_PAYLOAD_TURNS` user turns
7345
- * are rewritten in place in the messages table — the rows the next turn
7346
- * loads, so the model sees the placeholder and the bytes leave the
7347
- * database. Rows the graph state no longer references (summarised away)
7348
- * are older than any retained turn and are rewritten too. Idempotent:
7349
- * a rewritten row holds no inline block and is never selected again.
7441
+ * Image fetch for the attachment recall middleware
7442
+ * (`core/middlewares/attachment-recall.ts`): one image of this session,
7443
+ * read through the same sources as the turn pipeline (Matrix media from
7444
+ * the homeserver, the session's channel links or their room copies,
7445
+ * http(s)), cut off at the middleware's `maxBytes` (null then), refused
7446
+ * unless the bytes are an image, downscaled for the model when the
7447
+ * oracle binds Cloudflare Images, as base64. An image the resize step
7448
+ * drops resolves null: its original never goes to the model.
7350
7449
  */
7351
- private async retainAttachmentPayloads(
7352
- sessionId: string,
7353
- stateMessages: BaseMessage[],
7354
- ): Promise<void> {
7355
- // Nothing captured (an interrupted stream): never offload blindly.
7356
- if (stateMessages.length === 0) return;
7357
- const saver = this.saver!;
7358
- const { retainedIds, rewrites } = applyAttachmentRetention(
7359
- stateMessages,
7360
- ATTACHMENT_PAYLOAD_TURNS,
7361
- );
7362
- const pending = new Set(rewrites.map((m) => m.id));
7363
- const stale = await saver.listThreadMessagesMatching(sessionId, {
7364
- messageType: 'human',
7365
- contentContains: INLINE_PAYLOAD_NEEDLE,
7366
- });
7367
- for (const row of stale) {
7368
- if (!row.id || retainedIds.has(row.id) || pending.has(row.id)) continue;
7369
- const rewritten = offloadInlinePayloads(row);
7370
- if (rewritten) rewrites.push(rewritten);
7371
- }
7372
- let replaced = 0;
7373
- for (const message of rewrites) {
7374
- if (await saver.replaceThreadMessage(sessionId, message)) replaced += 1;
7375
- }
7376
- if (replaced > 0) {
7377
- console.log(
7378
- `[user-do] attachments: offloaded the inline payload of ${replaced} message(s) in ${sessionId}`,
7379
- );
7380
- }
7450
+ private recallImageLoader(input: {
7451
+ sessionId: string;
7452
+ roomId: string | undefined;
7453
+ /** The links of this turn's channel files (earlier ones come from the store). */
7454
+ channelLinks: ReturnType<typeof channelTurnLinks>;
7455
+ }): RecallImageLoader {
7456
+ return async (meta, { signal, maxBytes }) => {
7457
+ const attachment: AttachmentInput = {
7458
+ filename: meta.filename,
7459
+ mimetype: meta.mimetype,
7460
+ ...(meta.size !== undefined ? { size: meta.size } : {}),
7461
+ ...(meta.mxcUri ? { mxcUri: meta.mxcUri } : {}),
7462
+ ...(meta.eventId ? { eventId: meta.eventId } : {}),
7463
+ ...(meta.channelRef ? { channelRef: meta.channelRef } : {}),
7464
+ };
7465
+ const name = sanitizeAttachmentFilename(attachment.filename);
7466
+ const channel = turnChannelMediaSource({
7467
+ sessionId: input.sessionId,
7468
+ turnLinks: input.channelLinks,
7469
+ store: this.channelAttachmentLinks,
7470
+ channelServiceDid: this.env.CHANNEL_SERVICE_DID,
7471
+ });
7472
+ let loaded: Awaited<ReturnType<typeof loadAttachmentBytes>>;
7473
+ try {
7474
+ loaded = await loadAttachmentBytes(
7475
+ attachment,
7476
+ input.roomId,
7477
+ this.matrixMediaSource(),
7478
+ { signal, maxBytes, channel },
7479
+ );
7480
+ } catch (error) {
7481
+ if (!(error instanceof AttachmentTooLargeError)) throw error;
7482
+ console.warn(
7483
+ `[attachments] recall: "${name}" is over the ${maxBytes}-byte recall limit`,
7484
+ );
7485
+ return null;
7486
+ }
7487
+ const { bytes, mimetype, sniffed } = loaded;
7488
+ // Bytes that are not an image never go back as an image block.
7489
+ const kind = classifyAttachment({
7490
+ mimetype,
7491
+ filename: sniffed ? '' : attachment.filename,
7492
+ });
7493
+ if (kind !== 'image')
7494
+ throw new Error(`content is ${mimetype}, not an image`);
7495
+ const sent = await resizeForModel(bytes, mimetype, this.env.IMAGES, {
7496
+ logger: console,
7497
+ });
7498
+ if (sent.dropped) {
7499
+ console.warn(
7500
+ `[attachments] recall: "${name}" not re-attached: ${sent.dropped}`,
7501
+ );
7502
+ return null;
7503
+ }
7504
+ return {
7505
+ kind: 'image',
7506
+ mimeType: sent.mimetype,
7507
+ base64: bytesToBase64(sent.bytes),
7508
+ filename: name,
7509
+ };
7510
+ };
7381
7511
  }
7382
7512
 
7383
7513
  /**
@@ -7618,6 +7748,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7618
7748
  configurable: { thread_id: req.sessionId },
7619
7749
  });
7620
7750
  const priorState = existing?.checkpoint.channel_values ?? {};
7751
+ // A new thread, or quiet for longer than the provider's cache lives:
7752
+ // this turn pays a miss whatever it sends, so it is the one to change
7753
+ // the prefix on.
7754
+ const coldTurn =
7755
+ !existing ||
7756
+ turnAt.getTime() - Date.parse(existing.checkpoint.ts) >=
7757
+ contextBudget.cacheColdMs;
7621
7758
  // What the memory engine knows about the user, for the prompt's
7622
7759
  // context block: started now so the round-trip overlaps the rest of
7623
7760
  // the build, settled right before the agent is built.
@@ -7631,6 +7768,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7631
7768
  prior: priorState.userContext,
7632
7769
  client: req.client,
7633
7770
  roomKind: req.roomKind,
7771
+ coldTurn,
7634
7772
  });
7635
7773
 
7636
7774
  // A Matrix turn with no usable delegation: ask the web app to open its
@@ -7665,7 +7803,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7665
7803
  // A channel turn's files are read by their `channel:` reference; their
7666
7804
  // links stay on the server (the run request and the session's link
7667
7805
  // store), out of every message and note.
7668
- const attachmentInputs = turnAttachmentInputs(req, body.attachments);
7669
7806
  const attachmentModel =
7670
7807
  effectiveModel && isAllowedModel(effectiveModel)
7671
7808
  ? effectiveModel
@@ -7681,6 +7818,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7681
7818
  : undefined;
7682
7819
  if (turnLinks.length && !run.resumed)
7683
7820
  await this.channelAttachmentLinks?.put(req.sessionId, turnLinks);
7821
+ // A channel turn's files are also posted to the session thread as
7822
+ // encrypted media events (attachments/room-media.ts), in the
7823
+ // background: the run names each by its `channel:` reference, and the
7824
+ // copies read on the way reach the pipeline instead of a second
7825
+ // download. A resumed attempt posts nothing.
7826
+ const threadCopies =
7827
+ turnLinks.length && sessionRoomId
7828
+ ? this.postChannelFilesToThread(
7829
+ req,
7830
+ sessionRoomId,
7831
+ abortController.signal,
7832
+ run.resumed,
7833
+ )
7834
+ : null;
7835
+ const attachmentInputs = turnAttachmentInputs(req, body.attachments);
7684
7836
  const { prepared, metas: turnMetas } = await prepareTurnAttachments({
7685
7837
  inputs: attachmentInputs,
7686
7838
  resumed: run.resumed,
@@ -7696,22 +7848,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7696
7848
  {
7697
7849
  source: this.matrixMediaSource(),
7698
7850
  ...(channelMedia ? { channel: channelMedia } : {}),
7851
+ ...(threadCopies ? { preloaded: threadCopies } : {}),
7699
7852
  extraction: this.extractionProvider(baseAmbient.llm, core.llm),
7700
7853
  sandbox: await this.sandboxArchiveConfig(req.identity.userDid),
7854
+ ...(this.env.IMAGES ? { images: this.env.IMAGES } : {}),
7701
7855
  signal: abortController.signal,
7702
7856
  logger: console,
7703
7857
  },
7704
7858
  ),
7705
7859
  });
7860
+ // Copies the pipeline did not take (a file it refused) go now, not
7861
+ // with the turn.
7862
+ threadCopies?.clear();
7706
7863
  if (prepared) {
7707
7864
  console.log(
7708
7865
  `[user-do] attachments: ${attachmentInputs.length} attached, ${prepared.extracted.length} extracted, ${Array.isArray(prepared.content) ? prepared.content.length - 1 : 0} native`,
7709
7866
  );
7710
7867
  }
7711
7868
 
7712
- // `view_attachment` for this session: payloads older than the newest
7713
- // `ATTACHMENT_PAYLOAD_TURNS` user turns are placeholders by now (see
7714
- // `retainAttachmentPayloads`); the tool fetches one again on demand.
7869
+ // `view_attachment` for this session: attachment bytes are never
7870
+ // stored with the conversation (the checkpointer keeps a placeholder,
7871
+ // `src/sqlite/inline-payloads.ts`); the tool fetches one again on
7872
+ // demand.
7715
7873
  // Cleanups registered by tools for THIS turn (MCP clients …): run once
7716
7874
  // the turn ends, however it ends — see `RuntimeContext.onTurnEnd`.
7717
7875
  abortController.signal.addEventListener('abort', () => {
@@ -7739,11 +7897,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7739
7897
  suppliedContextOnly ? {} : priorState,
7740
7898
  );
7741
7899
 
7742
- // Capability router: predict the on-demand plugin this message needs
7743
- // and preload it for THIS turn only. `on` is awaited here, ahead of the
7744
- // first model call; `shadow` returns at once and logs its verdict when
7745
- // it lands. The preload never enters `stateInput` — the checkpointed
7746
- // `loadedPlugins` channel is written by `load_capability` alone.
7747
7900
  const priorLoaded: ReadonlySet<string> = new Set(
7748
7901
  priorMeta.loadedPlugins ?? [],
7749
7902
  );
@@ -7762,45 +7915,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7762
7915
  client: req.client,
7763
7916
  env: langsmithEnvFromWorkerEnv(this.env),
7764
7917
  });
7765
- const preloadedPlugins =
7766
- !suppliedContextOnly && this.capabilityRouter
7767
- ? await this.capabilityRouter({
7768
- mode: capabilityRouterMode(core.validatedEnv.CAPABILITY_ROUTER),
7769
- manifests: core.registries.manifests.collect(),
7770
- loaded: priorLoaded,
7771
- hidden: () =>
7772
- bootHiddenPlugins({
7773
- registries: core.registries,
7774
- buildCtx: core.buildCtx(),
7775
- has: (resource, action) =>
7776
- ambient.ucan.hasCapability(
7777
- ucanDelegation,
7778
- resource,
7779
- action,
7918
+ // Capability router: from what is left to load this turn (the build
7919
+ // works that out from the turn's tools), the plugins this message needs.
7920
+ // `on` is awaited in the build, ahead of the first model call, and the
7921
+ // plugins load on the thread like any other; `shadow` returns at once
7922
+ // and logs its verdict when it lands. A resumed attempt keeps what the
7923
+ // run started with.
7924
+ const router = this.capabilityRouter;
7925
+ const routeCapabilities =
7926
+ !suppliedContextOnly && !run.resumed && router
7927
+ ? (candidates: readonly RegisteredManifest[]) =>
7928
+ router({
7929
+ mode: capabilityRouterMode(core.validatedEnv.CAPABILITY_ROUTER),
7930
+ candidates,
7931
+ ...preloadThreshold(coldTurn, core.validatedEnv),
7932
+ text: body.message,
7933
+ recentTurns: async () =>
7934
+ recentTurnTexts(
7935
+ await saver.listRecentThreadMessages(
7936
+ req.sessionId,
7937
+ ROUTER_RECENT_ROWS,
7780
7938
  ),
7781
- }),
7782
- hasCapability: (resource, action) =>
7783
- ambient.ucan.hasCapability(ucanDelegation, resource, action),
7784
- text: body.message,
7785
- requestId: req.requestId,
7786
- signal: abortController.signal,
7787
- trace: {
7788
- ...(tracing.callbacks && { callbacks: tracing.callbacks }),
7789
- // `thread_id` matches the graph run's, so LangSmith's thread
7790
- // view groups the router span with the turn.
7791
- metadata: {
7792
- ...tracing.metadata,
7793
- thread_id: req.sessionId,
7794
- request_id: req.requestId,
7939
+ ROUTER_RECENT_TURNS,
7940
+ ROUTER_RECENT_TURN_CHARS,
7941
+ ),
7942
+ requestId: req.requestId,
7943
+ signal: abortController.signal,
7944
+ trace: {
7945
+ ...(tracing.callbacks && { callbacks: tracing.callbacks }),
7946
+ // `thread_id` matches the graph run's, so LangSmith's thread
7947
+ // view groups the router span with the turn.
7948
+ metadata: {
7949
+ ...tracing.metadata,
7950
+ thread_id: req.sessionId,
7951
+ request_id: req.requestId,
7952
+ },
7795
7953
  },
7796
- },
7797
- onShadowVerdict: (wouldPreload) =>
7798
- this.shadowRoutes.set(req.sessionId, {
7799
- requestId: req.requestId,
7800
- priorLoaded,
7801
- wouldPreload,
7802
- }),
7803
- })
7954
+ onShadowVerdict: (wouldPreload) =>
7955
+ this.shadowRoutes.set(req.sessionId, {
7956
+ requestId: req.requestId,
7957
+ priorLoaded,
7958
+ wouldPreload,
7959
+ }),
7960
+ })
7804
7961
  : undefined;
7805
7962
 
7806
7963
  // Host page-context / safety-guardrail hooks, resolved against this
@@ -7971,13 +8128,44 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7971
8128
  )
7972
8129
  : undefined;
7973
8130
 
8131
+ // On a cold turn the thread's history shows which capabilities it has
8132
+ // moved on from. The messages are read only then, and only for a
8133
+ // thread with something to unload; the editor stays while its context
8134
+ // is active, and a resumed attempt keeps what the run started with.
8135
+ const unloadable = (priorMeta.loadedPlugins ?? []).filter(
8136
+ (plugin) =>
8137
+ plugin !== EDITOR_PLUGIN_NAME ||
8138
+ !editorContextActive(meta, priorMeta),
8139
+ );
8140
+ const capabilityExpiry =
8141
+ coldTurn &&
8142
+ !run.resumed &&
8143
+ contextBudget.pluginIdleTurns > 0 &&
8144
+ unloadable.length > 0
8145
+ ? {
8146
+ loaded: unloadable,
8147
+ maxIdleTurns: contextBudget.pluginIdleTurns,
8148
+ messages: await this.contextMessages(req.sessionId),
8149
+ }
8150
+ : undefined;
8151
+
7974
8152
  const built = await createMainAgent({
7975
8153
  executionProfile: req.executionProfile,
8154
+ ...(capabilityExpiry ? { capabilityExpiry } : {}),
7976
8155
  registries: core.registries,
7977
8156
  identity: core.identity,
7978
8157
  config: core.validatedEnv,
7979
8158
  availablePlugins: core.availablePlugins,
7980
- preloadedPlugins,
8159
+ ...(routeCapabilities ? { routeCapabilities } : {}),
8160
+ promptCache: {
8161
+ cold: coldTurn && !run.resumed,
8162
+ ...(priorMeta.promptGuides
8163
+ ? { promptGuides: priorMeta.promptGuides }
8164
+ : {}),
8165
+ ...(priorMeta.capabilityUsage
8166
+ ? { capabilityUsage: priorMeta.capabilityUsage }
8167
+ : {}),
8168
+ },
7981
8169
  byoProvider: byoTurn?.provider,
7982
8170
  contextBudget,
7983
8171
  turnBudget: budget,
@@ -8038,6 +8226,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8038
8226
  }
8039
8227
  : {}),
8040
8228
  ...(hostSafetyModel ? { safetyModel: hostSafetyModel(ambient) } : {}),
8229
+ // The images of the newest user turns, back in each model request
8230
+ // (their bytes are not stored with the conversation).
8231
+ ...(suppliedContextOnly
8232
+ ? {}
8233
+ : {
8234
+ attachmentRecall: {
8235
+ loadImage: this.recallImageLoader({
8236
+ sessionId: req.sessionId,
8237
+ roomId: sessionRoomId,
8238
+ channelLinks: turnLinks,
8239
+ }),
8240
+ imageInput: getModelCapabilities(attachmentModel).image,
8241
+ signal: abortController.signal,
8242
+ cache: this.recallCache,
8243
+ },
8244
+ }),
8041
8245
  },
8042
8246
  requestCtx: {
8043
8247
  user: {
@@ -8085,7 +8289,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8085
8289
  checkpointer: saver,
8086
8290
  abortSignal: abortController.signal,
8087
8291
  });
8088
- const { agent, context } = built;
8292
+ const { agent, context, guideNote } = built;
8089
8293
  for (const [name, effect] of built.toolEffects)
8090
8294
  toolEffects.set(name, effect);
8091
8295
  for (const [name, surface] of built.toolSurfaces)
@@ -8138,9 +8342,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8138
8342
  const referenced = replyNote
8139
8343
  ? withTurnTimeNote(noticed, replyNote)
8140
8344
  : noticed;
8141
- const content = timeNote
8142
- ? withTurnTimeNote(referenced, timeNote)
8345
+ const guided = guideNote
8346
+ ? withTurnTimeNote(referenced, guideNote.text)
8143
8347
  : referenced;
8348
+ const content = timeNote ? withTurnTimeNote(guided, timeNote) : guided;
8144
8349
  const human = new HumanMessage({
8145
8350
  content,
8146
8351
  additional_kwargs: {
@@ -8150,6 +8355,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8150
8355
  ...(noticeNote
8151
8356
  ? { [CHANNEL_DELIVERY_NOTICE_KWARG]: noticeNote }
8152
8357
  : {}),
8358
+ ...(guideNote
8359
+ ? {
8360
+ [TURN_GUIDE_NOTE_KWARG]: guideNote.text,
8361
+ [USAGE_CARRIED_KWARG]: guideNote.plugins,
8362
+ }
8363
+ : {}),
8153
8364
  oracleName: core.identity.name,
8154
8365
  msgFromMatrixRoom: req.client === 'matrix',
8155
8366
  ...speakerKwargs,
@@ -8180,6 +8391,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8180
8391
  // every slot present; later turns write nothing and keep it.
8181
8392
  ...(userContext ? { userContext } : {}),
8182
8393
  ...metadataGraphInput(meta, priorMeta),
8394
+ ...built.stateUpdate,
8183
8395
  ...surface.input,
8184
8396
  };
8185
8397
  const config = {
@@ -8241,6 +8453,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8241
8453
  text: string,
8242
8454
  who: 'user' | 'oracle',
8243
8455
  receipt = false,
8456
+ ): Promise<void> {
8457
+ const queued = this.queueReplay(req, text, who, receipt);
8458
+ // The turn's file events follow the person's text on the chain.
8459
+ if (who === 'user') this.noteChannelTextQueued(req);
8460
+ return queued;
8461
+ }
8462
+
8463
+ private queueReplay(
8464
+ req: TurnRequest,
8465
+ text: string,
8466
+ who: 'user' | 'oracle',
8467
+ receipt: boolean,
8244
8468
  ): Promise<void> {
8245
8469
  if (req.client === 'matrix' && !receipt) return Promise.resolve();
8246
8470
  if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
@@ -8308,7 +8532,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8308
8532
  send.roomId,
8309
8533
  'm.room.message',
8310
8534
  JSON.stringify(mirrorEventContent(send)),
8311
- { txnId: send.txnId },
8535
+ { txnId: send.txnId, priority: 'background' },
8312
8536
  )
8313
8537
  : this.gateway.sendText(send.roomId, send.body, {
8314
8538
  threadId: send.threadId,
@@ -8318,6 +8542,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8318
8542
  priority: 'background',
8319
8543
  txnId: send.txnId,
8320
8544
  }),
8545
+ sendEvent: (roomId, content, txnId) =>
8546
+ this.gateway.sendEvent(roomId, 'm.room.message', content, {
8547
+ txnId,
8548
+ priority: 'background',
8549
+ }),
8321
8550
  keepAlive: (work) => this.ctx.waitUntil(work),
8322
8551
  log: (message) => console.log(message),
8323
8552
  warn: (message) => console.warn(message),
@@ -8325,6 +8554,271 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8325
8554
  return this.mirror;
8326
8555
  }
8327
8556
 
8557
+ /**
8558
+ * The oracle's plugin device (`botCredentials`): what channel files are
8559
+ * uploaded and Matrix media downloaded with. One gateway RPC per
8560
+ * instance, shared by concurrent callers. A caller the homeserver
8561
+ * answered 401 passes the token it used as `rejected`: the cache is
8562
+ * dropped only when it still holds that token, and the gateway
8563
+ * re-validates its device (or logs it in again) once for everyone.
8564
+ */
8565
+ private pluginDevice(
8566
+ opts: { rejected?: string } = {},
8567
+ ): Promise<BotCredentials> {
8568
+ if (
8569
+ opts.rejected &&
8570
+ this.pluginCredentials?.accessToken === opts.rejected
8571
+ )
8572
+ this.pluginCredentials = null;
8573
+ if (this.pluginCredentials)
8574
+ return Promise.resolve(this.pluginCredentials);
8575
+ this.pluginCredentialsLoad ??= this.gateway
8576
+ .botCredentials(opts.rejected ? { rejected: opts.rejected } : {})
8577
+ .then((credentials) => {
8578
+ this.pluginCredentials = credentials;
8579
+ return credentials;
8580
+ })
8581
+ .finally(() => {
8582
+ this.pluginCredentialsLoad = null;
8583
+ });
8584
+ return this.pluginCredentialsLoad;
8585
+ }
8586
+
8587
+ /**
8588
+ * The mirror pass has queued the person's text of channel request
8589
+ * `req` on the session chain (or found none to queue): release the
8590
+ * request's file events waiting for it, or remember it for a turn that
8591
+ * has not asked yet. Only requests with files are tracked; entries older
8592
+ * than the wait are dropped.
8593
+ */
8594
+ private noteChannelTextQueued(req: TurnRequest): void {
8595
+ if (req.client !== 'channel' || !req.channel?.attachments?.length) return;
8596
+ const entry = this.channelTextQueued.get(req.requestId);
8597
+ if (entry?.waiter) {
8598
+ this.channelTextQueued.delete(req.requestId);
8599
+ entry.waiter();
8600
+ return;
8601
+ }
8602
+ const now = Date.now();
8603
+ for (const [requestId, noted] of this.channelTextQueued)
8604
+ if (!noted.waiter && now - noted.at > CHANNEL_MEDIA_TEXT_WAIT_MS)
8605
+ this.channelTextQueued.delete(requestId);
8606
+ this.channelTextQueued.set(req.requestId, { at: now });
8607
+ }
8608
+
8609
+ /**
8610
+ * `queued` resolves once the person's text of `requestId` is queued on
8611
+ * the session chain (true), after `CHANNEL_MEDIA_TEXT_WAIT_MS` (false),
8612
+ * or with null when `cancel` is called first (nothing left to post).
8613
+ */
8614
+ private afterChannelText(requestId: string): {
8615
+ queued: Promise<boolean | null>;
8616
+ cancel: () => void;
8617
+ } {
8618
+ const noted = this.channelTextQueued.get(requestId);
8619
+ if (noted && !noted.waiter) {
8620
+ this.channelTextQueued.delete(requestId);
8621
+ return { queued: Promise.resolve(true), cancel: () => undefined };
8622
+ }
8623
+ let settle: (value: boolean | null) => void = () => undefined;
8624
+ const queued = new Promise<boolean | null>((resolve) => {
8625
+ settle = resolve;
8626
+ });
8627
+ const finish = (value: boolean | null): void => {
8628
+ clearTimeout(timer);
8629
+ if (this.channelTextQueued.get(requestId)?.waiter === waiter)
8630
+ this.channelTextQueued.delete(requestId);
8631
+ settle(value);
8632
+ };
8633
+ const waiter = (): void => finish(true);
8634
+ const timer = setTimeout(() => finish(false), CHANNEL_MEDIA_TEXT_WAIT_MS);
8635
+ this.channelTextQueued.set(requestId, { at: Date.now(), waiter });
8636
+ return { queued, cancel: () => finish(null) };
8637
+ }
8638
+
8639
+ /**
8640
+ * Post a channel turn's files as encrypted media events in the session
8641
+ * thread (`attachments/room-media.ts`), off the turn's critical path.
8642
+ * Returns at once: per file the turn may read directly
8643
+ * (`channelFilesToKeep`), a promise of its verified plaintext, resolved
8644
+ * as soon as the read passes its check, or null when no copy comes (the
8645
+ * pipeline then reads the gateway as before). In the background:
8646
+ *
8647
+ * - the encryption check and the plugin device, bounded with the uploads'
8648
+ * reads by `CHANNEL_MEDIA_TURN_TIMEOUT_MS` from now and by the run's
8649
+ * signal; on a failure (or a room that is not Megolm-encrypted) one
8650
+ * line is logged and nothing is posted;
8651
+ * - the uploads (`startChannelFileUploads`), kept alive by `waitUntil`;
8652
+ * - one event per file on the session's mirror chain, queued in message
8653
+ * order right after the person's text (`afterChannelText`) at
8654
+ * background priority, each within `CHANNEL_MEDIA_SEND_TIMEOUT_MS`;
8655
+ * their event ids then go to the link store (`setEventIds`), whose room
8656
+ * copies serve the files once their links expire.
8657
+ *
8658
+ * A resumed attempt posts nothing: its first attempt did, or lost the
8659
+ * uploads with the object, and the person's message is in the
8660
+ * checkpoint already.
8661
+ */
8662
+ private postChannelFilesToThread(
8663
+ req: TurnRequest,
8664
+ roomId: string,
8665
+ runSignal: AbortSignal,
8666
+ resumed: boolean,
8667
+ ): Map<string, Promise<ThreadFileCopy | null>> {
8668
+ const copies = new Map<string, Promise<ThreadFileCopy | null>>();
8669
+ const files = channelThreadFiles(req);
8670
+ const channel = req.channel;
8671
+ // A channel session is always a thread under its marker event.
8672
+ if (
8673
+ resumed ||
8674
+ !files.length ||
8675
+ !channel ||
8676
+ !req.sessionId.startsWith('$')
8677
+ )
8678
+ return copies;
8679
+ const keep = channelFilesToKeep(files);
8680
+ const pending = new Map<
8681
+ string,
8682
+ (copy: Promise<ThreadFileCopy | null> | null) => void
8683
+ >();
8684
+ for (const ref of keep)
8685
+ copies.set(
8686
+ ref,
8687
+ new Promise((resolve) => {
8688
+ pending.set(ref, resolve);
8689
+ }),
8690
+ );
8691
+ const textOrder = this.afterChannelText(req.requestId);
8692
+ const deadline = Date.now() + CHANNEL_MEDIA_TURN_TIMEOUT_MS;
8693
+
8694
+ const started = (async (): Promise<ChannelFileUpload[]> => {
8695
+ const setup = boundedSignal(
8696
+ runSignal,
8697
+ CHANNEL_MEDIA_TURN_TIMEOUT_MS,
8698
+ 'Preparing the channel file posts',
8699
+ );
8700
+ try {
8701
+ const [encrypted, device] = await untilAborted(
8702
+ Promise.all([this.roomIsEncrypted(roomId), this.pluginDevice()]),
8703
+ setup.signal,
8704
+ );
8705
+ if (!encrypted) {
8706
+ console.warn(
8707
+ `[attachments] channel files of ${req.sessionId} not posted to its thread: the room is not end-to-end encrypted`,
8708
+ );
8709
+ return [];
8710
+ }
8711
+ const run = startChannelFileUploads({
8712
+ files,
8713
+ sessionId: req.sessionId,
8714
+ deps: {
8715
+ origins: channelGatewayOrigins(this.env.CHANNEL_SERVICE_DID),
8716
+ homeserver: {
8717
+ baseUrl: device.baseUrl,
8718
+ accessToken: device.accessToken,
8719
+ },
8720
+ keep,
8721
+ turnTimeoutMs: Math.max(0, deadline - Date.now()),
8722
+ onUnauthorized: (token) => {
8723
+ void this.pluginDevice({ rejected: token }).catch(
8724
+ () => undefined,
8725
+ );
8726
+ },
8727
+ log: console,
8728
+ },
8729
+ });
8730
+ this.ctx.waitUntil(run.done);
8731
+ for (const upload of run.uploads)
8732
+ pending.get(upload.file.ref)?.(upload.copy);
8733
+ return run.uploads;
8734
+ } catch (error) {
8735
+ console.warn(
8736
+ `[attachments] channel files of ${req.sessionId} not posted to its thread; the turn reads them from the channel gateway: ${error instanceof Error ? error.message : String(error)}`,
8737
+ );
8738
+ return [];
8739
+ } finally {
8740
+ setup.release();
8741
+ // A copy not handed over by now never comes (a resolved one stays).
8742
+ for (const resolve of pending.values()) resolve(null);
8743
+ }
8744
+ })();
8745
+
8746
+ const origin = {
8747
+ provider: channel.provider,
8748
+ bindingId: channel.bindingId,
8749
+ remoteMessageRef: channel.remoteMessageRef,
8750
+ };
8751
+ // Nothing to post: the events need not wait for the text.
8752
+ void started.then((uploads) => {
8753
+ if (uploads.length === 0) textOrder.cancel();
8754
+ });
8755
+ const posted = textOrder.queued.then(async (afterText) => {
8756
+ if (afterText === null) return;
8757
+ if (!afterText)
8758
+ console.warn(
8759
+ `[attachments] the text of ${req.requestId} was not queued within ${CHANNEL_MEDIA_TEXT_WAIT_MS} ms; its files are queued without it`,
8760
+ );
8761
+ // Queued synchronously, one after the other: the chain keeps the
8762
+ // message order whichever upload ends first.
8763
+ const sent = await Promise.all(
8764
+ files.map(async (file): Promise<[string, string] | null> => {
8765
+ const bound = boundedSignal(
8766
+ undefined,
8767
+ CHANNEL_MEDIA_SEND_TIMEOUT_MS,
8768
+ 'Posting the channel file',
8769
+ );
8770
+ try {
8771
+ const eventId = await this.roomMirror().sendMedia(
8772
+ req.sessionId,
8773
+ async () => {
8774
+ const upload = (await started).find(
8775
+ (entry) => entry.file.ref === file.ref,
8776
+ );
8777
+ // A failed upload is logged by `startChannelFileUploads`.
8778
+ const media = await upload?.uploaded.catch(() => null);
8779
+ if (!media) return null;
8780
+ return {
8781
+ roomId,
8782
+ threadId: req.sessionId,
8783
+ txnId: await channelMediaTxnId({
8784
+ userDid: req.identity.userDid,
8785
+ bindingId: channel.bindingId,
8786
+ requestId: req.requestId,
8787
+ index: file.index,
8788
+ }),
8789
+ origin,
8790
+ media,
8791
+ };
8792
+ },
8793
+ 'channel file',
8794
+ bound.signal,
8795
+ );
8796
+ return eventId ? [file.ref, eventId] : null;
8797
+ } catch (error) {
8798
+ console.warn(
8799
+ `[attachments] channel file ${file.ref} not posted to thread ${req.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
8800
+ );
8801
+ return null;
8802
+ } finally {
8803
+ bound.release();
8804
+ }
8805
+ }),
8806
+ );
8807
+ const eventIds = new Map(
8808
+ sent.filter((entry): entry is [string, string] => entry !== null),
8809
+ );
8810
+ await this.channelAttachmentLinks?.setEventIds(req.sessionId, eventIds);
8811
+ });
8812
+ this.ctx.waitUntil(
8813
+ posted.catch((error: unknown) => {
8814
+ console.warn(
8815
+ `[attachments] channel file posts of ${req.sessionId} failed: ${error instanceof Error ? error.message : String(error)}`,
8816
+ );
8817
+ }),
8818
+ );
8819
+ return copies;
8820
+ }
8821
+
8328
8822
  private async afterTurn(
8329
8823
  sessionId: string,
8330
8824
  messages: BaseMessage[],
@@ -8333,13 +8827,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
8333
8827
  ): Promise<void> {
8334
8828
  const sessions = this.sessions!;
8335
8829
  await sessions.touchSession(sessionId);
8336
- await this.retainAttachmentPayloads(sessionId, messages).catch(
8337
- (err: unknown) => {
8338
- console.warn(
8339
- `[user-do] attachment payload retention failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
8340
- );
8341
- },
8342
- );
8343
8830
  if (disposition.kind === 'agent')
8344
8831
  await this.compareShadowRoute(sessionId);
8345
8832
  const row = await sessions.getSession(sessionId);