@ixo/oracle-runtime-workers 0.26.0 → 0.28.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/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/channels/auth.ts +2 -1
  4. package/src/channels/contract.ts +104 -0
  5. package/src/channels/replies.ts +58 -0
  6. package/src/channels/routing.ts +222 -0
  7. package/src/channels/turns.ts +1252 -16
  8. package/src/core/domain-context/turn-context.ts +1 -1
  9. package/src/core/env.ts +22 -4
  10. package/src/core/main-agent.ts +31 -5
  11. package/src/core/prompt-composer.ts +14 -0
  12. package/src/core/sub-agent-fallback.ts +3 -2
  13. package/src/core/user-identity.ts +67 -0
  14. package/src/do/contracts.ts +19 -0
  15. package/src/do/room-mirror.ts +8 -1
  16. package/src/do/run-coordinator.ts +27 -0
  17. package/src/do/transcript.ts +14 -11
  18. package/src/do/user-oracle-do.ts +269 -63
  19. package/src/memory/session-history-indexer.ts +442 -103
  20. package/src/memory/user-context-fetcher.ts +13 -0
  21. package/src/memory/user-context-invalidation.ts +2 -2
  22. package/src/plugins/action-proposals/evidence.ts +7 -8
  23. package/src/plugins/hub-actions/hub-actions.plugin.ts +6 -2
  24. package/src/plugins/index.ts +6 -2
  25. package/src/plugins/ixo-chain/blocksync.ts +214 -0
  26. package/src/plugins/ixo-chain/denoms.ts +189 -0
  27. package/src/plugins/ixo-chain/http.ts +388 -0
  28. package/src/plugins/ixo-chain/index.ts +1 -0
  29. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +543 -0
  30. package/src/plugins/ixo-chain/queries.ts +401 -0
  31. package/src/plugins/ixo-chain/rest.ts +79 -0
  32. package/src/plugins/ixo-chain/summaries.ts +799 -0
  33. package/src/plugins/tasks/middleware.ts +4 -9
  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';
@@ -294,8 +303,12 @@ import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
294
303
  import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
295
304
  import { SqliteUserKv } from '../sqlite/user-kv-store';
296
305
  import {
306
+ channelHistoryIndexDebounceMs,
307
+ pageOfMessages,
297
308
  SessionHistoryIndexer,
309
+ type HistoryEntry,
298
310
  type HistoryMessage,
311
+ type HistoryPage,
299
312
  type IndexableSession,
300
313
  } from '../memory/session-history-indexer';
301
314
  import {
@@ -2037,6 +2050,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2037
2050
  beforeCreate: () => this.markRunsMaybeActive(),
2038
2051
  });
2039
2052
 
2053
+ const channelRouting = channelRoutingConfig.parse(this.core.validatedEnv);
2040
2054
  this.channelTurns = new ChannelTurns(liveDb, {
2041
2055
  createSession: async (identity, markerTxnId) => {
2042
2056
  const roomId = await this.resolveMainRoomId(identity.userDid);
@@ -2091,6 +2105,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2091
2105
  );
2092
2106
  }
2093
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),
2094
2124
  getRun: (runId) => this.runStore!.get(runId),
2095
2125
  getPlan: (runId) => this.runStore!.getPlan(runId),
2096
2126
  wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
@@ -2123,6 +2153,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2123
2153
  keepAlive: (work) => this.ctx.waitUntil(work),
2124
2154
  pruneTombstones: (before, limit) =>
2125
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
+ },
2126
2174
  });
2127
2175
 
2128
2176
  const core = this.core;
@@ -3073,54 +3121,87 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3073
3121
  return toSummary(row);
3074
3122
  }
