@ixo/oracle-runtime-workers 0.24.0 → 0.26.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 -2
- package/src/action-proposals/store.ts +0 -40
- package/src/action-proposals/topic-store.ts +0 -42
- package/src/attachments/extract.ts +108 -14
- package/src/attachments/index.ts +15 -0
- package/src/attachments/pdf-text.ts +99 -0
- package/src/attachments/pipeline.ts +47 -4
- package/src/attachments/sanitize.ts +14 -0
- package/src/attachments/view-cache.ts +95 -0
- package/src/attachments/view.ts +308 -3
- package/src/delivery/plan.ts +4 -1
- package/src/do/contracts.ts +0 -1
- package/src/do/turn-messages.ts +54 -0
- package/src/do/user-oracle-do.ts +309 -143
- package/src/hub-actions/client.ts +58 -5
- package/src/hub-actions/outcome-signature.ts +227 -0
- package/src/hub-actions/request.ts +16 -7
- package/src/hub-actions/store.ts +61 -37
- package/src/hub-actions/transfer.ts +182 -0
- package/src/memory/user-context-invalidation.ts +6 -0
- package/src/plugin-api/types.ts +25 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +154 -25
- package/src/plugins/memory/document-chunker.ts +313 -0
- package/src/plugins/memory/document-source.ts +229 -0
- package/src/plugins/memory/memory-engine-client.ts +352 -0
- package/src/plugins/memory/memory.plugin.ts +25 -2
- package/src/plugins/memory/oracle-document-tools.ts +677 -0
- package/src/plugins/vfs/index.ts +11 -2
- package/src/plugins/vfs/vfs-content.ts +86 -22
- package/src/plugins/vfs/vfs.plugin.ts +28 -19
- package/src/shell/app.ts +36 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"!src/plugins/pod-creator/test-fixtures.ts"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ixo/action-protocol": "0.
|
|
36
|
+
"@ixo/action-protocol": "0.5.0",
|
|
37
37
|
"@blocknote/server-util": "0.29.1",
|
|
38
38
|
"@composio/core": "0.10.0",
|
|
39
39
|
"@composio/langchain": "0.9.2",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"matrix-js-sdk": "^42.2.0",
|
|
59
59
|
"multiformats": "14.0.4",
|
|
60
60
|
"superjson": "1.13.3",
|
|
61
|
+
"unpdf": "1.8.1",
|
|
61
62
|
"wa-sqlite": "1.0.0",
|
|
62
63
|
"yjs": "13.6.32",
|
|
63
64
|
"zod": "^4.4.3",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
73
74
|
"@cloudflare/workers-types": "^5.20260825.1",
|
|
75
|
+
"@ixo/action-protocol-041": "npm:@ixo/action-protocol@0.4.1",
|
|
74
76
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
75
77
|
"happy-dom": "^19.0.0",
|
|
76
78
|
"typescript": "^5.9.3",
|
|
@@ -76,12 +76,6 @@ export const ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS = 180 * 24 * 60 * 60_000;
|
|
|
76
76
|
* still possible, so the link stays used up.
|
|
77
77
|
*/
|
|
78
78
|
export const ACTION_PROPOSAL_EXECUTION_WINDOW_MS = 60 * 60_000;
|
|
79
|
-
/** Cards one agent run may prepare; more is a looping run, refused. */
|
|
80
|
-
export const ACTION_PROPOSALS_PER_RUN = 8;
|
|
81
|
-
/** Cards still awaiting the owner (live `prepared`, `claimed`, hash-less `unknown`) per owner. */
|
|
82
|
-
export const ACTION_PROPOSALS_OPEN_PER_OWNER = 16;
|
|
83
|
-
/** A `prepared` card counts as open this long after creation (its TTL plus clock skew). */
|
|
84
|
-
export const OPEN_PREPARED_MS = 20 * 60_000;
|
|
85
79
|
|
|
86
80
|
/**
|
|
87
81
|
* A card's claim time from its lifecycle JSON, NULL when the JSON is not
|
|
@@ -256,11 +250,6 @@ export class ActionProposalStore {
|
|
|
256
250
|
}
|
|
257
251
|
if (Date.parse(subject.expiresAt) <= this.now())
|
|
258
252
|
throw new ActionProposalConflict('Proposal expired');
|
|
259
|
-
await this.assertCapacity(
|
|
260
|
-
subject.ownerDid,
|
|
261
|
-
subject.origin.sessionId,
|
|
262
|
-
subject.origin.runId,
|
|
263
|
-
);
|
|
264
253
|
await this.db.run(
|
|
265
254
|
`INSERT INTO action_proposals (id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
266
255
|
[
|
|
@@ -293,35 +282,6 @@ export class ActionProposalStore {
|
|
|
293
282
|
});
|
|
294
283
|
}
|
|
295
284
|
|
|
296
|
-
/**
|
|
297
|
-
* A looping or injected run must not mint cards without bound: at most
|
|
298
|
-
* `ACTION_PROPOSALS_PER_RUN` per run and `ACTION_PROPOSALS_OPEN_PER_OWNER`
|
|
299
|
-
* still awaiting the owner. A resumed tool call never reaches here (it
|
|
300
|
-
* returns its original card above).
|
|
301
|
-
*/
|
|
302
|
-
private async assertCapacity(
|
|
303
|
-
ownerDid: string,
|
|
304
|
-
sessionId: string,
|
|
305
|
-
runId: string,
|
|
306
|
-
): Promise<void> {
|
|
307
|
-
const run = await this.db.get<{ n: number }>(
|
|
308
|
-
`SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
|
|
309
|
-
[ownerDid, sessionId, runId],
|
|
310
|
-
);
|
|
311
|
-
if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
|
|
312
|
-
throw new ActionProposalConflict(
|
|
313
|
-
'Too many action cards in this request; finish the open ones first',
|
|
314
|
-
);
|
|
315
|
-
const open = await this.db.get<{ n: number }>(
|
|
316
|
-
`SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR status='claimed' OR (status='unknown' AND transaction_hash IS NULL))`,
|
|
317
|
-
[ownerDid, this.now() - OPEN_PREPARED_MS],
|
|
318
|
-
);
|
|
319
|
-
if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
|
|
320
|
-
throw new ActionProposalConflict(
|
|
321
|
-
'Too many action cards are open; finish or decline them first',
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
285
|
async get(ownerDid: string, id: string): Promise<ActionProposal | null> {
|
|
326
286
|
const row = await this.db.get<{
|
|
327
287
|
subject: string;
|
|
@@ -24,9 +24,6 @@ import {
|
|
|
24
24
|
ActionProposalConflict,
|
|
25
25
|
ACTION_PROPOSAL_RETENTION_MS,
|
|
26
26
|
ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
|
|
27
|
-
ACTION_PROPOSALS_OPEN_PER_OWNER,
|
|
28
|
-
ACTION_PROPOSALS_PER_RUN,
|
|
29
|
-
OPEN_PREPARED_MS,
|
|
30
27
|
RECEIPT_MAX_ATTEMPTS,
|
|
31
28
|
RECEIPT_RETRY_BASE_MS,
|
|
32
29
|
RECEIPT_RETRY_MAX_MS,
|
|
@@ -163,11 +160,6 @@ export class TopicProposalStore {
|
|
|
163
160
|
}
|
|
164
161
|
if (Date.parse(subject.expiresAt) <= this.now())
|
|
165
162
|
throw new ActionProposalConflict('Proposal expired');
|
|
166
|
-
await this.assertCapacity(
|
|
167
|
-
subject.ownerDid,
|
|
168
|
-
subject.origin.sessionId,
|
|
169
|
-
subject.origin.runId,
|
|
170
|
-
);
|
|
171
163
|
const steps: TopicStepId[] =
|
|
172
164
|
subject.action.name === 'create_topic'
|
|
173
165
|
? ['anchor', 'body', 'operation', 'snapshot']
|
|
@@ -201,40 +193,6 @@ export class TopicProposalStore {
|
|
|
201
193
|
return TopicProposalV2Schema.parse({ ...subject, digest, lifecycle });
|
|
202
194
|
});
|
|
203
195
|
}
|
|
204
|
-
/**
|
|
205
|
-
* At most `ACTION_PROPOSALS_PER_RUN` cards per run and
|
|
206
|
-
* `ACTION_PROPOSALS_OPEN_PER_OWNER` open ones (prepared in the last 20
|
|
207
|
-
* minutes, or claimed / unknown in the last two hours: the claim window
|
|
208
|
-
* plus the execution window). The owner object runs this before the
|
|
209
|
-
* authority read a refused card would waste; `create` runs it again.
|
|
210
|
-
*/
|
|
211
|
-
async assertCapacity(
|
|
212
|
-
owner: string,
|
|
213
|
-
sessionId: string,
|
|
214
|
-
runId: string,
|
|
215
|
-
): Promise<void> {
|
|
216
|
-
const now = this.now();
|
|
217
|
-
const run = await this.db.get<{ n: number }>(
|
|
218
|
-
`SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
|
|
219
|
-
[owner, sessionId, runId],
|
|
220
|
-
);
|
|
221
|
-
if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
|
|
222
|
-
throw new ActionProposalConflict(
|
|
223
|
-
'Too many action cards in this request; finish the open ones first',
|
|
224
|
-
);
|
|
225
|
-
const open = await this.db.get<{ n: number }>(
|
|
226
|
-
`SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR (status IN ('claimed','unknown') AND created_at>?))`,
|
|
227
|
-
[
|
|
228
|
-
owner,
|
|
229
|
-
now - OPEN_PREPARED_MS,
|
|
230
|
-
now - OPEN_PREPARED_MS - TOPIC_EXECUTION_WINDOW_MS - 40 * 60_000,
|
|
231
|
-
],
|
|
232
|
-
);
|
|
233
|
-
if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
|
|
234
|
-
throw new ActionProposalConflict(
|
|
235
|
-
'Too many action cards are open; finish or decline them first',
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
196
|
async get(owner: string, id: string): Promise<TopicActionProposal | null> {
|
|
239
197
|
const row = await this.db.get<{
|
|
240
198
|
subject: string;
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Reduced vs Node: there is no local PDF/office parser on workerd
|
|
10
10
|
* (`loadFileFromBuffer` is Node-only), so every non-plain-text document goes
|
|
11
11
|
* straight to the helper model's `file` lane instead of "local parse, then
|
|
12
|
-
* model on failure".
|
|
12
|
+
* model on failure". (Document ingestion parses PDFs locally first:
|
|
13
|
+
* `pdf-text.ts`.)
|
|
13
14
|
*/
|
|
14
15
|
import { bytesToBase64 } from '../plugins/base64';
|
|
15
16
|
import { validateUrlTarget } from './download';
|
|
@@ -36,15 +37,36 @@ export interface ExtractionUsage {
|
|
|
36
37
|
export interface ExtractionResult {
|
|
37
38
|
content: string;
|
|
38
39
|
usage?: ExtractionUsage;
|
|
40
|
+
/** True when the provider stopped the reply at its output-token limit. */
|
|
41
|
+
truncated?: boolean;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export interface ExtractOptions {
|
|
42
45
|
fetchImpl?: typeof fetch;
|
|
43
46
|
signal?: AbortSignal;
|
|
44
47
|
timeoutMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Raw mode (document ingestion): `extractText` returns the text itself —
|
|
50
|
+
* no `[Content of …]` header, no `[...truncated]` marker — cut at
|
|
51
|
+
* `maxChars` with `truncated` reporting whether anything was cut. Unset
|
|
52
|
+
* keeps the chat-attachment behaviour.
|
|
53
|
+
*/
|
|
54
|
+
raw?: { maxChars: number };
|
|
55
|
+
/**
|
|
56
|
+
* `max_tokens` for the completion, sent when the provider accepts it (a
|
|
57
|
+
* 400 that names it is retried once without). Set by raw mode.
|
|
58
|
+
*/
|
|
59
|
+
maxTokens?: number;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
export const MAX_TEXT_LENGTH = 50_000;
|
|
63
|
+
/**
|
|
64
|
+
* Largest scanned or binary file handed to the helper model (inline base64
|
|
65
|
+
* in the request body); also the Files plugin's `MAX_VISION_BYTES`.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_MODEL_LANE_BYTES = 10 * 1024 * 1024;
|
|
68
|
+
/** Upper bound on the `max_tokens` raw mode asks for. */
|
|
69
|
+
export const MAX_RAW_COMPLETION_TOKENS = 32_768;
|
|
48
70
|
const AI_PROCESS_TIMEOUT_MS = 120_000;
|
|
49
71
|
const MAX_ERROR_BODY_LENGTH = 1024;
|
|
50
72
|
|
|
@@ -70,6 +92,18 @@ export function truncateText(text: string): string {
|
|
|
70
92
|
return `${text.slice(0, MAX_TEXT_LENGTH)}\n\n[...truncated]`;
|
|
71
93
|
}
|
|
72
94
|
|
|
95
|
+
/** Cut `text` to at most `maxChars` UTF-16 units without splitting a surrogate pair. */
|
|
96
|
+
export function capText(
|
|
97
|
+
text: string,
|
|
98
|
+
maxChars: number,
|
|
99
|
+
): { text: string; truncated: boolean } {
|
|
100
|
+
if (text.length <= maxChars) return { text, truncated: false };
|
|
101
|
+
let end = maxChars;
|
|
102
|
+
const last = text.charCodeAt(end - 1);
|
|
103
|
+
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
|
104
|
+
return { text: text.slice(0, end), truncated: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
export function getAudioFormat(
|
|
74
108
|
mimetype: string,
|
|
75
109
|
): 'mp3' | 'wav' | 'ogg' | 'flac' | 'webm' | 'mp4' | 'aac' {
|
|
@@ -84,7 +118,10 @@ export function getAudioFormat(
|
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
interface ChatCompletion {
|
|
87
|
-
choices?: Array<{
|
|
121
|
+
choices?: Array<{
|
|
122
|
+
message?: { content?: unknown };
|
|
123
|
+
finish_reason?: string | null;
|
|
124
|
+
}>;
|
|
88
125
|
usage?: {
|
|
89
126
|
prompt_tokens?: number;
|
|
90
127
|
completion_tokens?: number;
|
|
@@ -94,6 +131,27 @@ interface ChatCompletion {
|
|
|
94
131
|
model?: string;
|
|
95
132
|
}
|
|
96
133
|
|
|
134
|
+
/** The text of a reply: a string as is, a block array reduced to its text blocks. */
|
|
135
|
+
export function replyText(content: unknown): string {
|
|
136
|
+
if (typeof content === 'string') return content;
|
|
137
|
+
if (!Array.isArray(content)) return '';
|
|
138
|
+
const parts: string[] = [];
|
|
139
|
+
for (const block of content) {
|
|
140
|
+
if (typeof block === 'string') parts.push(block);
|
|
141
|
+
else if (
|
|
142
|
+
typeof block === 'object' &&
|
|
143
|
+
block !== null &&
|
|
144
|
+
'type' in block &&
|
|
145
|
+
block.type === 'text' &&
|
|
146
|
+
'text' in block &&
|
|
147
|
+
typeof block.text === 'string'
|
|
148
|
+
) {
|
|
149
|
+
parts.push(block.text);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return parts.join('');
|
|
153
|
+
}
|
|
154
|
+
|
|
97
155
|
async function chatCompletion(
|
|
98
156
|
provider: ExtractionProvider,
|
|
99
157
|
contentParts: Array<Record<string, unknown>>,
|
|
@@ -106,9 +164,8 @@ async function chatCompletion(
|
|
|
106
164
|
if (opts.signal?.aborted) controller.abort();
|
|
107
165
|
const timer = setTimeout(onAbort, opts.timeoutMs ?? AI_PROCESS_TIMEOUT_MS);
|
|
108
166
|
try {
|
|
109
|
-
const
|
|
110
|
-
`${provider.baseURL.replace(/\/+$/, '')}/chat/completions`,
|
|
111
|
-
{
|
|
167
|
+
const send = (withMaxTokens: boolean): Promise<Response> =>
|
|
168
|
+
fetchImpl(`${provider.baseURL.replace(/\/+$/, '')}/chat/completions`, {
|
|
112
169
|
method: 'POST',
|
|
113
170
|
headers: {
|
|
114
171
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
@@ -118,20 +175,34 @@ async function chatCompletion(
|
|
|
118
175
|
body: JSON.stringify({
|
|
119
176
|
model: provider.model,
|
|
120
177
|
messages: [{ role: 'user', content: contentParts }],
|
|
178
|
+
...(withMaxTokens && opts.maxTokens !== undefined
|
|
179
|
+
? { max_tokens: opts.maxTokens }
|
|
180
|
+
: {}),
|
|
121
181
|
}),
|
|
122
182
|
signal: controller.signal,
|
|
123
|
-
}
|
|
124
|
-
);
|
|
183
|
+
});
|
|
184
|
+
let response = await send(true);
|
|
125
185
|
if (!response.ok) {
|
|
126
186
|
const errorText = await response.text().catch(() => '');
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
// A provider that rejects `max_tokens` gets the request without it.
|
|
188
|
+
if (
|
|
189
|
+
opts.maxTokens !== undefined &&
|
|
190
|
+
response.status === 400 &&
|
|
191
|
+
/max_(completion_)?tokens/i.test(errorText)
|
|
192
|
+
) {
|
|
193
|
+
response = await send(false);
|
|
194
|
+
} else {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`AI processing failed (${response.status}): ${errorText.slice(0, MAX_ERROR_BODY_LENGTH)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
130
199
|
}
|
|
131
200
|
const raw: unknown = await response.json();
|
|
132
201
|
const result = raw as ChatCompletion;
|
|
202
|
+
const choice = result.choices?.[0];
|
|
133
203
|
return {
|
|
134
|
-
content:
|
|
204
|
+
content: replyText(choice?.message?.content),
|
|
205
|
+
...(choice?.finish_reason === 'length' ? { truncated: true } : {}),
|
|
135
206
|
...(result.usage
|
|
136
207
|
? {
|
|
137
208
|
usage: {
|
|
@@ -197,6 +268,14 @@ export async function aiProcess(
|
|
|
197
268
|
return chatCompletion(provider, parts, opts);
|
|
198
269
|
}
|
|
199
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The `max_tokens` that matches a character cap: about three characters per
|
|
273
|
+
* token, bounded by what providers commonly accept.
|
|
274
|
+
*/
|
|
275
|
+
export function rawMaxTokens(maxChars: number): number {
|
|
276
|
+
return Math.min(Math.ceil(maxChars / 3), MAX_RAW_COMPLETION_TOKENS);
|
|
277
|
+
}
|
|
278
|
+
|
|
200
279
|
/**
|
|
201
280
|
* Text for one downloaded attachment by category — the Node
|
|
202
281
|
* `processDocument` / `processImage` / `processAudio` / `processVideo`
|
|
@@ -208,9 +287,14 @@ export async function extractText(
|
|
|
208
287
|
category: Exclude<FileCategory, 'unsupported'>,
|
|
209
288
|
provider: ExtractionProvider | null,
|
|
210
289
|
opts: ExtractOptions = {},
|
|
211
|
-
): Promise<{ text: string; usage?: ExtractionUsage }> {
|
|
290
|
+
): Promise<{ text: string; usage?: ExtractionUsage; truncated?: boolean }> {
|
|
212
291
|
const safeName = sanitizeAttachmentFilename(attachment.filename);
|
|
213
292
|
if (category === 'document' && isPlainTextType(attachment.mimetype)) {
|
|
293
|
+
if (opts.raw) {
|
|
294
|
+
// At most 3 UTF-8 bytes per UTF-16 unit: decoding past this adds nothing.
|
|
295
|
+
const head = bytes.subarray(0, opts.raw.maxChars * 3 + 4);
|
|
296
|
+
return capText(new TextDecoder().decode(head), opts.raw.maxChars);
|
|
297
|
+
}
|
|
214
298
|
const text = new TextDecoder().decode(bytes);
|
|
215
299
|
return { text: formatContent('Content', safeName, truncateText(text)) };
|
|
216
300
|
}
|
|
@@ -225,14 +309,24 @@ export async function extractText(
|
|
|
225
309
|
: category === 'audio'
|
|
226
310
|
? 'Transcription'
|
|
227
311
|
: 'Description';
|
|
228
|
-
const { content, usage } = await aiProcess(
|
|
312
|
+
const { content, usage, truncated } = await aiProcess(
|
|
229
313
|
bytes,
|
|
230
314
|
attachment.mimetype,
|
|
231
315
|
category,
|
|
232
316
|
attachment.filename,
|
|
233
317
|
provider,
|
|
234
|
-
opts
|
|
318
|
+
opts.raw && opts.maxTokens === undefined
|
|
319
|
+
? { ...opts, maxTokens: rawMaxTokens(opts.raw.maxChars) }
|
|
320
|
+
: opts,
|
|
235
321
|
);
|
|
322
|
+
if (opts.raw) {
|
|
323
|
+
const capped = capText(content, opts.raw.maxChars);
|
|
324
|
+
return {
|
|
325
|
+
text: capped.text,
|
|
326
|
+
truncated: capped.truncated || truncated === true,
|
|
327
|
+
...(usage ? { usage } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
236
330
|
const body = category === 'document' ? truncateText(content) : content;
|
|
237
331
|
return {
|
|
238
332
|
text: formatContent(label, safeName, body),
|
package/src/attachments/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
export {
|
|
21
21
|
aiProcess,
|
|
22
22
|
aiProcessFromUrl,
|
|
23
|
+
capText,
|
|
23
24
|
extractText,
|
|
24
25
|
formatContent,
|
|
25
26
|
getAudioFormat,
|
|
@@ -39,6 +40,7 @@ export {
|
|
|
39
40
|
type FileCategory,
|
|
40
41
|
} from './magic';
|
|
41
42
|
export {
|
|
43
|
+
nativeAttachmentNote,
|
|
42
44
|
prepareAttachments,
|
|
43
45
|
SANDBOX_TRUNCATE_LIMIT,
|
|
44
46
|
type AttachmentPipelineDeps,
|
|
@@ -46,6 +48,11 @@ export {
|
|
|
46
48
|
type PrepareAttachmentsParams,
|
|
47
49
|
type PreparedAttachments,
|
|
48
50
|
} from './pipeline';
|
|
51
|
+
export {
|
|
52
|
+
extractPdfText,
|
|
53
|
+
MIN_CHARS_PER_PAGE,
|
|
54
|
+
type PdfTextResult,
|
|
55
|
+
} from './pdf-text';
|
|
49
56
|
export { routeAttachment, type AttachmentStrategy } from './route';
|
|
50
57
|
export {
|
|
51
58
|
buildAnalysisMarkdown,
|
|
@@ -81,14 +88,22 @@ export {
|
|
|
81
88
|
type AttachmentRetentionResult,
|
|
82
89
|
} from './retention';
|
|
83
90
|
export {
|
|
91
|
+
createAttachmentViewSurface,
|
|
92
|
+
extractAttachmentText,
|
|
93
|
+
findPersistedAttachmentMeta,
|
|
84
94
|
viewAttachment,
|
|
95
|
+
type AttachmentMessageStore,
|
|
96
|
+
type AttachmentTextExtraction,
|
|
85
97
|
type AttachmentView,
|
|
86
98
|
type AttachmentViewSurface,
|
|
87
99
|
type ViewAttachmentDeps,
|
|
88
100
|
} from './view';
|
|
89
101
|
export {
|
|
90
102
|
AttachmentTextCacheStore,
|
|
103
|
+
IngestTextLru,
|
|
91
104
|
LOCAL_TEXT_EXTRACTION,
|
|
92
105
|
VIEW_CACHE_MAX_CHARS,
|
|
93
106
|
type AttachmentTextCache,
|
|
107
|
+
type IngestedText,
|
|
108
|
+
type IngestTextScope,
|
|
94
109
|
} from './view-cache';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local PDF text extraction (pdf.js through `unpdf`'s serverless build, which
|
|
3
|
+
* runs on Workers with no DOM or canvas). Used by document ingestion, where
|
|
4
|
+
* the text must be the document's own words rather than a model's rendering.
|
|
5
|
+
* A PDF without a text layer (a scan) is reported as `scanned` so the caller
|
|
6
|
+
* can fall back to the helper model.
|
|
7
|
+
*
|
|
8
|
+
* Pages are read one at a time and reading stops as soon as the caller's
|
|
9
|
+
* character cap is reached, so a thousand-page file costs what the first
|
|
10
|
+
* pages cost, and the document is always destroyed (pdf.js otherwise keeps
|
|
11
|
+
* its worker-side state, fonts and page caches, alive).
|
|
12
|
+
*/
|
|
13
|
+
import { getDocumentProxy } from 'unpdf';
|
|
14
|
+
|
|
15
|
+
import { capText } from './extract';
|
|
16
|
+
|
|
17
|
+
/** Below this many non-whitespace characters per page on average, a PDF is treated as scanned. */
|
|
18
|
+
export const MIN_CHARS_PER_PAGE = 50;
|
|
19
|
+
|
|
20
|
+
/** Pages read at most, whatever the character cap. */
|
|
21
|
+
export const DEFAULT_MAX_PDF_PAGES = 2000;
|
|
22
|
+
|
|
23
|
+
export interface PdfTextOptions {
|
|
24
|
+
/** Stop reading once the text passes this many UTF-16 units. */
|
|
25
|
+
maxChars: number;
|
|
26
|
+
/** Stop after this many pages (default {@link DEFAULT_MAX_PDF_PAGES}). */
|
|
27
|
+
maxPages?: number;
|
|
28
|
+
/** Checked between pages; an aborted signal throws its reason. */
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PdfTextResult =
|
|
33
|
+
| {
|
|
34
|
+
kind: 'text';
|
|
35
|
+
/** At most `maxChars` units, as `--- page N ---` blocks. */
|
|
36
|
+
text: string;
|
|
37
|
+
/** Pages in the document. */
|
|
38
|
+
pages: number;
|
|
39
|
+
/** Pages actually read. */
|
|
40
|
+
pagesRead: number;
|
|
41
|
+
/** True when pages were left unread or the text was cut at `maxChars`. */
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
}
|
|
44
|
+
| { kind: 'scanned'; pages: number };
|
|
45
|
+
|
|
46
|
+
function pageBlock(pageNumber: number, text: string): string {
|
|
47
|
+
return `--- page ${pageNumber} ---\n${text.trim()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function extractPdfText(
|
|
51
|
+
bytes: Uint8Array,
|
|
52
|
+
opts: PdfTextOptions,
|
|
53
|
+
): Promise<PdfTextResult> {
|
|
54
|
+
const maxPages = opts.maxPages ?? DEFAULT_MAX_PDF_PAGES;
|
|
55
|
+
opts.signal?.throwIfAborted();
|
|
56
|
+
// pdf.js takes ownership of (and detaches) the buffer it is handed.
|
|
57
|
+
const pdf = await getDocumentProxy(new Uint8Array(bytes));
|
|
58
|
+
try {
|
|
59
|
+
const totalPages = pdf.numPages;
|
|
60
|
+
const blocks: string[] = [];
|
|
61
|
+
let length = 0;
|
|
62
|
+
let visible = 0;
|
|
63
|
+
let pagesRead = 0;
|
|
64
|
+
while (pagesRead < totalPages && pagesRead < maxPages) {
|
|
65
|
+
opts.signal?.throwIfAborted();
|
|
66
|
+
const page = await pdf.getPage(pagesRead + 1);
|
|
67
|
+
let pageText: string;
|
|
68
|
+
try {
|
|
69
|
+
const content = await page.getTextContent();
|
|
70
|
+
pageText = content.items
|
|
71
|
+
.map((item) =>
|
|
72
|
+
'str' in item ? item.str + (item.hasEOL ? '\n' : '') : '',
|
|
73
|
+
)
|
|
74
|
+
.join('');
|
|
75
|
+
} finally {
|
|
76
|
+
page.cleanup();
|
|
77
|
+
}
|
|
78
|
+
pagesRead += 1;
|
|
79
|
+
visible += pageText.replace(/\s/g, '').length;
|
|
80
|
+
const block = pageBlock(pagesRead, pageText);
|
|
81
|
+
blocks.push(block);
|
|
82
|
+
length += block.length + 2;
|
|
83
|
+
if (length > opts.maxChars) break;
|
|
84
|
+
}
|
|
85
|
+
if (totalPages === 0 || visible < pagesRead * MIN_CHARS_PER_PAGE) {
|
|
86
|
+
return { kind: 'scanned', pages: totalPages };
|
|
87
|
+
}
|
|
88
|
+
const capped = capText(blocks.join('\n\n'), opts.maxChars);
|
|
89
|
+
return {
|
|
90
|
+
kind: 'text',
|
|
91
|
+
text: capped.text,
|
|
92
|
+
pages: totalPages,
|
|
93
|
+
pagesRead,
|
|
94
|
+
truncated: capped.truncated || pagesRead < totalPages,
|
|
95
|
+
};
|
|
96
|
+
} finally {
|
|
97
|
+
await pdf.loadingTask.destroy();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -58,7 +58,11 @@ import {
|
|
|
58
58
|
type SandboxUploadConfig,
|
|
59
59
|
} from './sandbox-archive';
|
|
60
60
|
import {
|
|
61
|
+
NOTE_EVENT_ID_MAX,
|
|
62
|
+
NOTE_MIMETYPE_MAX,
|
|
63
|
+
NOTE_URI_MAX,
|
|
61
64
|
sanitizeAttachmentFilename,
|
|
65
|
+
sanitizeNoteField,
|
|
62
66
|
sanitizeSandboxPath,
|
|
63
67
|
SANDBOX_OUTPUT_PREFIX,
|
|
64
68
|
} from './sanitize';
|
|
@@ -91,6 +95,13 @@ export interface PreparedAttachments {
|
|
|
91
95
|
metas: AttachmentMeta[];
|
|
92
96
|
/** Extraction-lane results, to append as hidden context messages. */
|
|
93
97
|
extracted: ExtractedAttachment[];
|
|
98
|
+
/**
|
|
99
|
+
* One line per file sent natively, to append as hidden context messages
|
|
100
|
+
* like `extracted`: the model reads the file itself but would otherwise
|
|
101
|
+
* never learn the reference that tools (`view_attachment`, document
|
|
102
|
+
* ingestion) take.
|
|
103
|
+
*/
|
|
104
|
+
nativeNotes: ExtractedAttachment[];
|
|
94
105
|
usage: { cost: number; promptTokens: number; completionTokens: number };
|
|
95
106
|
}
|
|
96
107
|
|
|
@@ -115,12 +126,34 @@ function metaFor(attachment: AttachmentInput): AttachmentMeta {
|
|
|
115
126
|
};
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The line that names an attachment's reference to the model, for both lanes:
|
|
131
|
+
* `[source: eventId="…"]` (the media event id) or `[source: url="…"]` (the
|
|
132
|
+
* mxc / http URI), the same two values `attachmentRef` accepts.
|
|
133
|
+
*/
|
|
118
134
|
function sourceRef(attachment: AttachmentInput): string {
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
// A reference the cleaner would alter is not the reference tools match on,
|
|
136
|
+
// so such a value is left out rather than shown changed.
|
|
137
|
+
const clean = (value: string, max: number): string => {
|
|
138
|
+
const cleaned = sanitizeNoteField(value, max);
|
|
139
|
+
return cleaned === value ? cleaned : '';
|
|
140
|
+
};
|
|
141
|
+
const eventId = attachment.eventId
|
|
142
|
+
? clean(attachment.eventId, NOTE_EVENT_ID_MAX)
|
|
143
|
+
: '';
|
|
144
|
+
if (eventId) return `[source: eventId="${eventId}"]`;
|
|
145
|
+
const url = attachment.mxcUri ? clean(attachment.mxcUri, NOTE_URI_MAX) : '';
|
|
146
|
+
if (url) return `[source: url="${url}"]`;
|
|
121
147
|
return '';
|
|
122
148
|
}
|
|
123
149
|
|
|
150
|
+
/** The note for a file sent natively: its source line, name and type. */
|
|
151
|
+
export function nativeAttachmentNote(attachment: AttachmentInput): string {
|
|
152
|
+
const ref = sourceRef(attachment);
|
|
153
|
+
if (!ref) return '';
|
|
154
|
+
return `${ref} "${sanitizeAttachmentFilename(attachment.filename)}" (${sanitizeNoteField(attachment.mimetype, NOTE_MIMETYPE_MAX)}) is attached to this message.`;
|
|
155
|
+
}
|
|
156
|
+
|
|
124
157
|
function errorMessage(error: unknown): string {
|
|
125
158
|
return error instanceof Error ? error.message : String(error);
|
|
126
159
|
}
|
|
@@ -320,7 +353,13 @@ export async function prepareAttachments(
|
|
|
320
353
|
const metas = params.attachments.map(metaFor);
|
|
321
354
|
const usage = { cost: 0, promptTokens: 0, completionTokens: 0 };
|
|
322
355
|
if (params.attachments.length === 0) {
|
|
323
|
-
return {
|
|
356
|
+
return {
|
|
357
|
+
content: params.text,
|
|
358
|
+
metas,
|
|
359
|
+
extracted: [],
|
|
360
|
+
nativeNotes: [],
|
|
361
|
+
usage,
|
|
362
|
+
};
|
|
324
363
|
}
|
|
325
364
|
|
|
326
365
|
const reportedTotal = params.attachments.reduce(
|
|
@@ -354,6 +393,7 @@ export async function prepareAttachments(
|
|
|
354
393
|
|
|
355
394
|
const budget = new DownloadBudget();
|
|
356
395
|
const natives: NativeAttachment[] = [];
|
|
396
|
+
const nativeNotes: ExtractedAttachment[] = [];
|
|
357
397
|
for (const attachment of nativeCandidates) {
|
|
358
398
|
try {
|
|
359
399
|
const { bytes, mimetype, sniffed } = await budget.load(
|
|
@@ -382,6 +422,8 @@ export async function prepareAttachments(
|
|
|
382
422
|
base64: bytesToBase64(bytes),
|
|
383
423
|
filename: attachment.filename,
|
|
384
424
|
});
|
|
425
|
+
const note = nativeAttachmentNote(attachment);
|
|
426
|
+
if (note) nativeNotes.push({ text: note, meta: metaFor(attachment) });
|
|
385
427
|
deps.logger.log(
|
|
386
428
|
`[attachments] NATIVE → "${attachment.filename}" sent directly to ${params.model} (${bytes.length} bytes, ${kind})`,
|
|
387
429
|
);
|
|
@@ -449,7 +491,7 @@ export async function prepareAttachments(
|
|
|
449
491
|
deps.logger.error(
|
|
450
492
|
`[attachments] failed to process ${attachment.filename}: ${errorMessage(error)}`,
|
|
451
493
|
);
|
|
452
|
-
text = `[File "${sanitizeAttachmentFilename(attachment.filename)}" (${attachment.mimetype}) failed to process: ${errorMessage(error)}]`;
|
|
494
|
+
text = `[File "${sanitizeAttachmentFilename(attachment.filename)}" (${sanitizeNoteField(attachment.mimetype, NOTE_MIMETYPE_MAX)}) failed to process: ${errorMessage(error)}]`;
|
|
453
495
|
}
|
|
454
496
|
const ref = sourceRef(attachment);
|
|
455
497
|
extracted.push({ text: ref ? `${ref}\n${text}` : text, meta });
|
|
@@ -460,6 +502,7 @@ export async function prepareAttachments(
|
|
|
460
502
|
content: buildUserMessageContent(params.text, natives),
|
|
461
503
|
metas,
|
|
462
504
|
extracted,
|
|
505
|
+
nativeNotes,
|
|
463
506
|
usage,
|
|
464
507
|
};
|
|
465
508
|
}
|
|
@@ -21,6 +21,20 @@ export function sanitizeAttachmentFilename(filename: string): string {
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Longest mimetype / event id / mxc URI written into a hidden note. */
|
|
25
|
+
export const NOTE_MIMETYPE_MAX = 100;
|
|
26
|
+
export const NOTE_EVENT_ID_MAX = 255;
|
|
27
|
+
export const NOTE_URI_MAX = 512;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Clean a client-supplied value (mimetype, media event id, mxc URI) for the
|
|
31
|
+
* hidden notes the model reads: the filename cleaner plus quotes removed
|
|
32
|
+
* (the notes wrap refs in `"…"`), cut to `max` characters.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeNoteField(value: string, max: number): string {
|
|
35
|
+
return sanitizeAttachmentFilename(value).replace(/"/g, '').slice(0, max);
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
/**
|
|
25
39
|
* Sanitize a filename/path for the sandbox upload endpoint: only
|
|
26
40
|
* alphanumerics, dots, dashes, underscores and slashes, and no `..` segments
|