@ixo/oracle-runtime-workers 0.37.2 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- 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/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +53 -4
- package/src/core/main-agent.ts +228 -125
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +64 -24
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +41 -12
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +639 -152
- package/src/index.ts +1 -0
- 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/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +258 -42
- package/src/tasks/schedule-input.ts +5 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"protobufjs": "^7.6.5",
|
|
68
68
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
69
69
|
"survey-core": "2.3.11",
|
|
70
|
-
"@ixo/common": "1.
|
|
71
|
-
"@ixo/
|
|
72
|
-
"@ixo/
|
|
70
|
+
"@ixo/common": "1.8.0",
|
|
71
|
+
"@ixo/ucan": "2.3.0",
|
|
72
|
+
"@ixo/ixo-transaction": "0.2.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
* user's own SQLite file in their own table, apart from the messages: the
|
|
4
4
|
* messages, the model and the transcript carry only the `channel:`
|
|
5
5
|
* reference, and `view_attachment` resolves it here once the turn that
|
|
6
|
-
* brought the file has ended. A
|
|
7
|
-
* gateway signs links for 30 days; `exp` in the link)
|
|
6
|
+
* brought the file has ended. A link is served until it expires (the
|
|
7
|
+
* gateway signs links for 30 days; `exp` in the link); a row goes with its
|
|
8
8
|
* session (`forgetSession`). Failures are logged and treated as a miss: a
|
|
9
9
|
* missing link makes the file unavailable, never the turn.
|
|
10
|
+
*
|
|
11
|
+
* A row also keeps the Matrix event the file was posted as in the session
|
|
12
|
+
* thread (`event_id`, `attachments/room-media.ts`): that room copy serves
|
|
13
|
+
* the file when the link fails or has expired, so a row that carries one
|
|
14
|
+
* outlives its link (the expiry sweep only removes rows without it). A
|
|
15
|
+
* resend of the same file in a later turn refreshes the link and keeps the
|
|
16
|
+
* event until the new post records its own.
|
|
10
17
|
*/
|
|
11
18
|
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
12
19
|
import type { Logger } from '../plugin-api/types';
|
|
@@ -32,7 +39,7 @@ export class ChannelAttachmentLinkStore {
|
|
|
32
39
|
private setupPromise: Promise<void> | undefined;
|
|
33
40
|
|
|
34
41
|
constructor(
|
|
35
|
-
private readonly db: Pick<DoSqliteDatabase, 'run' | 'get'>,
|
|
42
|
+
private readonly db: Pick<DoSqliteDatabase, 'run' | 'get' | 'exec'>,
|
|
36
43
|
private readonly log: Pick<Logger, 'warn'>,
|
|
37
44
|
private readonly now: () => number = Date.now,
|
|
38
45
|
) {}
|
|
@@ -54,16 +61,30 @@ export class ChannelAttachmentLinkStore {
|
|
|
54
61
|
url TEXT NOT NULL,
|
|
55
62
|
sha256 TEXT NOT NULL,
|
|
56
63
|
expires_at INTEGER NOT NULL,
|
|
64
|
+
event_id TEXT,
|
|
57
65
|
UNIQUE (session_id, ref)
|
|
58
66
|
)`,
|
|
59
67
|
);
|
|
68
|
+
// A table created before `event_id` existed (CREATE TABLE IF NOT
|
|
69
|
+
// EXISTS leaves it as it was).
|
|
70
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
71
|
+
'PRAGMA table_info(channel_attachment_links)',
|
|
72
|
+
);
|
|
73
|
+
if (!columns.some((column) => column.name === 'event_id'))
|
|
74
|
+
await this.db.run(
|
|
75
|
+
'ALTER TABLE channel_attachment_links ADD COLUMN event_id TEXT',
|
|
76
|
+
);
|
|
60
77
|
await this.db.run(
|
|
61
78
|
`CREATE INDEX IF NOT EXISTS idx_channel_attachment_links_expiry
|
|
62
79
|
ON channel_attachment_links(expires_at)`,
|
|
63
80
|
);
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Keep the links of a turn's files for its session (a resend refreshes
|
|
85
|
+
* them, keeping a recorded room event), then sweep expired rows that have
|
|
86
|
+
* no room copy.
|
|
87
|
+
*/
|
|
67
88
|
async put(
|
|
68
89
|
sessionId: string,
|
|
69
90
|
links: ReadonlyArray<ChannelMediaLink & { ref: string }>,
|
|
@@ -74,9 +95,12 @@ export class ChannelAttachmentLinkStore {
|
|
|
74
95
|
const now = this.now();
|
|
75
96
|
for (const link of links)
|
|
76
97
|
await this.db.run(
|
|
77
|
-
`INSERT
|
|
98
|
+
`INSERT INTO channel_attachment_links
|
|
78
99
|
(session_id, ref, binding_id, url, sha256, expires_at)
|
|
79
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
101
|
+
ON CONFLICT (session_id, ref) DO UPDATE SET
|
|
102
|
+
binding_id = excluded.binding_id, url = excluded.url,
|
|
103
|
+
sha256 = excluded.sha256, expires_at = excluded.expires_at`,
|
|
80
104
|
[
|
|
81
105
|
sessionId,
|
|
82
106
|
link.ref,
|
|
@@ -87,7 +111,7 @@ export class ChannelAttachmentLinkStore {
|
|
|
87
111
|
],
|
|
88
112
|
);
|
|
89
113
|
await this.db.run(
|
|
90
|
-
'DELETE FROM channel_attachment_links WHERE expires_at <= ?',
|
|
114
|
+
'DELETE FROM channel_attachment_links WHERE expires_at <= ? AND event_id IS NULL',
|
|
91
115
|
[now],
|
|
92
116
|
);
|
|
93
117
|
} catch (error) {
|
|
@@ -124,6 +148,49 @@ export class ChannelAttachmentLinkStore {
|
|
|
124
148
|
}
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Record the session-thread events the turn's files were posted as
|
|
153
|
+
* (reference → event id). A failure is logged: the files are then served
|
|
154
|
+
* by their links alone.
|
|
155
|
+
*/
|
|
156
|
+
async setEventIds(
|
|
157
|
+
sessionId: string,
|
|
158
|
+
eventIds: ReadonlyMap<string, string>,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
if (eventIds.size === 0) return;
|
|
161
|
+
try {
|
|
162
|
+
await this.setup();
|
|
163
|
+
for (const [ref, eventId] of eventIds)
|
|
164
|
+
await this.db.run(
|
|
165
|
+
`UPDATE channel_attachment_links SET event_id = ?
|
|
166
|
+
WHERE session_id = ? AND ref = ?`,
|
|
167
|
+
[eventId, sessionId, ref],
|
|
168
|
+
);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.log.warn(
|
|
171
|
+
`[attachments] could not keep the thread events of ${sessionId}'s channel files: ${error instanceof Error ? error.message : String(error)}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The session-thread event recorded for `ref`, expired link or not. */
|
|
177
|
+
async eventIdOf(sessionId: string, ref: string): Promise<string | undefined> {
|
|
178
|
+
try {
|
|
179
|
+
await this.setup();
|
|
180
|
+
const row = await this.db.get<{ event_id: string | null }>(
|
|
181
|
+
`SELECT event_id FROM channel_attachment_links
|
|
182
|
+
WHERE session_id = ? AND ref = ?`,
|
|
183
|
+
[sessionId, ref],
|
|
184
|
+
);
|
|
185
|
+
return row?.event_id ?? undefined;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.log.warn(
|
|
188
|
+
`[attachments] channel file thread event read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
+
);
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
127
194
|
/** Drop every link of a deleted session. */
|
|
128
195
|
async forgetSession(sessionId: string): Promise<void> {
|
|
129
196
|
await this.setup();
|
|
@@ -27,8 +27,9 @@ export const CHANNEL_REF_PREFIX = 'channel:';
|
|
|
27
27
|
/**
|
|
28
28
|
* The gateway's per-file bound for a video or a document
|
|
29
29
|
* (`maxLargeMediaBytes`, 50 MiB); photos are at most 16 MB there. A file
|
|
30
|
-
* above `MAX_FILE_SIZE` is
|
|
31
|
-
* model
|
|
30
|
+
* above `MAX_FILE_SIZE` is posted to the session thread (`room-media.ts`)
|
|
31
|
+
* but never read by the turn: no model is handed it and the sandbox
|
|
32
|
+
* archive refuses it (see the pipeline).
|
|
32
33
|
*/
|
|
33
34
|
export const CHANNEL_MAX_FILE_BYTES = 52_428_800;
|
|
34
35
|
|
|
@@ -94,7 +95,8 @@ export function isGatewayMediaUrl(
|
|
|
94
95
|
);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
/** Lowercase hex of a digest. */
|
|
99
|
+
export function hex(bytes: ArrayBuffer): string {
|
|
98
100
|
return Array.from(new Uint8Array(bytes), (byte) =>
|
|
99
101
|
byte.toString(16).padStart(2, '0'),
|
|
100
102
|
).join('');
|
|
@@ -163,15 +165,23 @@ export interface ChannelMediaSource {
|
|
|
163
165
|
timeoutMs?: number;
|
|
164
166
|
},
|
|
165
167
|
): Promise<{ bytes: Uint8Array; contentType?: string }>;
|
|
168
|
+
/**
|
|
169
|
+
* The media event the file was posted as in the session thread
|
|
170
|
+
* (`room-media.ts`), when one is recorded: the room copy that serves the
|
|
171
|
+
* file once its link has expired or the gateway no longer has it.
|
|
172
|
+
*/
|
|
173
|
+
roomEventOf?(ref: string): Promise<string | undefined>;
|
|
166
174
|
}
|
|
167
175
|
|
|
168
176
|
/**
|
|
169
177
|
* A `ChannelMediaSource` over `resolve` (the turn's files, then the
|
|
170
|
-
* session's stored links) for the gateways in `origins
|
|
178
|
+
* session's stored links) for the gateways in `origins`; `roomEventOf`
|
|
179
|
+
* names the room copy of a file, when the owner keeps one.
|
|
171
180
|
*/
|
|
172
181
|
export function createChannelMediaSource(input: {
|
|
173
182
|
resolve: (ref: string) => Promise<ChannelMediaLink | undefined>;
|
|
174
183
|
origins: readonly string[];
|
|
184
|
+
roomEventOf?: (ref: string) => Promise<string | undefined>;
|
|
175
185
|
}): ChannelMediaSource {
|
|
176
186
|
return {
|
|
177
187
|
load: async (ref, opts) => {
|
|
@@ -182,6 +192,7 @@ export function createChannelMediaSource(input: {
|
|
|
182
192
|
);
|
|
183
193
|
return downloadChannelFile(link, { ...opts, origins: input.origins });
|
|
184
194
|
},
|
|
195
|
+
...(input.roomEventOf ? { roomEventOf: input.roomEventOf } : {}),
|
|
185
196
|
};
|
|
186
197
|
}
|
|
187
198
|
|
|
@@ -30,22 +30,25 @@ export function buildUserMessageContent(
|
|
|
30
30
|
|
|
31
31
|
const content: MessageContent = [
|
|
32
32
|
{ type: 'text', text },
|
|
33
|
-
...natives.map(
|
|
34
|
-
native.kind === 'image'
|
|
35
|
-
? {
|
|
36
|
-
type: 'image',
|
|
37
|
-
source_type: 'base64',
|
|
38
|
-
mime_type: native.mimeType,
|
|
39
|
-
data: native.base64,
|
|
40
|
-
}
|
|
41
|
-
: {
|
|
42
|
-
type: 'file',
|
|
43
|
-
source_type: 'base64',
|
|
44
|
-
mime_type: native.mimeType,
|
|
45
|
-
data: native.base64,
|
|
46
|
-
filename: native.filename,
|
|
47
|
-
},
|
|
48
|
-
),
|
|
33
|
+
...natives.map(nativeContentBlock),
|
|
49
34
|
];
|
|
50
35
|
return content;
|
|
51
36
|
}
|
|
37
|
+
|
|
38
|
+
/** The content block of one native attachment (the shape described above). */
|
|
39
|
+
export function nativeContentBlock(native: NativeAttachment) {
|
|
40
|
+
return native.kind === 'image'
|
|
41
|
+
? {
|
|
42
|
+
type: 'image',
|
|
43
|
+
source_type: 'base64',
|
|
44
|
+
mime_type: native.mimeType,
|
|
45
|
+
data: native.base64,
|
|
46
|
+
}
|
|
47
|
+
: {
|
|
48
|
+
type: 'file',
|
|
49
|
+
source_type: 'base64',
|
|
50
|
+
mime_type: native.mimeType,
|
|
51
|
+
data: native.base64,
|
|
52
|
+
filename: native.filename,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fetching attachment bytes — Matrix media (
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Fetching attachment bytes — Matrix media (the owner object asks the
|
|
3
|
+
* gateway for the media event's small JSON — it alone holds the room keys —
|
|
4
|
+
* and reads the bytes from the homeserver itself, `openHomeserverMedia`),
|
|
5
|
+
* channel files (through the owner object's `ChannelMediaSource`, see
|
|
6
|
+
* `channel.ts`, with the file's room copy as the fallback) and plain
|
|
7
|
+
* `http(s)` URLs with the Node runtime's SSRF guard, manual redirect
|
|
8
|
+
* validation and streaming size cap.
|
|
7
9
|
*/
|
|
8
10
|
import type { ChannelMediaSource } from './channel';
|
|
9
11
|
import { containerMatchesClaim, detectMimeFromMagicBytes } from './magic';
|
|
@@ -53,7 +55,7 @@ function tooLarge(maxBytes: number): AttachmentTooLargeError {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** The signal's reason as an Error (an aborter may pass any value). */
|
|
56
|
-
function abortError(signal: AbortSignal): Error {
|
|
58
|
+
export function abortError(signal: AbortSignal): Error {
|
|
57
59
|
const reason: unknown = signal.reason;
|
|
58
60
|
if (reason instanceof Error) return reason;
|
|
59
61
|
return new Error(
|
|
@@ -65,7 +67,10 @@ function abortError(signal: AbortSignal): Error {
|
|
|
65
67
|
* `work`, or the signal's reason as soon as it aborts — so a transfer that
|
|
66
68
|
* ignores the signal still cannot hold the caller past it.
|
|
67
69
|
*/
|
|
68
|
-
function untilAborted<T>(
|
|
70
|
+
export function untilAborted<T>(
|
|
71
|
+
work: Promise<T>,
|
|
72
|
+
signal: AbortSignal,
|
|
73
|
+
): Promise<T> {
|
|
69
74
|
if (signal.aborted) {
|
|
70
75
|
work.catch(() => undefined);
|
|
71
76
|
return Promise.reject(abortError(signal));
|
|
@@ -105,8 +110,9 @@ export function validateUrlTarget(targetUrl: string): void {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
/**
|
|
108
|
-
* Matrix media access the pipeline needs. Implemented
|
|
109
|
-
*
|
|
113
|
+
* Matrix media access the pipeline needs. Implemented in the user object
|
|
114
|
+
* (the event's media info from the gateway, the bytes from the homeserver);
|
|
115
|
+
* tests pass an in-memory fake.
|
|
110
116
|
*
|
|
111
117
|
* `signal` aborts at the download deadline or when the turn is aborted: an
|
|
112
118
|
* implementation stops the transfer then (`readBytesCapped` with the signal
|
|
@@ -148,15 +154,69 @@ export interface DownloadOptions {
|
|
|
148
154
|
maxRedirects?: number;
|
|
149
155
|
/** Where a `channelRef` attachment's bytes come from; absent = none can be read. */
|
|
150
156
|
channel?: ChannelMediaSource;
|
|
157
|
+
/**
|
|
158
|
+
* The attachment's bytes as this turn already read them (a channel file
|
|
159
|
+
* read while it was posted to the session thread): used in place of a
|
|
160
|
+
* download, under the same size, emptiness and type checks.
|
|
161
|
+
*/
|
|
162
|
+
preloaded?: { bytes: Uint8Array; contentType?: string };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The homeserver answered 401: the access token it was given is no longer valid. */
|
|
166
|
+
export class HomeserverUnauthorizedError extends Error {
|
|
167
|
+
override readonly name: string = 'HomeserverUnauthorizedError';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `mxc://<server>/<media id>`, the parts checked before they go into a URL. */
|
|
171
|
+
const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Open the media behind `mxc` on the homeserver as the account behind
|
|
175
|
+
* `accessToken`: the authenticated endpoint
|
|
176
|
+
* (`/_matrix/client/v1/media/download`), then the legacy one
|
|
177
|
+
* (`/_matrix/media/v3/download`) when the server answers 404 — the order
|
|
178
|
+
* matrix-js-sdk's `mxcUrlToHttp` and the bot SDK use. The body is returned
|
|
179
|
+
* unread; a 401 is `HomeserverUnauthorizedError`.
|
|
180
|
+
*/
|
|
181
|
+
export async function openHomeserverMedia(
|
|
182
|
+
mxc: string,
|
|
183
|
+
homeserver: { baseUrl: string; accessToken: string },
|
|
184
|
+
opts: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {},
|
|
185
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
186
|
+
const match = MXC_PATTERN.exec(mxc);
|
|
187
|
+
if (!match) throw new Error('Invalid mxc URI');
|
|
188
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
189
|
+
const path = `${match[1]}/${match[2]}?allow_redirect=true`;
|
|
190
|
+
const headers = { authorization: `Bearer ${homeserver.accessToken}` };
|
|
191
|
+
const open = (prefix: string): Promise<Response> =>
|
|
192
|
+
fetchImpl(new URL(`${prefix}/${path}`, homeserver.baseUrl), {
|
|
193
|
+
headers,
|
|
194
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
195
|
+
});
|
|
196
|
+
let res = await open('/_matrix/client/v1/media/download');
|
|
197
|
+
if (res.status === 404) {
|
|
198
|
+
await res.body?.cancel().catch(() => undefined);
|
|
199
|
+
res = await open('/_matrix/media/v3/download');
|
|
200
|
+
}
|
|
201
|
+
if (!res.ok || !res.body) {
|
|
202
|
+
await res.body?.cancel().catch(() => undefined);
|
|
203
|
+
if (res.status === 401)
|
|
204
|
+
throw new HomeserverUnauthorizedError(
|
|
205
|
+
'the homeserver rejected the access token (401)',
|
|
206
|
+
);
|
|
207
|
+
throw new Error(`Matrix media download failed: HTTP ${res.status}`);
|
|
208
|
+
}
|
|
209
|
+
return res.body;
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
/**
|
|
154
213
|
* A controller that aborts on `signal` (with its reason) OR after
|
|
155
|
-
* `timeoutMs` (with a timeout error), whichever comes first.
|
|
214
|
+
* `timeoutMs` (with a timeout error naming `what`), whichever comes first.
|
|
156
215
|
*/
|
|
157
|
-
function boundedSignal(
|
|
216
|
+
export function boundedSignal(
|
|
158
217
|
signal: AbortSignal | undefined,
|
|
159
218
|
timeoutMs: number,
|
|
219
|
+
what = 'Attachment download',
|
|
160
220
|
): { signal: AbortSignal; release: () => void } {
|
|
161
221
|
const controller = new AbortController();
|
|
162
222
|
const onAbort = (): void => {
|
|
@@ -167,9 +227,7 @@ function boundedSignal(
|
|
|
167
227
|
const timer = setTimeout(
|
|
168
228
|
() =>
|
|
169
229
|
controller.abort(
|
|
170
|
-
new Error(
|
|
171
|
-
`Attachment download timed out after ${Math.round(timeoutMs / 1000)} s`,
|
|
172
|
-
),
|
|
230
|
+
new Error(`${what} timed out after ${Math.round(timeoutMs / 1000)} s`),
|
|
173
231
|
),
|
|
174
232
|
timeoutMs,
|
|
175
233
|
);
|
|
@@ -297,11 +355,87 @@ export async function downloadFromUrl(
|
|
|
297
355
|
}
|
|
298
356
|
}
|
|
299
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The Matrix media event `eventId` of `roomId`, under the download deadline
|
|
360
|
+
* (`opts.timeoutMs`, default `DOWNLOAD_TIMEOUT_MS`) and `opts.signal`.
|
|
361
|
+
*/
|
|
362
|
+
async function loadMatrixEvent(
|
|
363
|
+
eventId: string,
|
|
364
|
+
roomId: string | undefined,
|
|
365
|
+
source: MatrixMediaSource,
|
|
366
|
+
maxBytes: number,
|
|
367
|
+
opts: DownloadOptions,
|
|
368
|
+
): Promise<{ bytes: Uint8Array; httpType?: string }> {
|
|
369
|
+
if (!roomId)
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Cannot fetch event ${eventId}: no Matrix room for this turn`,
|
|
372
|
+
);
|
|
373
|
+
const bound = boundedSignal(
|
|
374
|
+
opts.signal,
|
|
375
|
+
opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
|
|
376
|
+
);
|
|
377
|
+
try {
|
|
378
|
+
const media = await untilAborted(
|
|
379
|
+
source.downloadEvent(roomId, eventId, maxBytes, bound.signal),
|
|
380
|
+
bound.signal,
|
|
381
|
+
);
|
|
382
|
+
if (!media) throw new Error(`Matrix event ${eventId} not found`);
|
|
383
|
+
return {
|
|
384
|
+
bytes: media.bytes,
|
|
385
|
+
...(media.mimetype ? { httpType: media.mimetype } : {}),
|
|
386
|
+
};
|
|
387
|
+
} finally {
|
|
388
|
+
bound.release();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A channel file's bytes: from its link, else — when the link is gone or
|
|
394
|
+
* fails for any reason but an abort or the size cap — from the copy posted
|
|
395
|
+
* in the session thread (`postedAs`, else the event the channel source
|
|
396
|
+
* recorded), which outlives the gateway's 30-day link.
|
|
397
|
+
*/
|
|
398
|
+
async function loadChannelFile(
|
|
399
|
+
ref: string,
|
|
400
|
+
postedAs: string | undefined,
|
|
401
|
+
roomId: string | undefined,
|
|
402
|
+
source: MatrixMediaSource,
|
|
403
|
+
maxBytes: number,
|
|
404
|
+
opts: DownloadOptions,
|
|
405
|
+
): Promise<{ bytes: Uint8Array; httpType?: string }> {
|
|
406
|
+
try {
|
|
407
|
+
if (!opts.channel)
|
|
408
|
+
throw new Error('Channel files cannot be read on this host');
|
|
409
|
+
const loaded = await opts.channel.load(ref, {
|
|
410
|
+
maxBytes,
|
|
411
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
412
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
413
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
bytes: loaded.bytes,
|
|
417
|
+
...(loaded.contentType ? { httpType: loaded.contentType } : {}),
|
|
418
|
+
};
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (
|
|
421
|
+
opts.signal?.aborted ||
|
|
422
|
+
error instanceof AttachmentTooLargeError ||
|
|
423
|
+
!roomId
|
|
424
|
+
)
|
|
425
|
+
throw error;
|
|
426
|
+
const eventId = postedAs ?? (await opts.channel?.roomEventOf?.(ref));
|
|
427
|
+
if (!eventId) throw error;
|
|
428
|
+
return loadMatrixEvent(eventId, roomId, source, maxBytes, opts);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
300
432
|
/**
|
|
301
433
|
* Bytes for one attachment from whichever source it names, with the per-file
|
|
302
434
|
* cap (`opts.maxBytes`, default `MAX_FILE_SIZE`) enforced before and during
|
|
303
435
|
* the transfer and the `DOWNLOAD_TIMEOUT_MS` deadline (or `opts.timeoutMs`)
|
|
304
|
-
* on every source — Matrix media included
|
|
436
|
+
* on every source — Matrix media included; `opts.preloaded` bytes are
|
|
437
|
+
* checked the same way without a download. A channel file whose link fails
|
|
438
|
+
* is read from its room copy when one is known (`loadChannelFile`). `mimetype` is the best-known
|
|
305
439
|
* type: content sniffing (`sniffed` is then true; a container signature
|
|
306
440
|
* consistent with the claim, such as a zip for a `.docx`, keeps the claimed
|
|
307
441
|
* type), then the HTTP `content-type`, then the client's claim. An empty
|
|
@@ -326,47 +460,36 @@ export async function loadAttachmentBytes(
|
|
|
326
460
|
|
|
327
461
|
let bytes: Uint8Array;
|
|
328
462
|
let httpType: string | undefined;
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
463
|
+
if (opts.preloaded) {
|
|
464
|
+
bytes = opts.preloaded.bytes;
|
|
465
|
+
httpType = opts.preloaded.contentType;
|
|
466
|
+
} else if (attachment.channelRef) {
|
|
467
|
+
({ bytes, httpType } = await loadChannelFile(
|
|
468
|
+
attachment.channelRef,
|
|
469
|
+
attachment.eventId,
|
|
470
|
+
roomId,
|
|
471
|
+
source,
|
|
333
472
|
maxBytes,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
473
|
+
opts,
|
|
474
|
+
));
|
|
475
|
+
} else if (attachment.eventId) {
|
|
476
|
+
({ bytes, httpType } = await loadMatrixEvent(
|
|
477
|
+
attachment.eventId,
|
|
478
|
+
roomId,
|
|
479
|
+
source,
|
|
480
|
+
maxBytes,
|
|
481
|
+
opts,
|
|
482
|
+
));
|
|
483
|
+
} else if (attachment.mxcUri!.startsWith('mxc://')) {
|
|
341
484
|
const bound = boundedSignal(
|
|
342
485
|
opts.signal,
|
|
343
486
|
opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
|
|
344
487
|
);
|
|
345
488
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
);
|
|
351
|
-
const media = await untilAborted(
|
|
352
|
-
source.downloadEvent(
|
|
353
|
-
roomId,
|
|
354
|
-
attachment.eventId,
|
|
355
|
-
maxBytes,
|
|
356
|
-
bound.signal,
|
|
357
|
-
),
|
|
358
|
-
bound.signal,
|
|
359
|
-
);
|
|
360
|
-
if (!media)
|
|
361
|
-
throw new Error(`Matrix event ${attachment.eventId} not found`);
|
|
362
|
-
bytes = media.bytes;
|
|
363
|
-
httpType = media.mimetype;
|
|
364
|
-
} else {
|
|
365
|
-
bytes = await untilAborted(
|
|
366
|
-
source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
|
|
367
|
-
bound.signal,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
489
|
+
bytes = await untilAborted(
|
|
490
|
+
source.downloadMxc(attachment.mxcUri!, maxBytes, bound.signal),
|
|
491
|
+
bound.signal,
|
|
492
|
+
);
|
|
370
493
|
} finally {
|
|
371
494
|
bound.release();
|
|
372
495
|
}
|
package/src/attachments/index.ts
CHANGED
|
@@ -73,7 +73,12 @@ export {
|
|
|
73
73
|
MIN_CHARS_PER_PAGE,
|
|
74
74
|
type PdfTextResult,
|
|
75
75
|
} from './pdf-text';
|
|
76
|
-
export {
|
|
76
|
+
export { resizeForModel, RESIZE_JPEG_QUALITY, RESIZE_MAX_EDGE } from './resize';
|
|
77
|
+
export {
|
|
78
|
+
NATIVE_DOCUMENT_MAX_BYTES,
|
|
79
|
+
routeAttachment,
|
|
80
|
+
type AttachmentStrategy,
|
|
81
|
+
} from './route';
|
|
77
82
|
export {
|
|
78
83
|
buildAnalysisMarkdown,
|
|
79
84
|
uploadToSandbox,
|
|
@@ -94,21 +99,17 @@ export {
|
|
|
94
99
|
type AttachmentInput,
|
|
95
100
|
} from './types';
|
|
96
101
|
export {
|
|
97
|
-
applyAttachmentRetention,
|
|
98
|
-
ATTACHMENT_PAYLOAD_TURNS,
|
|
99
102
|
ATTACHMENT_PLACEHOLDER_PREFIX,
|
|
100
103
|
ATTACHMENT_VIEW_SOURCE,
|
|
101
104
|
attachmentMetas,
|
|
102
105
|
attachmentRef,
|
|
103
106
|
buildPlaceholderText,
|
|
104
107
|
hasInlinePayload,
|
|
105
|
-
INLINE_PAYLOAD_NEEDLE,
|
|
106
108
|
isAttachmentPlaceholderText,
|
|
107
109
|
isAttachmentViewMessage,
|
|
108
110
|
isInlinePayloadBlock,
|
|
109
111
|
offloadInlinePayloads,
|
|
110
112
|
VIEW_ATTACHMENT_TOOL_NAME,
|
|
111
|
-
type AttachmentRetentionResult,
|
|
112
113
|
} from './retention';
|
|
113
114
|
export {
|
|
114
115
|
createAttachmentViewSurface,
|