@ixo/oracle-runtime-workers 0.30.0 → 0.31.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 (44) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/runtime-context.ts +3 -0
  15. package/src/delivery/plan.ts +3 -1
  16. package/src/do/contracts.ts +3 -0
  17. package/src/do/turn-attachments.ts +103 -0
  18. package/src/do/user-oracle-do.ts +142 -42
  19. package/src/hub-actions/client.ts +87 -7
  20. package/src/hub-actions/hub-text.ts +82 -0
  21. package/src/hub-actions/request.ts +7 -4
  22. package/src/hub-actions/store.ts +5 -1
  23. package/src/hub-actions/transfer.ts +23 -6
  24. package/src/index.ts +1 -0
  25. package/src/plugin-api/index.ts +1 -0
  26. package/src/plugin-api/types.ts +43 -2
  27. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  28. package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
  29. package/src/plugins/ixo-chain/blocksync.ts +6 -2
  30. package/src/plugins/ixo-chain/http.ts +16 -8
  31. package/src/plugins/ixo-chain/rest.ts +7 -1
  32. package/src/plugins/yoma/catalog.ts +780 -0
  33. package/src/plugins/yoma/clef.ts +98 -0
  34. package/src/plugins/yoma/drafts.ts +339 -0
  35. package/src/plugins/yoma/engine.ts +926 -0
  36. package/src/plugins/yoma/index.ts +13 -0
  37. package/src/plugins/yoma/judge.ts +358 -0
  38. package/src/plugins/yoma/permission.ts +62 -0
  39. package/src/plugins/yoma/reads.ts +195 -0
  40. package/src/plugins/yoma/submission.ts +534 -0
  41. package/src/plugins/yoma/text.ts +16 -0
  42. package/src/plugins/yoma/work.ts +190 -0
  43. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  44. package/src/sqlite/serialization.ts +2 -0
@@ -162,6 +162,14 @@ const CHANNEL_TOPIC_LOOKBACK = 5;
162
162
  /** Ids per statement: a Durable Object binds at most 100 parameters. */
163
163
  const CHANNEL_IDS_PER_STATEMENT = 50;
164
164
 
