@ixo/oracle-runtime-workers 0.25.0 → 0.27.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 (36) hide show
  1. package/package.json +3 -2
  2. package/src/attachments/extract.ts +108 -14
  3. package/src/attachments/index.ts +15 -0
  4. package/src/attachments/pdf-text.ts +99 -0
  5. package/src/attachments/pipeline.ts +47 -4
  6. package/src/attachments/sanitize.ts +14 -0
  7. package/src/attachments/view-cache.ts +95 -0
  8. package/src/attachments/view.ts +308 -3
  9. package/src/channels/auth.ts +2 -1
  10. package/src/channels/contract.ts +104 -0
  11. package/src/channels/replies.ts +58 -0
  12. package/src/channels/routing.ts +222 -0
  13. package/src/channels/turns.ts +1252 -16
  14. package/src/core/env.ts +17 -0
  15. package/src/do/contracts.ts +19 -0
  16. package/src/do/room-mirror.ts +8 -1
  17. package/src/do/run-coordinator.ts +27 -0
  18. package/src/do/transcript.ts +14 -11
  19. package/src/do/turn-messages.ts +54 -0
  20. package/src/do/user-oracle-do.ts +321 -135
  21. package/src/memory/session-history-indexer.ts +442 -103
  22. package/src/memory/user-context-fetcher.ts +13 -0
  23. package/src/memory/user-context-invalidation.ts +8 -2
  24. package/src/plugins/action-proposals/evidence.ts +7 -8
  25. package/src/plugins/memory/document-chunker.ts +313 -0
  26. package/src/plugins/memory/document-source.ts +229 -0
  27. package/src/plugins/memory/memory-engine-client.ts +352 -0
  28. package/src/plugins/memory/memory.plugin.ts +25 -2
  29. package/src/plugins/memory/oracle-document-tools.ts +677 -0
  30. package/src/plugins/tasks/middleware.ts +4 -9
  31. package/src/plugins/vfs/index.ts +11 -2
  32. package/src/plugins/vfs/vfs-content.ts +86 -22
  33. package/src/plugins/vfs/vfs.plugin.ts +28 -19
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -1,3 +1,8 @@
1
+ import {
2
+ channelReplyNote,
3
+ CHANNEL_REPLY_NOTE_KWARG,
4
+ } from '../channels/replies';
5
+ import { channelRoutingConfig } from '../channels/routing';
1
6
  import { digestTopicProposalEffect } from '../action-proposals/topic-store';
