@ixo/oracle-runtime-workers 0.37.2 → 0.38.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.
- package/package.json +3 -3
- package/src/attachments/channel-links.ts +74 -7
- package/src/attachments/channel.ts +15 -4
- package/src/attachments/content-blocks.ts +19 -16
- package/src/attachments/download.ts +173 -50
- package/src/attachments/index.ts +6 -5
- package/src/attachments/pipeline.ts +117 -49
- package/src/attachments/resize.ts +142 -0
- package/src/attachments/retention.ts +26 -70
- package/src/attachments/room-media.ts +643 -0
- package/src/attachments/route.ts +47 -4
- package/src/attachments/sandbox-archive.ts +15 -4
- package/src/attachments/sanitize.ts +24 -13
- package/src/attachments/view.ts +82 -38
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +18 -3
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/do/contracts.ts +51 -23
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +1 -1
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/user-oracle-do.ts +507 -93
- package/src/llm/provider-error.ts +5 -2
- package/src/llm/stream-liveness.ts +3 -2
- package/src/matrix/gateway-do.ts +164 -102
- package/src/matrix/ingest.ts +12 -3
- package/src/matrix/media-deadline.ts +5 -47
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/sqlite-saver.ts +241 -42
package/src/do/room-mirror.ts
CHANGED
|
@@ -19,9 +19,20 @@ import {
|
|
|
19
19
|
* - sends are serialised per session (parallel across sessions), so a reply
|
|
20
20
|
* being retried can never overtake the message it answers, and a later
|
|
21
21
|
* turn's mirrors queue behind an earlier turn's.
|
|
22
|
+
*
|
|
23
|
+
* The same chain carries the media events of a channel turn's files
|
|
24
|
+
* (`sendMedia`, built by `attachments/room-media.ts`): the owner object
|
|
25
|
+
* queues them in file order right after the person's text, so each is
|
|
26
|
+
* posted after that text and before the reply, whenever its upload ends.
|
|
22
27
|
*/
|
|
28
|
+
import type { EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
|
|
29
|
+
import { abortError, untilAborted } from '../attachments/download';
|
|
23
30
|
import { matrixTxnId } from '../matrix/txn-id';
|
|
24
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
isTransientGatewayError,
|
|
33
|
+
retryGateway,
|
|
34
|
+
type RetryGatewayOptions,
|
|
35
|
+
} from './gateway-retry';
|
|
25
36
|
|
|
26
37
|
export type MirrorAuthor = 'user' | 'oracle';
|
|
27
38
|
|
|
@@ -53,8 +64,43 @@ export interface MirrorSend {
|
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/** The `org.ixo.qi.origin` input of a channel mirror. */
|
|
68
|
+
export type MirrorOrigin = NonNullable<MirrorSend['origin']>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The media part of an `m.room.message` file event (`m.image` / `m.video` /
|
|
72
|
+
* `m.audio` / `m.file`): what the room shows, without the thread relation
|
|
73
|
+
* and the channel origin, which `mediaEventContent` adds.
|
|
74
|
+
*/
|
|
75
|
+
export interface MediaMessage {
|
|
76
|
+
msgtype: 'm.image' | 'm.video' | 'm.audio' | 'm.file';
|
|
77
|
+
body: string;
|
|
78
|
+
filename: string;
|
|
79
|
+
info: { mimetype: string; size: number };
|
|
80
|
+
/**
|
|
81
|
+
* The encrypted attachment: its `mxc://` plus the `EncryptedFile` fields.
|
|
82
|
+
* Always encrypted: a file is only ever posted into a Megolm room.
|
|
83
|
+
*/
|
|
84
|
+
file: { url: string } & EncryptedFileInfo;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MediaMirrorSend {
|
|
88
|
+
roomId: string;
|
|
89
|
+
/** The session's marker event — the thread the file is posted under. */
|
|
90
|
+
threadId: string;
|
|
91
|
+
txnId: string;
|
|
92
|
+
origin?: MirrorOrigin;
|
|
93
|
+
media: MediaMessage;
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
export interface RoomMirrorDeps {
|
|
57
97
|
sendText(send: MirrorSend): Promise<string>;
|
|
98
|
+
/**
|
|
99
|
+
* Send one file event (`content` JSON-encoded) with a pinned transaction
|
|
100
|
+
* id, at background priority (`MatrixGatewayObject.sendEvent`); returns
|
|
101
|
+
* its event id.
|
|
102
|
+
*/
|
|
103
|
+
sendEvent(roomId: string, content: string, txnId: string): Promise<string>;
|
|
58
104
|
/** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
|
|
59
105
|
keepAlive(work: Promise<unknown>): void;
|
|
60
106
|
log(message: string): void;
|
|
@@ -63,6 +109,16 @@ export interface RoomMirrorDeps {
|
|
|
63
109
|
retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
|
|
64
110
|
}
|
|
65
111
|
|
|
112
|
+
/** The relation that posts an event in the thread of `threadId` (with the reply fallback for clients without threads). */
|
|
113
|
+
function threadRelation(threadId: string) {
|
|
114
|
+
return {
|
|
115
|
+
rel_type: 'm.thread',
|
|
116
|
+
event_id: threadId,
|
|
117
|
+
is_falling_back: true,
|
|
118
|
+
'm.in_reply_to': { event_id: threadId },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
66
122
|
export function mirrorEventContent(send: MirrorSend) {
|
|
67
123
|
return {
|
|
68
124
|
msgtype: 'm.text',
|
|
@@ -70,12 +126,7 @@ export function mirrorEventContent(send: MirrorSend) {
|
|
|
70
126
|
...(send.formattedBody
|
|
71
127
|
? { format: 'org.matrix.custom.html', formatted_body: send.formattedBody }
|
|
72
128
|
: {}),
|
|
73
|
-
'm.relates_to':
|
|
74
|
-
rel_type: 'm.thread',
|
|
75
|
-
event_id: send.threadId,
|
|
76
|
-
is_falling_back: true,
|
|
77
|
-
'm.in_reply_to': { event_id: send.threadId },
|
|
78
|
-
},
|
|
129
|
+
'm.relates_to': threadRelation(send.threadId),
|
|
79
130
|
...(send.origin
|
|
80
131
|
? {
|
|
81
132
|
'org.ixo.qi.origin': channelOrigin(send.origin),
|
|
@@ -87,6 +138,19 @@ export function mirrorEventContent(send: MirrorSend) {
|
|
|
87
138
|
};
|
|
88
139
|
}
|
|
89
140
|
|
|
141
|
+
/**
|
|
142
|
+
* A file event in the session thread: the media part, the thread relation
|
|
143
|
+
* of the text mirrors and their `org.ixo.qi.origin` (never the quote, which
|
|
144
|
+
* the text mirror of the same message carries).
|
|
145
|
+
*/
|
|
146
|
+
export function mediaEventContent(send: MediaMirrorSend) {
|
|
147
|
+
return {
|
|
148
|
+
...send.media,
|
|
149
|
+
'm.relates_to': threadRelation(send.threadId),
|
|
150
|
+
...(send.origin ? { 'org.ixo.qi.origin': channelOrigin(send.origin) } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
90
154
|
const errorText = (err: unknown): string =>
|
|
91
155
|
err instanceof Error ? err.message : String(err);
|
|
92
156
|
|
|
@@ -115,38 +179,98 @@ export class RoomMirror {
|
|
|
115
179
|
label: string,
|
|
116
180
|
required = false,
|
|
117
181
|
): Promise<void> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.then((send) => (send ? this.send(send, label) : undefined))
|
|
125
|
-
.catch((err: unknown) => {
|
|
182
|
+
return this.chain(sessionId, async () => {
|
|
183
|
+
try {
|
|
184
|
+
const send = await prepare();
|
|
185
|
+
if (send)
|
|
186
|
+
await this.send(() => this.deps.sendText(send), send.threadId, label);
|
|
187
|
+
} catch (err) {
|
|
126
188
|
if (required) throw err;
|
|
127
189
|
this.deps.warn(
|
|
128
190
|
`[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
|
|
129
191
|
);
|
|
130
|
-
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Queue one file event behind the session's previous mirrors. When its
|
|
198
|
+
* turn in the chain comes, `prepare` builds the event (it waits for the
|
|
199
|
+
* file's upload; `null` = nothing to post) and the send follows with the
|
|
200
|
+
* transient retries; resolves with the event id, or null when nothing was
|
|
201
|
+
* posted. `signal` is honoured throughout: checked before `prepare` and
|
|
202
|
+
* before every send attempt, and it ends the wait on `prepare` — once it
|
|
203
|
+
* aborts no further attempt is made and the call rejects with its reason.
|
|
204
|
+
* A send that fails after the retries rejects.
|
|
205
|
+
*/
|
|
206
|
+
sendMedia(
|
|
207
|
+
sessionId: string,
|
|
208
|
+
prepare: () => Promise<MediaMirrorSend | null>,
|
|
209
|
+
label: string,
|
|
210
|
+
signal?: AbortSignal,
|
|
211
|
+
): Promise<string | null> {
|
|
212
|
+
return this.chain(sessionId, async () => {
|
|
213
|
+
if (signal?.aborted) throw abortError(signal);
|
|
214
|
+
const send = await (signal ? untilAborted(prepare(), signal) : prepare());
|
|
215
|
+
if (!send) return null;
|
|
216
|
+
const content = JSON.stringify(mediaEventContent(send));
|
|
217
|
+
return this.send(
|
|
218
|
+
() => {
|
|
219
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
220
|
+
return this.deps.sendEvent(send.roomId, content, send.txnId);
|
|
221
|
+
},
|
|
222
|
+
send.threadId,
|
|
223
|
+
label,
|
|
224
|
+
signal,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run `work` once the session's previous mirrors have settled either way
|
|
231
|
+
* (a required mirror that failed, and rejected its own caller, must not
|
|
232
|
+
* skip the next one), keep it alive past the request, and drop the chain
|
|
233
|
+
* once it drains.
|
|
234
|
+
*/
|
|
235
|
+
private chain<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
|
|
236
|
+
const previous = this.chains.get(sessionId) ?? Promise.resolve();
|
|
237
|
+
// Settles (as `work` did) once the chain entry is gone, so a caller that
|
|
238
|
+
// awaited it sees the session's chain drained.
|
|
239
|
+
const done = previous
|
|
240
|
+
.catch(() => undefined)
|
|
241
|
+
.then(work)
|
|
131
242
|
.finally(() => {
|
|
132
243
|
if (this.chains.get(sessionId) === tracked)
|
|
133
244
|
this.chains.delete(sessionId);
|
|
134
245
|
});
|
|
246
|
+
const tracked: Promise<void> = done.then(
|
|
247
|
+
() => undefined,
|
|
248
|
+
() => undefined,
|
|
249
|
+
);
|
|
135
250
|
this.chains.set(sessionId, tracked);
|
|
136
251
|
this.deps.keepAlive(tracked);
|
|
137
|
-
return
|
|
252
|
+
return done;
|
|
138
253
|
}
|
|
139
254
|
|
|
140
|
-
private async send(
|
|
141
|
-
|
|
255
|
+
private async send(
|
|
256
|
+
post: () => Promise<string>,
|
|
257
|
+
threadId: string,
|
|
258
|
+
label: string,
|
|
259
|
+
signal?: AbortSignal,
|
|
260
|
+
): Promise<string> {
|
|
261
|
+
const isTransient = this.deps.retry?.isTransient ?? isTransientGatewayError;
|
|
262
|
+
const eventId = await retryGateway(post, {
|
|
142
263
|
...this.deps.retry,
|
|
264
|
+
// An abort is final: the transient check would otherwise retry it.
|
|
265
|
+
isTransient: (err) => !signal?.aborted && isTransient(err),
|
|
143
266
|
onRetry: (err, attempt, delayMs) =>
|
|
144
267
|
this.deps.warn(
|
|
145
268
|
`[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
|
|
146
269
|
),
|
|
147
270
|
});
|
|
148
271
|
this.deps.log(
|
|
149
|
-
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${
|
|
272
|
+
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${threadId}`,
|
|
150
273
|
);
|
|
274
|
+
return eventId;
|
|
151
275
|
}
|
|
152
276
|
}
|
package/src/do/transcript.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function contentToText(content: BaseMessage['content']): string {
|
|
|
111
111
|
if (typeof part === 'string') return part;
|
|
112
112
|
if (part && typeof part === 'object' && 'text' in part) {
|
|
113
113
|
const text = (part as { text?: unknown }).text;
|
|
114
|
-
// The
|
|
114
|
+
// The attachment placeholder is model-facing only; the transcript
|
|
115
115
|
// keeps listing the attachment from its metadata.
|
|
116
116
|
return typeof text === 'string' && !isAttachmentPlaceholderText(text)
|
|
117
117
|
? text
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ChannelMediaSource,
|
|
16
16
|
type PreparedAttachments,
|
|
17
17
|
} from '../attachments';
|
|
18
|
+
import type { ChannelThreadFile } from '../attachments/room-media';
|
|
18
19
|
import { parseChannelServiceDids } from '../channels/auth';
|
|
19
20
|
import {
|
|
20
21
|
channelAttachmentInputs,
|
|
@@ -58,26 +59,64 @@ export function channelTurnLinks(
|
|
|
58
59
|
}));
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A channel turn's files as `room-media.ts` posts them to the session
|
|
64
|
+
* thread: the first occurrence of each file (a file sent twice in one
|
|
65
|
+
* message is posted once), with its position in the message.
|
|
66
|
+
*/
|
|
67
|
+
export function channelThreadFiles(req: TurnFiles): ChannelThreadFile[] {
|
|
68
|
+
if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
|
|
69
|
+
const bindingId = req.channel.bindingId;
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const files: ChannelThreadFile[] = [];
|
|
72
|
+
req.channel.attachments.forEach((file, index) => {
|
|
73
|
+
const ref = channelAttachmentRef(file.sha256);
|
|
74
|
+
if (seen.has(ref)) return;
|
|
75
|
+
seen.add(ref);
|
|
76
|
+
files.push({
|
|
77
|
+
ref,
|
|
78
|
+
index,
|
|
79
|
+
link: { url: file.url, sha256: file.sha256, bindingId },
|
|
80
|
+
filename: file.filename,
|
|
81
|
+
mimetype: file.mimetype,
|
|
82
|
+
size: file.size,
|
|
83
|
+
kind: file.kind,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The https origins channel files are fetched from: the gateways of `CHANNEL_SERVICE_DID`. */
|
|
90
|
+
export function channelGatewayOrigins(
|
|
91
|
+
channelServiceDid: string | undefined,
|
|
92
|
+
): string[] {
|
|
93
|
+
return channelMediaOrigins(parseChannelServiceDids(channelServiceDid));
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
/**
|
|
62
97
|
* The channel source of a turn in `sessionId`: a reference resolves to the
|
|
63
98
|
* turn's own files first, then to the links kept for the session; only the
|
|
64
|
-
* gateways of `CHANNEL_SERVICE_DID` are fetched from.
|
|
99
|
+
* gateways of `CHANNEL_SERVICE_DID` are fetched from. The store also names
|
|
100
|
+
* the room copy of a file posted to the session thread, which serves it
|
|
101
|
+
* when its link fails or has expired (`loadAttachmentBytes`).
|
|
65
102
|
*/
|
|
66
103
|
export function turnChannelMediaSource(input: {
|
|
67
104
|
sessionId: string;
|
|
68
105
|
turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
|
|
69
|
-
store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
|
|
106
|
+
store: Pick<ChannelAttachmentLinkStore, 'get' | 'eventIdOf'> | null;
|
|
70
107
|
channelServiceDid: string | undefined;
|
|
71
108
|
}): ChannelMediaSource {
|
|
109
|
+
const store = input.store;
|
|
72
110
|
return createChannelMediaSource({
|
|
73
|
-
origins:
|
|
74
|
-
parseChannelServiceDids(input.channelServiceDid),
|
|
75
|
-
),
|
|
111
|
+
origins: channelGatewayOrigins(input.channelServiceDid),
|
|
76
112
|
resolve: async (ref) => {
|
|
77
113
|
const own = input.turnLinks.find((link) => link.ref === ref);
|
|
78
114
|
if (own) return own;
|
|
79
|
-
return
|
|
115
|
+
return store?.get(input.sessionId, ref);
|
|
80
116
|
},
|
|
117
|
+
...(store
|
|
118
|
+
? { roomEventOf: (ref: string) => store.eventIdOf(input.sessionId, ref) }
|
|
119
|
+
: {}),
|
|
81
120
|
});
|
|
82
121
|
}
|
|
83
122
|
|