165
+ /**
166
+ * The text the gateway sends for a file with no caption: `[photo]`,
167
+ * `[video]`, `[audio]` or `[document: <name>]`.
168
+ */
169
+ export function isAttachmentPlaceholder(message: string): boolean {
170
+ return /^\[(?:photo|video|audio)\]$|^\[document: [^\n]*\]$/.test(message);
171
+ }
172
+
165
173
  function clipCodePoints(text: string, limit: number): string {
166
174
  const points = Array.from(text);
167
175
  return points.length > limit ? points.slice(0, limit).join('') : text;
@@ -1463,7 +1471,14 @@ export class ChannelTurns {
1463
1471
  assignment.destination = { kind: 'session', sessionId: session };
1464
1472
  } else if (mode === 'on')
1465
1473
  assignment.destination = { kind: 'clarify', text: ambiguousThreadText };
1466
- } else {
1474
+ } else if (
1475
+ input.attachments?.length &&
1476
+ isAttachmentPlaceholder(input.message)
1477
+ )
1478
+ // A file with no caption, only the gateway's placeholder: nothing to
1479
+ // classify, so it stays in the current conversation.
1480
+ assignment.reason = 'attachment-placeholder';
1481
+ else {
1467
1482
  const candidates = await this.routingCandidates(input, baseline);
1468
1483
  if (candidates.length) {
1469
1484
  const evidence = {
@@ -1516,6 +1531,10 @@ export class ChannelTurns {
1516
1531
  } else assignment.reason = 'shadow';
1517
1532
  }
1518
1533
  }
1534
+ // A clarifying question is answered instead of the message, so the files
1535
+ // would be dropped: a turn that carries files stays where it is.
1536
+ if (input.attachments?.length && assignment.destination.kind === 'clarify')
1537
+ assignment.destination = { kind: 'session', sessionId: baseline };
1519
1538
  await this.serialize(async () => {
1520
1539
  await this.assertNotCancelled(input);
1521
1540
  await this.db.run(
@@ -1861,6 +1880,9 @@ export class ChannelTurns {
1861
1880
  bindingId: input.bindingId,
1862
1881
  bindingRevision: input.bindingRevision,
1863
1882
  remoteMessageRef: input.remoteMessageRef,
1883
+ ...(input.attachments?.length
1884
+ ? { attachments: input.attachments }
1885
+ : {}),
1864
1886
  ...(replyContext ? { replyContext } : {}),
1865
1887
  ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1866
1888
  ...(routing?.destination.kind === 'clarify'
@@ -9,6 +9,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
9
9
  import type { BaseCheckpointSaver } from '@langchain/langgraph';
10
10
  import type { ReactAgent } from 'langchain';
11
11
  import { z } from 'zod';
12
+ import { ChannelAttachment } from '../channels/contract';
12
13
  import type {
13
14
  ChatOpenAIFields,
14
15
  MergedConfig,
@@ -48,6 +49,7 @@ export const mainAgentRequestContextSchema = z.object({
48
49
  provider: z.string(),
49
50
  bindingId: z.string(),
50
51
  bindingRevision: z.number().int().positive(),
52
+ attachments: z.array(ChannelAttachment).optional(),
51
53
  })
52
54
  .optional(),
53
55
  wsId: z.string().optional(),
@@ -1,3 +1,4 @@
1
+ import type { ChannelAttachment } from '../channels/contract';
1
2
  import {
2
3
  UNAVAILABLE_DECISION_EVALUATOR,
3
4
  type DecisionEvaluateOptions,
@@ -330,6 +331,8 @@ export interface RuntimeSessionContext {
330
331
  provider: string;
331
332
  bindingId: string;
332
333
  bindingRevision: number;
334
+ /** Files sent with this message (see `RuntimeContext.session.channel`). */
335
+ attachments?: readonly ChannelAttachment[];
333
336
  };
334
337
  roomId?: string;
335
338
  surface?: SessionSurface;
@@ -165,7 +165,9 @@ export function draftReplyPlan(input: {
165
165
  for (const call of step.toolCalls) {
166
166
  if (
167
167
  call.name === 'request_hub_action' ||
168
- call.name === 'request_chain_transaction'
168
+ call.name === 'request_chain_transaction' ||
169
+ // The Yoma plugin's application and claim cards.
170
+ call.name === 'yoma_form'
169
171
  ) {
170
172
  try {
171
173
  const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChannelAttachment,
2
3
  ChannelControlInput,
3
4
  ChannelControlOutcome,
4
5
  ChannelReplyContext,
@@ -400,6 +401,8 @@ export interface TurnRequest {
400
401
  replyTo?: ChannelReplyReference;
401
402
  /** Host-generated direct reply: no agent or tools run for an ambiguous destination. */
402
403
  routingClarification?: string;
404
+ /** Files the person sent with this message, as the gateway serves them. */
405
+ attachments?: ChannelAttachment[];
403
406
  };
404
407
  executionProfile?: TaskExecutionProfile;
405
408
  identity: TurnIdentity;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The attachments of one turn attempt: which files the run reads, how a
3
+ * channel file's `channel:` reference becomes its link (server-side, never
4
+ * through the model), and when nothing is downloaded at all.
5
+ */
6
+ import {
7
+ attachmentMetaFor,
8
+ channelAttachmentRef,
9
+ channelMediaOrigins,
10
+ createChannelMediaSource,
11
+ parseAttachmentInputs,
12
+ type AttachmentInput,
13
+ type ChannelAttachmentLinkStore,
14
+ type ChannelMediaLink,
15
+ type ChannelMediaSource,
16
+ type PreparedAttachments,
17
+ } from '../attachments';
18
+ import { parseChannelServiceDids } from '../channels/auth';
19
+ import {
20
+ channelAttachmentInputs,
21
+ type ChannelAttachment,
22
+ } from '../channels/contract';
23
+ import type { AttachmentMeta } from '../sqlite/serialization';
24
+
25
+ /** The part of a turn request the attachment inputs come from. */
26
+ interface TurnFiles {
27
+ client: string;
28
+ attachments?: unknown;
29
+ channel?: { bindingId: string; attachments?: readonly ChannelAttachment[] };
30
+ }
31
+
32
+ /**
33
+ * What the run reads: a channel turn's files by their `channel:` reference
34
+ * (from the gateway's list on the request), otherwise the request body's
35
+ * attachments as the Portal and Matrix send them. A body can never name a
36
+ * `channel:` reference itself.
37
+ */
38
+ export function turnAttachmentInputs(
39
+ req: TurnFiles,
40
+ bodyAttachments: unknown,
41
+ ): AttachmentInput[] {
42
+ const files = req.client === 'channel' ? req.channel?.attachments : undefined;
43
+ if (files?.length) return channelAttachmentInputs(files);
44
+ return parseAttachmentInputs(bodyAttachments ?? req.attachments);
45
+ }
46
+
47
+ /** A channel turn's files as the links the owner object resolves references to. */
48
+ export function channelTurnLinks(
49
+ req: TurnFiles,
50
+ ): Array<ChannelMediaLink & { ref: string }> {
51
+ if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
52
+ const bindingId = req.channel.bindingId;
53
+ return req.channel.attachments.map((file) => ({
54
+ ref: channelAttachmentRef(file.sha256),
55
+ url: file.url,
56
+ sha256: file.sha256,
57
+ bindingId,
58
+ }));
59
+ }
60
+
61
+ /**
62
+ * The channel source of a turn in `sessionId`: a reference resolves to the
63
+ * turn's own files first, then to the links kept for the session; only the
64
+ * gateways of `CHANNEL_SERVICE_DID` are fetched from.
65
+ */
66
+ export function turnChannelMediaSource(input: {
67
+ sessionId: string;
68
+ turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
69
+ store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
70
+ channelServiceDid: string | undefined;
71
+ }): ChannelMediaSource {
72
+ return createChannelMediaSource({
73
+ origins: channelMediaOrigins(
74
+ parseChannelServiceDids(input.channelServiceDid),
75
+ ),
76
+ resolve: async (ref) => {
77
+ const own = input.turnLinks.find((link) => link.ref === ref);
78
+ if (own) return own;
79
+ return input.store?.get(input.sessionId, ref);
80
+ },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * The turn's prepared attachments, or none: a resumed attempt hands the
86
+ * graph no new input (the person's message, with its files, is already in
87
+ * the checkpoint), so nothing is downloaded again; its metadata is still
88
+ * known for `view_attachment`.
89
+ */
90
+ export async function prepareTurnAttachments(input: {
91
+ inputs: readonly AttachmentInput[];
92
+ resumed: boolean;
93
+ prepare: () => Promise<PreparedAttachments>;
94
+ }): Promise<{ prepared: PreparedAttachments | null; metas: AttachmentMeta[] }> {
95
+ if (input.inputs.length === 0) return { prepared: null, metas: [] };
96
+ if (input.resumed)
97
+ return {
98
+ prepared: null,
99
+ metas: input.inputs.map((attachment) => attachmentMetaFor(attachment)),
100
+ };
101
+ const prepared = await input.prepare();
102
+ return { prepared, metas: prepared.metas };
103
+ }
@@ -46,7 +46,11 @@ import type {
46
46
  ActionProposalSubject,
47
47
  } from '@ixo/ixo-transaction/action-proposals';
48
48
  import { ChannelTurns } from '../channels/turns';
49
- import { HubActionClient } from '../hub-actions/client';
49
+ import {
50
+ HubActionClient,
51
+ type HubOperationStatus,
52
+ type HubReviewStatus,
53
+ } from '../hub-actions/client';
50
54
  import { chainTxOperation, prepareHubAction } from '../hub-actions/request';
51
55
  import {
52
56
  bankReceiptSummary,
@@ -126,6 +130,7 @@ import {
126
130
  offloadInlinePayloads,
127
131
  parseAttachmentInputs,
128
132
  prepareAttachments,
133
+ ChannelAttachmentLinkStore,
129
134
  type AttachmentViewSurface,
130
135
  type MatrixMediaSource,
131
136
  type ViewAttachmentDeps,
@@ -135,6 +140,12 @@ import {
135
140
  readBytesCapped,
136
141
  AttachmentTextCacheStore,
137
142
  } from '../attachments';
143
+ import {
144
+ channelTurnLinks,
145
+ prepareTurnAttachments,
146
+ turnAttachmentInputs,
147
+ turnChannelMediaSource,
148
+ } from './turn-attachments';
138
149
  import type { RuntimeCore } from '../core';
139
150
  import {
140
151
  createLlmAdapter,
@@ -430,6 +441,7 @@ import {
430
441
  } from './ucan-service';
431
442
  import type {
432
443
  ChainTxMessageInput,
444
+ HubActionStatusRead,
433
445
  UcanDelegation,
434
446
  UserContextData,
435
447
  } from '../plugin-api/types';
@@ -564,6 +576,7 @@ function hasUserContext(value: unknown): boolean {
564
576
  function admissionApplies(req: TurnRequest): boolean {
565
577
  return (
566
578
  !req.attachments?.length &&
579
+ !req.channel?.attachments?.length &&
567
580
  !req.taskRunId &&
568
581
  !req.sessionId.startsWith(TASK_SESSION_PREFIX) &&
569
582
  req.roomKind !== 'group'
@@ -1058,6 +1071,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1058
1071
  private lastActionPruneAt: number | null = null;
1059
1072
  /** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
1060
1073
  private attachmentTextCache: AttachmentTextCacheStore | null = null;
1074
+ /** The links behind the `channel:` references of each session's files. */
1075
+ private channelAttachmentLinks: ChannelAttachmentLinkStore | null = null;
1061
1076
  /** Large texts of document ingestion, in memory (a few, minutes). */
1062
1077
  private readonly ingestTextLru = new IngestTextLru();
1063
1078
  /**
@@ -2019,6 +2034,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2019
2034
  });
2020
2035
  await this.resultStore.setup();
2021
2036
  this.attachmentTextCache = new AttachmentTextCacheStore(liveDb, console);
2037
+ this.channelAttachmentLinks = new ChannelAttachmentLinkStore(
2038
+ liveDb,
2039
+ console,
2040
+ );
2022
2041
  // Constructing it touches no SQL. Only an observe turn records (and so
2023
2042
  // creates `domain_context_runs`); a session delete never creates it but
2024
2043
  // still removes rows an earlier observe deployment left behind.
@@ -2273,8 +2292,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2273
2292
  }) => this.requestHubAction(input),
2274
2293
  chainTransaction: (input) =>
2275
2294
  this.chainTransactionHubAction(input),
2276
- status: (ownerDid: string, actionId: string) =>
2277
- this.hubActionStatusForTool(ownerDid, actionId),
2295
+ status: (
2296
+ ownerDid: string,
2297
+ actionId: string,
2298
+ opts?: { timeoutMs?: number },
2299
+ ) => this.hubActionStatusForTool(ownerDid, actionId, opts),
2278
2300
  },
2279
2301
  }
2280
2302
  : {}),
@@ -2366,6 +2388,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2366
2388
  this.lastHubOptions = null;
2367
2389
  this.topicProposals = null;
2368
2390
  this.attachmentTextCache = null;
2391
+ this.channelAttachmentLinks = null;
2369
2392
  this.domainContextStore = null;
2370
2393
  // Its store is bound to the database just dropped: a caller that sees
2371
2394
  // no scheduler boots again instead of reading a closed connection.
@@ -3329,7 +3352,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3329
3352
  // The gateway parses with @ixo/action-protocol 0.4.1, whose bank
3330
3353
  // receipt is exact: the 0.5.0 `denom`/`amountBaseUnits` fields are
3331
3354
  // dropped here (the owner-facing amount line comes from
3332
- // hub_action_status instead), so the emitted shape stays 0.4.1.
3355
+ // hub_action_status instead), so the emitted shape stays 0.4.1. Every
3356
+ // other receipt goes as Auth Hub signed it, a failure's `detail`
3357
+ // included: the gateway confirms an outcome by comparing the whole
3358
+ // receipt with Auth Hub's copy. Only what the model sees drops it.
3333
3359
  const hubActionOutcomes = (
3334
3360
  await this.hubOutcomesForRun(ownerDid, result.sessionId, result.runId)
3335
3361
  ).map((outcome) => ({ ...outcome, receipt: v1Receipt(outcome.receipt) }));
@@ -3613,24 +3639,58 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3613
3639
  }
3614
3640
 
3615
3641
  /**
3616
- * `hub_action_status`: the outcome plus, for a bank send, the
3617
- * owner-facing line (amount from the token stored at preparation).
3642
+ * `ctx.hubActions.status` (and `hub_action_status`): Auth Hub's review
3643
+ * and operation status, the outcome and, for a bank send, the
3644
+ * owner-facing line (amount from the token stored at preparation). An
3645
+ * action this object never prepared is `held: false`, without a Hub
3646
+ * read.
3618
3647
  */
3619
3648
  private async hubActionStatusForTool(
3620
3649
  ownerDid: string,
3621
3650
  actionId: string,
3622
- ): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }> {
3623
- const outcome = await this.statusHubAction(ownerDid, actionId);
3624
- if (!outcome) return { outcome, summary: null };
3625
- const token = await this.hubActionStore!.token(ownerDid, actionId);
3626
- return { outcome, summary: bankReceiptSummary(outcome.receipt, token) };
3651
+ opts: { timeoutMs?: number } = {},
3652
+ ): Promise<HubActionStatusRead> {
3653
+ const read = await this.statusHubAction(
3654
+ ownerDid,
3655
+ actionId,
3656
+ opts.timeoutMs,
3657
+ );
3658
+ if (!read)
3659
+ return {
3660
+ held: false,
3661
+ reviewStatus: null,
3662
+ operationStatus: null,
3663
+ outcome: null,
3664
+ summary: null,
3665
+ };
3666
+ const token = read.outcome
3667
+ ? await this.hubActionStore!.token(ownerDid, actionId)
3668
+ : null;
3669
+ return {
3670
+ held: true,
3671
+ reviewStatus: read.reviewStatus,
3672
+ operationStatus: read.operationStatus,
3673
+ outcome: read.outcome,
3674
+ summary: read.outcome
3675
+ ? bankReceiptSummary(read.outcome.receipt, token)
3676
+ : null,
3677
+ };
3627
3678
  }
3628
3679
 
3680
+ /**
3681
+ * One authenticated status read of a held action (null: not held), its
3682
+ * outcome recorded when new. An outcome for another revision than the
3683
+ * one Auth Hub reports is not taken (`outcome: null`).
3684
+ */
3629
3685
  private async statusHubAction(
3630
3686
  ownerDid: string,
3631
3687
  actionId: string,
3632
3688
  timeoutMs?: number,
3633
- ): Promise<ActionOutcomeV1 | null> {
3689
+ ): Promise<{
3690
+ reviewStatus: HubReviewStatus;
3691
+ operationStatus: HubOperationStatus;
3692
+ outcome: ActionOutcomeV1 | null;
3693
+ } | null> {
3634
3694
  const held = await this.hubActionStore?.get(ownerDid, actionId);
3635
3695
  if (!held || !this.hubActionClient) return null;
3636
3696
  const status = await this.hubActionClient.status(actionId, timeoutMs);
@@ -3639,16 +3699,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3639
3699
  canonicalJson(status.reference) !== canonicalJson(held.reference)
3640
3700
  )
3641
3701
  throw new Error('Hub status does not match the prepared action');
3702
+ const read = {
3703
+ reviewStatus: status.reviewStatus,
3704
+ operationStatus: status.operationStatus,
3705
+ };
3642
3706
  if (status.outcome) {
3643
3707
  if (
3644
3708
  status.revision !== status.outcome.authorizedRevision ||
3645
3709
  status.digest !== status.outcome.authorizedDigest
3646
3710
  )
3647
- return null;
3711
+ return { ...read, outcome: null };
3648
3712
  if (await this.hubActionStore!.recordOutcome(status.outcome))
3649
3713
  this.markDirty();
3650
3714
  }
3651
- return status.outcome;
3715
+ return { ...read, outcome: status.outcome };
3652
3716
  }
3653
3717
 
3654
3718
  /**
@@ -3687,11 +3751,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3687
3751
  // refreshed writes it, at most one per poll.
3688
3752
  this.markDirty();
3689
3753
  try {
3690
- const fresh = await this.statusHubAction(
3691
- ownerDid,
3692
- row.reference.id,
3693
- HUB_POLL_TIMEOUT_MS,
3694
- );
3754
+ const fresh = (
3755
+ await this.statusHubAction(
3756
+ ownerDid,
3757
+ row.reference.id,
3758
+ HUB_POLL_TIMEOUT_MS,
3759
+ )
3760
+ )?.outcome;
3695
3761
  if (fresh && (!latest || fresh.sequence > latest.sequence))
3696
3762
  latest = fresh;
3697
3763
  } catch (error) {
@@ -3848,6 +3914,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3848
3914
  `[user-do] could not delete the cached attachment text of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
3849
3915
  );
3850
3916
  });
3917
+ await this.channelAttachmentLinks
3918
+ ?.forgetSession(sessionId)
3919
+ .catch((err: unknown) => {
3920
+ console.warn(
3921
+ `[user-do] could not delete the channel file links of ${sessionId}; they expire with the links: ${err instanceof Error ? err.message : String(err)}`,
3922
+ );
3923
+ });
3851
3924
  await this.resultStore
3852
3925
  ?.deleteForSession(sessionId)
3853
3926
  .catch((err: unknown) => {
@@ -7038,6 +7111,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7038
7111
  signal: AbortSignal;
7039
7112
  /** Attachments of the turn now running (not necessarily persisted yet). */
7040
7113
  turnMetas: readonly AttachmentMeta[];
7114
+ /** The links of this turn's channel files (earlier ones come from the store). */
7115
+ channelLinks: ReturnType<typeof channelTurnLinks>;
7041
7116
  }): Promise<AttachmentViewSurface> {
7042
7117
  const saver = this.saver!;
7043
7118
  const offloaded = await saver.hasThreadMessageMatching(input.sessionId, {
@@ -7048,6 +7123,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7048
7123
  findPersistedAttachmentMeta(saver, input.sessionId, ref);
7049
7124
  const viewDeps = (): ViewAttachmentDeps => ({
7050
7125
  source: this.matrixMediaSource(),
7126
+ channel: turnChannelMediaSource({
7127
+ sessionId: input.sessionId,
7128
+ turnLinks: input.channelLinks,
7129
+ store: this.channelAttachmentLinks,
7130
+ channelServiceDid: this.env.CHANNEL_SERVICE_DID,
7131
+ }),
7051
7132
  extraction: this.extractionProvider(input.ambientLlm, input.platform),
7052
7133
  caps: getModelCapabilities(input.model),
7053
7134
  roomId: input.roomId,
@@ -7390,32 +7471,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7390
7471
  // native capabilities, send images/files inline when it reads them,
7391
7472
  // otherwise the `vision` role turns them into text on the platform key;
7392
7473
  // originals are archived to the user's sandbox when one is reachable.
7393
- const attachmentInputs = parseAttachmentInputs(
7394
- body.attachments ?? req.attachments,
7395
- );
7474
+ // A channel turn's files are read by their `channel:` reference; their
7475
+ // links stay on the server (the run request and the session's link
7476
+ // store), out of every message and note.
7477
+ const attachmentInputs = turnAttachmentInputs(req, body.attachments);
7396
7478
  const attachmentModel =
7397
7479
  effectiveModel && isAllowedModel(effectiveModel)
7398
7480
  ? effectiveModel
7399
7481
  : getDefaultModelId(core.validatedEnv);
7400
- const prepared =
7401
- attachmentInputs.length > 0
7402
- ? await prepareAttachments(
7403
- {
7404
- text: body.message,
7405
- attachments: attachmentInputs,
7406
- roomId: sessionRoomId,
7407
- model: attachmentModel,
7408
- caps: getModelCapabilities(attachmentModel),
7409
- },
7410
- {
7411
- source: this.matrixMediaSource(),
7412
- extraction: this.extractionProvider(baseAmbient.llm, core.llm),
7413
- sandbox: await this.sandboxArchiveConfig(req.identity.userDid),
7414
- signal: abortController.signal,
7415
- logger: console,
7416
- },
7417
- )
7418
- : null;
7482
+ const turnLinks = channelTurnLinks(req);
7483
+ const channelMedia = turnLinks.length
7484
+ ? turnChannelMediaSource({
7485
+ sessionId: req.sessionId,
7486
+ turnLinks,
7487
+ store: this.channelAttachmentLinks,
7488
+ channelServiceDid: this.env.CHANNEL_SERVICE_DID,
7489
+ })
7490
+ : undefined;
7491
+ if (turnLinks.length && !run.resumed)
7492
+ await this.channelAttachmentLinks?.put(req.sessionId, turnLinks);
7493
+ const { prepared, metas: turnMetas } = await prepareTurnAttachments({
7494
+ inputs: attachmentInputs,
7495
+ resumed: run.resumed,
7496
+ prepare: async () =>
7497
+ prepareAttachments(
7498
+ {
7499
+ text: body.message,
7500
+ attachments: attachmentInputs,
7501
+ roomId: sessionRoomId,
7502
+ model: attachmentModel,
7503
+ caps: getModelCapabilities(attachmentModel),
7504
+ },
7505
+ {
7506
+ source: this.matrixMediaSource(),
7507
+ ...(channelMedia ? { channel: channelMedia } : {}),
7508
+ extraction: this.extractionProvider(baseAmbient.llm, core.llm),
7509
+ sandbox: await this.sandboxArchiveConfig(req.identity.userDid),
7510
+ signal: abortController.signal,
7511
+ logger: console,
7512
+ },
7513
+ ),
7514
+ });
7419
7515
  if (prepared) {
7420
7516
  console.log(
7421
7517
  `[user-do] attachments: ${attachmentInputs.length} attached, ${prepared.extracted.length} extracted, ${Array.isArray(prepared.content) ? prepared.content.length - 1 : 0} native`,
@@ -7439,7 +7535,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7439
7535
  ambientLlm: baseAmbient.llm,
7440
7536
  platform: core.llm,
7441
7537
  signal: abortController.signal,
7442
- turnMetas: prepared?.metas ?? [],
7538
+ turnMetas,
7539
+ channelLinks: turnLinks,
7443
7540
  });
7444
7541
 
7445
7542
  // Request metadata (editor room, space, session run, entity) → state,
@@ -7773,6 +7870,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
7773
7870
  provider: req.channel.provider,
7774
7871
  bindingId: req.channel.bindingId,
7775
7872
  bindingRevision: req.channel.bindingRevision,
7873
+ ...(req.channel.attachments?.length
7874
+ ? { attachments: req.channel.attachments }
7875
+ : {}),
7776
7876
  },
7777
7877
  }
7778
7878
  : {}),