@ixo/oracle-runtime-workers 0.25.0 → 0.27.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 -2
- 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/channels/auth.ts +2 -1
- package/src/channels/contract.ts +104 -0
- package/src/channels/replies.ts +58 -0
- package/src/channels/routing.ts +222 -0
- package/src/channels/turns.ts +1252 -16
- package/src/core/env.ts +17 -0
- package/src/do/contracts.ts +19 -0
- package/src/do/room-mirror.ts +8 -1
- package/src/do/run-coordinator.ts +27 -0
- package/src/do/transcript.ts +14 -11
- package/src/do/turn-messages.ts +54 -0
- package/src/do/user-oracle-do.ts +321 -135
- package/src/memory/session-history-indexer.ts +442 -103
- package/src/memory/user-context-fetcher.ts +13 -0
- package/src/memory/user-context-invalidation.ts +8 -2
- package/src/plugins/action-proposals/evidence.ts +7 -8
- 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/tasks/middleware.ts +4 -9
- 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 +70 -0
- package/src/sqlite/serialization.ts +20 -0
- package/src/sqlite/sessions-store.ts +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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": {
|
|
@@ -58,14 +58,15 @@
|
|
|
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",
|
|
64
65
|
"@ixo/impactxclient-sdk": "^3.0.0",
|
|
65
66
|
"protobufjs": "^7.6.5",
|
|
66
67
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
67
|
-
"@ixo/common": "1.7.1",
|
|
68
68
|
"@ixo/ucan": "2.3.0",
|
|
69
|
+
"@ixo/common": "1.7.1",
|
|
69
70
|
"@ixo/ixo-transaction": "0.2.0"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
@@ -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
|
|
@@ -140,3 +140,98 @@ export class AttachmentTextCacheStore {
|
|
|
140
140
|
return changes;
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/** What {@link IngestTextLru} keeps per extraction. */
|
|
145
|
+
export interface IngestedText {
|
|
146
|
+
text: string;
|
|
147
|
+
/** True when the extraction itself was cut (cap reached, reply at its limit). */
|
|
148
|
+
truncated: boolean;
|
|
149
|
+
/** Size of the original file. */
|
|
150
|
+
bytes: number;
|
|
151
|
+
mimetype: string;
|
|
152
|
+
/** Page count of a PDF's text layer. */
|
|
153
|
+
pages?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One session's view of an {@link IngestTextLru}. */
|
|
157
|
+
export interface IngestTextScope {
|
|
158
|
+
get(ref: string, key: string): IngestedText | undefined;
|
|
159
|
+
put(ref: string, key: string, value: IngestedText): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The longest text the ingestion LRU keeps per entry (characters). */
|
|
163
|
+
export const INGEST_LRU_MAX_CHARS = 1_000_000;
|
|
164
|
+
/** Entries the ingestion LRU keeps (per Durable Object). */
|
|
165
|
+
export const INGEST_LRU_MAX_ENTRIES = 4;
|
|
166
|
+
/** How long an ingestion LRU entry is served. */
|
|
167
|
+
export const INGEST_LRU_TTL_MS = 10 * 60 * 1000;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Text extracted for document ingestion, in memory only: a few large
|
|
171
|
+
* entries (up to a megabyte of text each, far over the 64 KB the SQLite
|
|
172
|
+
* cache keeps) so the second call for the same file (confirm, retry,
|
|
173
|
+
* `allow_truncation`) costs no download and no parse. Least recently used
|
|
174
|
+
* entries go first; entries expire after the TTL.
|
|
175
|
+
*/
|
|
176
|
+
export class IngestTextLru {
|
|
177
|
+
private readonly entries = new Map<
|
|
178
|
+
string,
|
|
179
|
+
{ value: IngestedText; at: number }
|
|
180
|
+
>();
|
|
181
|
+
|
|
182
|
+
constructor(
|
|
183
|
+
private readonly opts: {
|
|
184
|
+
maxEntries?: number;
|
|
185
|
+
maxChars?: number;
|
|
186
|
+
ttlMs?: number;
|
|
187
|
+
now?: () => number;
|
|
188
|
+
} = {},
|
|
189
|
+
) {}
|
|
190
|
+
|
|
191
|
+
private now(): number {
|
|
192
|
+
return (this.opts.now ?? Date.now)();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private get(key: string): IngestedText | undefined {
|
|
196
|
+
const entry = this.entries.get(key);
|
|
197
|
+
if (!entry) return undefined;
|
|
198
|
+
if (this.now() - entry.at >= (this.opts.ttlMs ?? INGEST_LRU_TTL_MS)) {
|
|
199
|
+
this.entries.delete(key);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
// Re-insert: Map iteration order is the recency order.
|
|
203
|
+
this.entries.delete(key);
|
|
204
|
+
this.entries.set(key, entry);
|
|
205
|
+
return entry.value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private put(key: string, value: IngestedText): void {
|
|
209
|
+
if (value.text.length > (this.opts.maxChars ?? INGEST_LRU_MAX_CHARS)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.entries.delete(key);
|
|
213
|
+
this.entries.set(key, { value, at: this.now() });
|
|
214
|
+
const max = this.opts.maxEntries ?? INGEST_LRU_MAX_ENTRIES;
|
|
215
|
+
while (this.entries.size > max) {
|
|
216
|
+
const oldest = this.entries.keys().next();
|
|
217
|
+
if (oldest.done) break;
|
|
218
|
+
this.entries.delete(oldest.value);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get size(): number {
|
|
223
|
+
return this.entries.size;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
forSession(sessionId: string): IngestTextScope {
|
|
227
|
+
const keyOf = (ref: string, key: string) =>
|
|
228
|
+
JSON.stringify([sessionId, ref, key]);
|
|
229
|
+
return {
|
|
230
|
+
get: (ref, key) =>
|
|
231
|
+
isCacheable(ref) ? this.get(keyOf(ref, key)) : undefined,
|
|
232
|
+
put: (ref, key, value) => {
|
|
233
|
+
if (isCacheable(ref)) this.put(keyOf(ref, key), value);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|