3075
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
+
3076
3198
  async channelTurn(
3077
3199
  identity: TurnIdentity,
3078
3200
  input: ChannelTurnInput,
3079
3201
  requestHash: string,
3080
3202
  ): Promise<ChannelTurnOutcome> {
3081
3203
  try {
3082
- if (identity.ucanDelegation)
3083
- throw new ChannelError(
3084
- 403,
3085
- 'Channel grants cannot become tool authority',
3086
- 'channel_grant_not_tool_authority',
3087
- );
3088
- // The shell (the only caller) checked the binding just before.
3089
- // The 900 s margin is checked by admission, for a NEW request only
3090
- // (and again as its run starts): a poll of a run that exists
3091
- // answers its status whatever the delegation's state. Before a
3092
- // COLD object boots, though, a delegation must be live (present,
3093
- // unexpired): the boot may load the owner copy under it, and a user
3094
- // without one must get `delegation_required`, not a boot failure.
3095
- // Hydration reads the stored copy, then the deposited room state
3096
- // (re-read when the stored copy is inside the margin).
3097
- if (!this.db) {
3098
- const live = (): boolean => {
3099
- const held = this.delegations.get(identity.userDid);
3100
- return (
3101
- Boolean(held?.raw) &&
3102
- (typeof held?.expiration !== 'number' ||
3103
- held.expiration > Math.floor(Date.now() / 1000))
3104
- );
3105
- };
3106
- if (!live())
3107
- await this.hydrateDelegation(identity.userDid, {
3108
- minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
3109
- });
3110
- if (!live()) throw delegationRequiredError();
3111
- }
3112
- try {
3113
- await this.ready(identity);
3114
- } catch (error) {
3115
- // A delegation that lacks the file-storage grant over
3116
- // `/.oracles` cannot load the owner copy: the user must
3117
- // authorize again, exactly as without a delegation. (Boot throws
3118
- // the failure as its RPC envelope, so it is parsed, not matched
3119
- // by class.)
3120
- if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
3121
- throw delegationRequiredError();
3122
- throw error;
3123
- }
3204
+ await this.readyChannel(identity);
3124
3205
  return {
3125
3206
  ok: true,
3126
3207
  result: await this.withActionOutcomes(
@@ -4512,19 +4593,64 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4512
4593
  .map((m) => ({ type: m.type, content: visibleText(m) }));
4513
4594
  }
4514
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
+
4515
4636
  /**
4516
4637
  * Fire-and-forget session-history indexing (`SessionHistoryIndexer`).
4517
- * The watermark lives in this object's storage (`history:processed:<id>`),
4518
- * seeded from the row's `lastProcessedCount` for files migrated from Node;
4519
- * `touchSession` is not used because it bumps `last_updated_at` and
4520
- * 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.
4521
4644
  *
4522
4645
  * Task-run sessions (`task:<id>`) are never indexed: a run's transcript
4523
4646
  * is the task's instructions and its result, not something the user
4524
4647
  * said, and a supplied-context task's source text must not become
4525
4648
  * long-term memory that ordinary, tool-enabled turns can retrieve.
4526
4649
  */
4527
- private scheduleHistoryIndexing(sessionId: string): void {
4650
+ private scheduleHistoryIndexing(
4651
+ sessionId: string,
4652
+ opts: { afterQuietMs?: number } = {},
4653
+ ): void {
4528
4654
  if (sessionId.startsWith(TASK_SESSION_PREFIX)) return;
4529
4655
  const ambient = this.ambient;
4530
4656
  const userDid = this.userDid;
@@ -4552,9 +4678,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4552
4678
  lastProcessedCount: row.lastProcessedCount ?? 0,
4553
4679
  };
4554
4680
  },
4555
- listMessages: async (id) =>
4556
- this.historySnapshots.get(id)?.messages ??
4557
- (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
+ },
4558
4687
  setProcessedCount: async (id, count) => {
4559
4688
  if (this.historySnapshots.has(id)) return; // deleted: nothing to advance
4560
4689
  await this.sessions?.setLastProcessedCount(id, count);
@@ -4586,9 +4715,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4586
4715
  logger: console,
4587
4716
  });
4588
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);
4589
4726
  this.ctx.waitUntil(
4590
- indexer
4591
- .process(sessionId)
4727
+ work
4592
4728
  .catch((err: unknown) => {
4593
4729
  console.error(
4594
4730
  `[user-do] session-history indexing failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
@@ -4662,11 +4798,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4662
4798
 
4663
4799
  /**
4664
4800
  * Start a turn's user-context prefetch — stale-while-revalidate, as the
4665
- * Node AgentBuilder does it. A thread's context is fixed on first
4666
- * contact: a thread whose checkpoint already holds a gathered context
4667
- * never waits and never takes a newer answer; it only refreshes the
4668
- * room's cache in the background (a no-op while the cache is fresh), so
4669
- * 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
4670
4808
  * a thread whose earlier first turn got nothing (timeout, failure, skip)
4671
4809
  * — waits for the gather, at most the cap. Returns the step the build
4672
4810
  * awaits right before the agent is built, or null where this turn
@@ -4731,8 +4869,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4731
4869
  }),
4732
4870
  );
4733
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;
4734
4881
  keepGathering();
4735
- return null;
4882
+ if (!warm) return null;
4883
+ const context = withEverySlot(warm.context);
4884
+ return async () => context;
4736
4885
  }
4737
4886
  const pending = fetcher
4738
4887
  .fetch(params)
@@ -6100,6 +6249,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6100
6249
  * input whatever its disposition.
6101
6250
  */
6102
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
+ });
6103
6262
  const admissible =
6104
6263
  admissionApplies(req) &&
6105
6264
  this.core.plugins.some((plugin) => plugin.getRequestAdmission);
@@ -6122,6 +6281,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6122
6281
  const stored = JSON.parse(live.record.request) as StoredRunRequest;
6123
6282
  const req = stored.turn;
6124
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: '' };
6125
6292
  await this.ctx.storage.sync();
6126
6293
  // The binding was checked as the request was admitted; a queued or
6127
6294
  // recovered attempt starts later and checks it again.
@@ -6448,6 +6615,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6448
6615
  timestamp: new Date().toISOString(),
6449
6616
  oracleName: this.core.identity.name,
6450
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
+ : {}),
6451
6626
  ...(req.client === 'matrix'
6452
6627
  ? {
6453
6628
  senderDid: req.identity.userDid,
@@ -6570,7 +6745,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6570
6745
  // into the user's oracle room in the background: never awaited here, so
6571
6746
  // the session's next queued run starts at once. Without the handle (a
6572
6747
  // reset dropped it mid-run) the next poll or alarm mirrors it.
6573
- 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
+ }
6574
6757
  if (!record.taskRunId || !this.taskScheduler) return;
6575
6758
  if (this.taskScheduler.isRunActive(record.taskRunId)) return;
6576
6759
  if (outcome.status === 'finished')
@@ -7682,17 +7865,29 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7682
7865
  const timeNote = suppliedContextOnly
7683
7866
  ? undefined
7684
7867
  : renderTurnTimeNote(turnAt, req.identity.timezone);
7685
- 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;
7686
7875
  const human = new HumanMessage({
7687
7876
  content,
7688
7877
  additional_kwargs: {
7689
7878
  timestamp: turnAt.toISOString(),
7690
7879
  ...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
7880
+ ...(replyNote ? { [CHANNEL_REPLY_NOTE_KWARG]: replyNote } : {}),
7691
7881
  oracleName: core.identity.name,
7692
7882
  msgFromMatrixRoom: req.client === 'matrix',
7693
7883
  ...speakerKwargs,
7694
7884
  ...(req.channel
7695
- ? { 'org.ixo.qi.origin': channelOrigin(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
+ }
7696
7891
  : {}),
7697
7892
  ...attachmentKwargs,
7698
7893
  },
@@ -7814,7 +8009,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7814
8009
  txnId: req.channel
7815
8010
  ? `channel-${await channelRequestHash(JSON.stringify([req.identity.userDid, req.channel.bindingId, req.requestId, who]))}`
7816
8011
  : mirrorTxnId(req.sessionId, req.requestId, who),
7817
- ...(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
+ : {}),
7818
8024
  };
7819
8025
  },
7820
8026
  label,