@ixo/oracle-runtime-workers 0.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.25.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": {
@@ -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",
@@ -65,8 +66,8 @@
65
66
  "protobufjs": "^7.6.5",
66
67
  "@ixo/topic-protocol": "1.0.0-rc.8",
67
68
  "@ixo/common": "1.7.1",
68
- "@ixo/ucan": "2.3.0",
69
- "@ixo/ixo-transaction": "0.2.0"
69
+ "@ixo/ixo-transaction": "0.2.0",
70
+ "@ixo/ucan": "2.3.0"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -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<{ message?: { content?: string | null } }>;
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 response = await fetchImpl(
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
- throw new Error(
128
- `AI processing failed (${response.status}): ${errorText.slice(0, MAX_ERROR_BODY_LENGTH)}`,
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: result.choices?.[0]?.message?.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),
@@ -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
- if (attachment.eventId) return `[source: eventId="${attachment.eventId}"]`;
120
- if (attachment.mxcUri) return `[source: url="${attachment.mxcUri}"]`;
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 { content: params.text, metas, extracted: [], usage };
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
+ }