2
7
  import {
3
8
  chainIdsFromConfig,
@@ -22,7 +27,11 @@ import {
22
27
  type BeginTopicStep,
23
28
  type TopicReceiptReferences,
24
29
  } from '@ixo/ixo-transaction/topic-proposals';
25
- import type { ChannelTurnResponse } from '../channels/contract';
30
+ import type {
31
+ ChannelControlInput,
32
+ ChannelControlOutcome,
33
+ ChannelTurnResponse,
34
+ } from '../channels/contract';
26
35
  import { refreshActionProposal } from '../action-proposals/reconcile';
27
36
  import { assertActionProposalPolicy } from '../action-proposals/policy';
28
37
  import { TopicReadUnavailable } from '../action-proposals/topic-errors';
@@ -111,16 +120,17 @@ import {
111
120
  applyAttachmentRetention,
112
121
  ATTACHMENT_PAYLOAD_TURNS,
113
122
  ATTACHMENT_PLACEHOLDER_PREFIX,
114
- attachmentMetas,
115
- attachmentRef,
123
+ createAttachmentViewSurface,
124
+ findPersistedAttachmentMeta,
125
+ IngestTextLru,
116
126
  INLINE_PAYLOAD_NEEDLE,
117
127
  isAttachmentViewMessage,
118
128
  offloadInlinePayloads,
119
129
  parseAttachmentInputs,
120
130
  prepareAttachments,
121
- viewAttachment,
122
131
  type AttachmentViewSurface,
123
132
  type MatrixMediaSource,
133
+ type ViewAttachmentDeps,
124
134
  type SandboxUploadConfig,
125
135
  DOWNLOAD_TIMEOUT_MS,
126
136
  MAX_FILE_SIZE,
@@ -199,6 +209,7 @@ import {
199
209
  } from '../sqlite/cache-config';
200
210
  import { DoSqliteDatabase, type DoSqliteOpenOptions } from '../sqlite/database';
201
211
  import type { TierFlushResult } from '../sqlite/do-vfs';
212
+ import type { AttachmentMeta } from '../sqlite/serialization';
202
213
  import {
203
214
  DEFAULT_TIER_HOT_BUDGET_BYTES,
204
215
  type PageTierOptions,
@@ -272,6 +283,7 @@ import { decideBootDirty } from './boot-dirty';
272
283
  import { decideFlush } from './flush-schedule';
273
284
  import { mirrorTxnId, mirrorEventContent, RoomMirror } from './room-mirror';
274
285
  import { prefixSpeaker } from './speaker-prefix';
286
+ import { assembleTurnMessages } from './turn-messages';
275
287
  import {
276
288
  type ObservedMessage,
277
289
  summarizeObservedMessages,
@@ -291,8 +303,12 @@ import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
291
303
  import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
292
304
  import { SqliteUserKv } from '../sqlite/user-kv-store';
293
305
  import {
306
+ channelHistoryIndexDebounceMs,
307
+ pageOfMessages,
294
308
  SessionHistoryIndexer,
309
+ type HistoryEntry,
295
310
  type HistoryMessage,
311
+ type HistoryPage,
296
312
  type IndexableSession,
297
313
  } from '../memory/session-history-indexer';
298
314
  import {
@@ -1040,6 +1056,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1040
1056
  private lastActionPruneAt: number | null = null;
1041
1057
  /** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
1042
1058
  private attachmentTextCache: AttachmentTextCacheStore | null = null;
1059
+ /** Large texts of document ingestion, in memory (a few, minutes). */
1060
+ private readonly ingestTextLru = new IngestTextLru();
1043
1061
  /**
1044
1062
  * Per-model context windows; learned limits persist in this object's KV
1045
1063
  * storage (`ctxwin:<model>`), so a provider's rejection is remembered
@@ -2032,6 +2050,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2032
2050
  beforeCreate: () => this.markRunsMaybeActive(),
2033
2051
  });
2034
2052
 
2053
+ const channelRouting = channelRoutingConfig.parse(this.core.validatedEnv);
2035
2054
  this.channelTurns = new ChannelTurns(liveDb, {
2036
2055
  createSession: async (identity, markerTxnId) => {
2037
2056
  const roomId = await this.resolveMainRoomId(identity.userDid);
@@ -2086,6 +2105,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2086
2105
  );
2087
2106
  }
2088
2107
  },
2108
+ stopRuns: (runIds) => this.runs!.abortByIds(runIds),
2109
+ describeSessions: async (sessionIds) => {
2110
+ const found = await this.sessions!.summaries(sessionIds);
2111
+ return new Map(
2112
+ [...found].map(([sessionId, session]) => [
2113
+ sessionId,
2114
+ {
2115
+ title: session.title,
2116
+ createdAt: Date.parse(session.createdAt) || 0,
2117
+ updatedAt: Date.parse(session.lastUpdatedAt) || 0,
2118
+ },
2119
+ ]),
2120
+ );
2121
+ },
2122
+ renameSession: (sessionId, title) =>
2123
+ this.sessions!.setTitle(sessionId, title),
2089
2124
  getRun: (runId) => this.runStore!.get(runId),
2090
2125
  getPlan: (runId) => this.runStore!.getPlan(runId),
2091
2126
  wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
@@ -2118,6 +2153,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2118
2153
  keepAlive: (work) => this.ctx.waitUntil(work),
2119
2154
  pruneTombstones: (before, limit) =>
2120
2155
  this.runStore!.pruneChannelTombstones(before, limit),
2156
+ routing: {
2157
+ mode: channelRouting.CHANNEL_SEMANTIC_ROUTING,
2158
+ bindings: channelRouting.CHANNEL_SEMANTIC_ROUTING_BINDINGS.split(',')
2159
+ .map((binding) => binding.trim())
2160
+ .filter(Boolean),
2161
+ minProbability:
2162
+ channelRouting.CHANNEL_SEMANTIC_ROUTING_MIN_PROBABILITY,
2163
+ evaluator: this.core.decisions,
2164
+ ownsSession: async (sessionId) => {
2165
+ const session = await this.sessions!.getSession(sessionId);
2166
+ const roomId = await this.resolveMainRoomId(userDid);
2167
+ return Boolean(
2168
+ sessionId.startsWith('$') && roomId && session?.roomId === roomId,
2169
+ );
2170
+ },
2171
+ observe: (observation) =>
2172
+ console.log(`[channel-routing] ${JSON.stringify(observation)}`),
2173
+ },
2121
2174
  });
2122
2175
 
2123
2176
  const core = this.core;
@@ -3068,54 +3121,87 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3068
3121
  return toSummary(row);
3069
3122
  }
3070
3123
 
3124
+ private async readyChannel(identity: TurnIdentity): Promise<void> {
3125
+ if (identity.ucanDelegation)
3126
+ throw new ChannelError(
3127
+ 403,
3128
+ 'Channel grants cannot become tool authority',
3129
+ 'channel_grant_not_tool_authority',
3130
+ );
3131
+ // The shell (the only caller) checked the binding just before.
3132
+ // The 900 s margin is checked by admission, for a NEW request only
3133
+ // (and again as its run starts): a poll of a run that exists
3134
+ // answers its status whatever the delegation's state. Before a
3135
+ // COLD object boots, though, a delegation must be live (present,
3136
+ // unexpired): the boot may load the owner copy under it, and a user
3137
+ // without one must get `delegation_required`, not a boot failure.
3138
+ // Hydration reads the stored copy, then the deposited room state
3139
+ // (re-read when the stored copy is inside the margin).
3140
+ if (!this.db) {
3141
+ const live = (): boolean => {
3142
+ const held = this.delegations.get(identity.userDid);
3143
+ return (
3144
+ Boolean(held?.raw) &&
3145
+ (typeof held?.expiration !== 'number' ||
3146
+ held.expiration > Math.floor(Date.now() / 1000))
3147
+ );
3148
+ };
3149
+ if (!live())
3150
+ await this.hydrateDelegation(identity.userDid, {
3151
+ minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
3152
+ });
3153
+ if (!live()) throw delegationRequiredError();
3154
+ }
3155
+ try {
3156
+ await this.ready(identity);
3157
+ } catch (error) {
3158
+ // A delegation that lacks the file-storage grant over
3159
+ // `/.oracles` cannot load the owner copy: the user must
3160
+ // authorize again, exactly as without a delegation. (Boot throws
3161
+ // the failure as its RPC envelope, so it is parsed, not matched
3162
+ // by class.)
3163
+ if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
3164
+ throw delegationRequiredError();
3165
+ throw error;
3166
+ }
3167
+ }
3168
+
3169
+ async channelControl(
3170
+ identity: TurnIdentity,
3171
+ input: ChannelControlInput,
3172
+ requestHash: string,
3173
+ ): Promise<ChannelControlOutcome> {
3174
+ try {
3175
+ await this.readyChannel(identity);
3176
+ return {
3177
+ ok: true,
3178
+ result: await this.channelTurns!.control(
3179
+ identity,
3180
+ input,
3181
+ requestHash,
3182
+ ),
3183
+ };
3184
+ } catch (error) {
3185
+ const refusal =
3186
+ error instanceof ChannelError ? error : classifyChannelFailure(error);
3187
+ if (refusal)
3188
+ return {
3189
+ ok: false,
3190
+ status: refusal.status,
3191
+ code: refusal.code,
3192
+ message: refusal.message,
3193
+ };
3194
+ throw error;
3195
+ }
3196
+ }
3197
+
3071
3198
  async channelTurn(
3072
3199
  identity: TurnIdentity,
3073
3200
  input: ChannelTurnInput,
3074
3201
  requestHash: string,
3075
3202
  ): Promise<ChannelTurnOutcome> {
3076
3203
  try {
3077
- if (identity.ucanDelegation)
3078
- throw new ChannelError(
3079
- 403,
3080
- 'Channel grants cannot become tool authority',
3081
- 'channel_grant_not_tool_authority',
3082
- );
3083
- // The shell (the only caller) checked the binding just before.
3084
- // The 900 s margin is checked by admission, for a NEW request only
3085
- // (and again as its run starts): a poll of a run that exists
3086
- // answers its status whatever the delegation's state. Before a
3087
- // COLD object boots, though, a delegation must be live (present,
3088
- // unexpired): the boot may load the owner copy under it, and a user
3089
- // without one must get `delegation_required`, not a boot failure.
3090
- // Hydration reads the stored copy, then the deposited room state
3091
- // (re-read when the stored copy is inside the margin).
3092
- if (!this.db) {
3093
- const live = (): boolean => {
3094
- const held = this.delegations.get(identity.userDid);
3095
- return (
3096
- Boolean(held?.raw) &&
3097
- (typeof held?.expiration !== 'number' ||
3098
- held.expiration > Math.floor(Date.now() / 1000))
3099
- );
3100
- };
3101
- if (!live())
3102
- await this.hydrateDelegation(identity.userDid, {
3103
- minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
3104
- });
3105
- if (!live()) throw delegationRequiredError();
3106
- }
3107
- try {
3108
- await this.ready(identity);
3109
- } catch (error) {
3110
- // A delegation that lacks the file-storage grant over
3111
- // `/.oracles` cannot load the owner copy: the user must
3112
- // authorize again, exactly as without a delegation. (Boot throws
3113
- // the failure as its RPC envelope, so it is parsed, not matched
3114
- // by class.)
3115
- if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
3116
- throw delegationRequiredError();
3117
- throw error;
3118
- }
3204
+ await this.readyChannel(identity);
3119
3205
  return {
3120
3206
  ok: true,
3121
3207
  result: await this.withActionOutcomes(
@@ -4507,19 +4593,64 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4507
4593
  .map((m) => ({ type: m.type, content: visibleText(m) }));
4508
4594
  }
4509
4595
 
4596
+ /**
4597
+ * The indexer's tail read: transcript rows after the message `after`
4598
+ * names (its id, re-resolved per read because rowids move when a
4599
+ * checkpoint rewrites the thread), paged so only the unindexed tail is
4600
+ * deserialized. The same messages as `historyMessages`, in its order;
4601
+ * null when `after` is no longer in the thread.
4602
+ */
4603
+ private async historyPage(
4604
+ sessionId: string,
4605
+ after: string | null,
4606
+ limit: number,
4607
+ ): Promise<HistoryPage | null> {
4608
+ const saver = this.saver!;
4609
+ let anchor = null;
4610
+ if (after !== null) {
4611
+ anchor = await saver.findThreadMessageAnchor(sessionId, after);
4612
+ if (!anchor) return null;
4613
+ }
4614
+ const pageRows = Math.max(1, limit);
4615
+ const entries: HistoryEntry[] = [];
4616
+ for (;;) {
4617
+ const rows = await saver.listThreadMessageRows(sessionId, {
4618
+ direction: 'newer',
4619
+ anchor,
4620
+ limit: pageRows,
4621
+ });
4622
+ for (const row of rows) {
4623
+ anchor = row.anchor;
4624
+ const m = row.message;
4625
+ if (isSummarizationMessage(m) || isAttachmentViewMessage(m)) continue;
4626
+ entries.push({
4627
+ message: { type: m.type, content: visibleText(m) },
4628
+ cursor: row.messageId,
4629
+ });
4630
+ }
4631
+ if (rows.length < pageRows) return { entries, more: false };
4632
+ if (entries.length >= limit) return { entries, more: true };
4633
+ }
4634
+ }
4635
+
4510
4636
  /**
4511
4637
  * Fire-and-forget session-history indexing (`SessionHistoryIndexer`).
4512
- * The watermark lives in this object's storage (`history:processed:<id>`),
4513
- * seeded from the row's `lastProcessedCount` for files migrated from Node;
4514
- * `touchSession` is not used because it bumps `last_updated_at` and
4515
- * would reorder the session list.
4638
+ * The watermark is the session row's `last_processed_count` (the column
4639
+ * the Node runtime keeps, so a migrated file continues where it left
4640
+ * off); `touchSession` is not used because it bumps `last_updated_at`
4641
+ * and would reorder the session list. `afterQuietMs` defers the pass
4642
+ * until the session has been quiet that long (a finished channel run);
4643
+ * every other trigger indexes at once.
4516
4644
  *
4517
4645
  * Task-run sessions (`task:<id>`) are never indexed: a run's transcript
4518
4646
  * is the task's instructions and its result, not something the user
4519
4647
  * said, and a supplied-context task's source text must not become
4520
4648
  * long-term memory that ordinary, tool-enabled turns can retrieve.
4521
4649
  */
4522
- private scheduleHistoryIndexing(sessionId: string): void {
4650
+ private scheduleHistoryIndexing(
4651
+ sessionId: string,
4652
+ opts: { afterQuietMs?: number } = {},
4653
+ ): void {
4523
4654
  if (sessionId.startsWith(TASK_SESSION_PREFIX)) return;
4524
4655
  const ambient = this.ambient;
4525
4656
  const userDid = this.userDid;
@@ -4547,9 +4678,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4547
4678
  lastProcessedCount: row.lastProcessedCount ?? 0,
4548
4679
  };
4549
4680
  },
4550
- listMessages: async (id) =>
4551
- this.historySnapshots.get(id)?.messages ??
4552
- (await this.historyMessages(id)),
4681
+ readMessages: async (id, after, limit) => {
4682
+ const snapshot = this.historySnapshots.get(id);
4683
+ return snapshot
4684
+ ? pageOfMessages(snapshot.messages, after, limit)
4685
+ : this.historyPage(id, after, limit);
4686
+ },
4553
4687
  setProcessedCount: async (id, count) => {
4554
4688
  if (this.historySnapshots.has(id)) return; // deleted: nothing to advance
4555
4689
  await this.sessions?.setLastProcessedCount(id, count);
@@ -4581,9 +4715,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4581
4715
  logger: console,
4582
4716
  });
4583
4717
  const indexer = this.historyIndexer;
4718
+ // A deleted session's snapshot is its transcript's last chance: it is
4719
+ // indexed even while the session backs off after a failure.
4720
+ const work =
4721
+ opts.afterQuietMs !== undefined
4722
+ ? indexer.processAfterQuiet(sessionId, opts.afterQuietMs)
4723
+ : this.historySnapshots.has(sessionId)
4724
+ ? indexer.process(sessionId, { ignoreBackoff: true })
4725
+ : indexer.process(sessionId);
4584
4726
  this.ctx.waitUntil(
4585
- indexer
4586
- .process(sessionId)
4727
+ work
4587
4728
  .catch((err: unknown) => {
4588
4729
  console.error(
4589
4730
  `[user-do] session-history indexing failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
@@ -4657,11 +4798,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4657
4798
 
4658
4799
  /**
4659
4800
  * Start a turn's user-context prefetch — stale-while-revalidate, as the
4660
- * Node AgentBuilder does it. A thread's context is fixed on first
4661
- * contact: a thread whose checkpoint already holds a gathered context
4662
- * never waits and never takes a newer answer; it only refreshes the
4663
- * room's cache in the background (a no-op while the cache is fresh), so
4664
- * the NEXT session in the room starts from it. First contact — including
4801
+ * Node AgentBuilder does it. A thread whose checkpoint already holds a
4802
+ * gathered context never waits; it refreshes the room's cache in the
4803
+ * background (a no-op while the cache is fresh), so the NEXT session in
4804
+ * the room starts from it. A channel thread also takes the cached answer
4805
+ * when the cache is warm (indexed channel history invalidates it, so a
4806
+ * later turn picks up the refresh); other clients never take a newer
4807
+ * answer (first-contact policy). First contact — including
4665
4808
  * a thread whose earlier first turn got nothing (timeout, failure, skip)
4666
4809
  * — waits for the gather, at most the cap. Returns the step the build
4667
4810
  * awaits right before the agent is built, or null where this turn
@@ -4726,8 +4869,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4726
4869
  }),
4727
4870
  );
4728
4871
  if (hasUserContext(input.prior)) {
4872
+ // A channel session is one long conversation, so it takes a newer
4873
+ // room answer — but only one already in the cache: an upload that
4874
+ // just invalidated the room must not make the next message wait for
4875
+ // a gather. On a miss the thread keeps its context and the
4876
+ // (single-flight) refresh warms the cache for a later turn.
4877
+ const warm =
4878
+ input.client === 'channel'
4879
+ ? fetcher.readCached(input.roomId)
4880
+ : undefined;
4729
4881
  keepGathering();
4730
- return null;
4882
+ if (!warm) return null;
4883
+ const context = withEverySlot(warm.context);
4884
+ return async () => context;
4731
4885
  }
4732
4886
  const pending = fetcher
4733
4887
  .fetch(params)
@@ -6095,6 +6249,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6095
6249
  * input whatever its disposition.
6096
6250
  */
6097
6251
  private runRequestJson(req: TurnRequest, stored: StoredRunRequest): string {
6252
+ if (req.client === 'channel' && req.channel?.routingClarification)
6253
+ return JSON.stringify({
6254
+ ...stored,
6255
+ disposition: {
6256
+ kind: 'direct-read',
6257
+ text: req.channel.routingClarification,
6258
+ title: 'Conversation clarification',
6259
+ messageId: `direct:${req.sessionId}:${req.requestId}:ai`,
6260
+ },
6261
+ });
6098
6262
  const admissible =
6099
6263
  admissionApplies(req) &&
6100
6264
  this.core.plugins.some((plugin) => plugin.getRequestAdmission);
@@ -6117,6 +6281,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6117
6281
  const stored = JSON.parse(live.record.request) as StoredRunRequest;
6118
6282
  const req = stored.turn;
6119
6283
  if (req.client === 'channel') {
6284
+ if (
6285
+ req.channel &&
6286
+ (await this.channelTurns!.isCancelled({
6287
+ bindingId: req.channel.bindingId,
6288
+ requestId: req.requestId,
6289
+ }))
6290
+ )
6291
+ return { status: 'aborted', text: '' };
6120
6292
  await this.ctx.storage.sync();
6121
6293
  // The binding was checked as the request was admitted; a queued or
6122
6294
  // recovered attempt starts later and checks it again.
@@ -6443,6 +6615,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6443
6615
  timestamp: new Date().toISOString(),
6444
6616
  oracleName: this.core.identity.name,
6445
6617
  msgFromMatrixRoom: req.client === 'matrix',
6618
+ ...(req.client === 'channel' && req.channel
6619
+ ? {
6620
+ 'org.ixo.qi.origin': channelOrigin(req.channel),
6621
+ ...(req.channel.replyTo
6622
+ ? { 'org.ixo.qi.reply': req.channel.replyTo }
6623
+ : {}),
6624
+ }
6625
+ : {}),
6446
6626
  ...(req.client === 'matrix'
6447
6627
  ? {
6448
6628
  senderDid: req.identity.userDid,
@@ -6565,7 +6745,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6565
6745
  // into the user's oracle room in the background: never awaited here, so
6566
6746
  // the session's next queued run starts at once. Without the handle (a
6567
6747
  // reset dropped it mid-run) the next poll or alarm mirrors it.
6568
- if (record.client === 'channel') this.channelTurns?.deliverReply();
6748
+ if (record.client === 'channel') {
6749
+ this.channelTurns?.deliverReply();
6750
+ // Indexed once the session has been quiet for a while, not per turn:
6751
+ // each upload invalidates the room's prefetch cache.
6752
+ if (outcome.status === 'finished')
6753
+ this.scheduleHistoryIndexing(record.sessionId, {
6754
+ afterQuietMs: channelHistoryIndexDebounceMs(this.core.validatedEnv),
6755
+ });
6756
+ }
6569
6757
  if (!record.taskRunId || !this.taskScheduler) return;
6570
6758
  if (this.taskScheduler.isRunActive(record.taskRunId)) return;
6571
6759
  if (outcome.status === 'finished')
@@ -6877,54 +7065,34 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6877
7065
  ambientLlm: LlmAdapter;
6878
7066
  platform: OpenRouterLlmAdapter;
6879
7067
  signal: AbortSignal;
7068
+ /** Attachments of the turn now running (not necessarily persisted yet). */
7069
+ turnMetas: readonly AttachmentMeta[];
6880
7070
  }): Promise<AttachmentViewSurface> {
6881
7071
  const saver = this.saver!;
6882
7072
  const offloaded = await saver.hasThreadMessageMatching(input.sessionId, {
6883
7073
  messageType: 'human',
6884
7074
  contentContains: ATTACHMENT_PLACEHOLDER_PREFIX,
6885
7075
  });
6886
- const findMeta = async (ref: string) => {
6887
- // The placeholder quotes the ref, so a cheap pre-filter usually
6888
- // finds the message; a still-inline attachment needs the full scan.
6889
- const candidates = await saver.listThreadMessagesMatching(
6890
- input.sessionId,
6891
- { messageType: 'human', contentContains: ref },
6892
- );
6893
- const scan =
6894
- candidates.length > 0
6895
- ? candidates
6896
- : await saver.listThreadMessages(input.sessionId);
6897
- return scan
6898
- .filter((m) => m.type === 'human')
6899
- .flatMap((m) => attachmentMetas(m))
6900
- .find((meta) => attachmentRef(meta) === ref);
6901
- };
6902
- return {
7076
+ const findMeta = (ref: string) =>
7077
+ findPersistedAttachmentMeta(saver, input.sessionId, ref);
7078
+ const viewDeps = (): ViewAttachmentDeps => ({
7079
+ source: this.matrixMediaSource(),
7080
+ extraction: this.extractionProvider(input.ambientLlm, input.platform),
7081
+ caps: getModelCapabilities(input.model),
7082
+ roomId: input.roomId,
7083
+ signal: input.signal,
7084
+ logger: console,
7085
+ ...(this.attachmentTextCache
7086
+ ? { cache: this.attachmentTextCache.forSession(input.sessionId) }
7087
+ : {}),
7088
+ ingestCache: this.ingestTextLru.forSession(input.sessionId),
7089
+ });
7090
+ return createAttachmentViewSurface({
6903
7091
  offloaded,
6904
- view: async (ref) => {
6905
- const meta = await findMeta(ref);
6906
- if (!meta) {
6907
- throw new Error(
6908
- `no attachment with ref "${ref}" in this conversation`,
6909
- );
6910
- }
6911
- const view = await viewAttachment(meta, {
6912
- source: this.matrixMediaSource(),
6913
- extraction: this.extractionProvider(
6914
- input.ambientLlm,
6915
- input.platform,
6916
- ),
6917
- caps: getModelCapabilities(input.model),
6918
- roomId: input.roomId,
6919
- signal: input.signal,
6920
- logger: console,
6921
- ...(this.attachmentTextCache
6922
- ? { cache: this.attachmentTextCache.forSession(input.sessionId) }
6923
- : {}),
6924
- });
6925
- return { meta, view };
6926
- },
6927
- };
7092
+ turnMetas: input.turnMetas,
7093
+ findPersisted: findMeta,
7094
+ deps: viewDeps,
7095
+ });
6928
7096
  }
6929
7097
 
6930
7098
  /**
@@ -7300,6 +7468,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7300
7468
  ambientLlm: baseAmbient.llm,
7301
7469
  platform: core.llm,
7302
7470
  signal: abortController.signal,
7471
+ turnMetas: prepared?.metas ?? [],
7303
7472
  });
7304
7473
 
7305
7474
  // Request metadata (editor room, space, session run, entity) → state,
@@ -7696,37 +7865,43 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7696
7865
  const timeNote = suppliedContextOnly
7697
7866
  ? undefined
7698
7867
  : renderTurnTimeNote(turnAt, req.identity.timezone);
7699
- const content = timeNote ? withTurnTimeNote(spoken, timeNote) : spoken;
7868
+ const replyNote = channelReplyNote(req.channel?.replyContext);
7869
+ const referenced = replyNote
7870
+ ? withTurnTimeNote(spoken, replyNote)
7871
+ : spoken;
7872
+ const content = timeNote
7873
+ ? withTurnTimeNote(referenced, timeNote)
7874
+ : referenced;
7875
+ const human = new HumanMessage({
7876
+ content,
7877
+ additional_kwargs: {
7878
+ timestamp: turnAt.toISOString(),
7879
+ ...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
7880
+ ...(replyNote ? { [CHANNEL_REPLY_NOTE_KWARG]: replyNote } : {}),
7881
+ oracleName: core.identity.name,
7882
+ msgFromMatrixRoom: req.client === 'matrix',
7883
+ ...speakerKwargs,
7884
+ ...(req.channel
7885
+ ? {
7886
+ 'org.ixo.qi.origin': channelOrigin(req.channel),
7887
+ ...(req.channel.replyTo
7888
+ ? { 'org.ixo.qi.reply': req.channel.replyTo }
7889
+ : {}),
7890
+ }
7891
+ : {}),
7892
+ ...attachmentKwargs,
7893
+ },
7894
+ });
7700
7895
  const stateInput = {
7701
- messages: [
7702
- new HumanMessage({
7703
- content,
7704
- additional_kwargs: {
7705
- timestamp: turnAt.toISOString(),
7706
- ...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
7707
- oracleName: core.identity.name,
7708
- msgFromMatrixRoom: req.client === 'matrix',
7709
- ...speakerKwargs,
7710
- ...(req.channel
7711
- ? { 'org.ixo.qi.origin': channelOrigin(req.channel) }
7712
- : {}),
7713
- ...attachmentKwargs,
7714
- },
7715
- }),
7716
- // Extraction-lane text rides as hidden context messages (the
7717
- // transcript hides AI messages that carry an `attachment`), as on Node.
7718
- ...(prepared?.extracted ?? []).map(
7719
- (item) =>
7720
- new AIMessage({
7721
- content: item.text,
7722
- additional_kwargs: {
7723
- msgFromMatrixRoom: req.client === 'matrix',
7724
- timestamp: new Date().toISOString(),
7725
- attachment: item.meta,
7726
- },
7727
- }),
7728
- ),
7729
- ],
7896
+ // Hidden attachment context (the transcript hides AI messages that
7897
+ // carry an `attachment`): native-lane notes precede the person's
7898
+ // message, extraction-lane text follows it, as on Node.
7899
+ messages: assembleTurnMessages({
7900
+ human,
7901
+ extracted: prepared?.extracted ?? [],
7902
+ nativeNotes: prepared?.nativeNotes ?? [],
7903
+ matrixRoom: req.client === 'matrix',
7904
+ }),
7730
7905
  config: { did: req.identity.userDid },
7731
7906
  client: req.client,
7732
7907
  // First contact's gathered context is checkpointed with the turn,
@@ -7834,7 +8009,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7834
8009
  txnId: req.channel
7835
8010
  ? `channel-${await channelRequestHash(JSON.stringify([req.identity.userDid, req.channel.bindingId, req.requestId, who]))}`
7836
8011
  : mirrorTxnId(req.sessionId, req.requestId, who),
7837
- ...(req.channel ? { origin: req.channel } : {}),
8012
+ ...(req.channel
8013
+ ? {
8014
+ origin: {
8015
+ provider: req.channel.provider,
8016
+ bindingId: req.channel.bindingId,
8017
+ remoteMessageRef: req.channel.remoteMessageRef,
8018
+ ...(who === 'user' && req.channel.replyTo
8019
+ ? { replyTo: req.channel.replyTo }
8020
+ : {}),
8021
+ },
8022
+ }
8023
+ : {}),
7838
8024
  };
7839
8025
  },
7840
8026
  label,