@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.
@@ -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
+ }
@@ -10,15 +10,26 @@
10
10
  import { classifyAttachment } from './classify';
11
11
  import type { NativeAttachment } from './content-blocks';
12
12
  import { loadAttachmentBytes, type MatrixMediaSource } from './download';
13
- import { extractText, type ExtractionProvider } from './extract';
13
+ import {
14
+ capText,
15
+ extractText,
16
+ MAX_MODEL_LANE_BYTES,
17
+ type ExtractionProvider,
18
+ } from './extract';
14
19
  import { categorizeFile, isPlainTextType, verifyMagicBytes } from './magic';
15
- import { attachmentRef } from './retention';
20
+ import { extractPdfText } from './pdf-text';
21
+ import { attachmentMetas, attachmentRef } from './retention';
16
22
  import { routeAttachment } from './route';
17
23
  import { sanitizeAttachmentFilename } from './sanitize';
18
- import { LOCAL_TEXT_EXTRACTION, type AttachmentTextCache } from './view-cache';
24
+ import {
25
+ LOCAL_TEXT_EXTRACTION,
26
+ type AttachmentTextCache,
27
+ type IngestTextScope,
28
+ } from './view-cache';
19
29
  import type { AttachmentInput } from './types';
20
30
  import type { ModelInputCapabilities } from '../core/llm';
21
31
  import type { AttachmentMeta } from '../sqlite/serialization';
32
+ import type { BaseMessage } from '@langchain/core/messages';
22
33
  import type { Logger } from '../plugin-api/types';
23
34
  import { bytesToBase64 } from '../plugins/base64';
24
35
 
@@ -40,6 +51,11 @@ export interface ViewAttachmentDeps {
40
51
  * a hit costs no download and no helper-model call.
41
52
  */
42
53
  cache?: AttachmentTextCache;
54
+ /**
55
+ * The session's in-memory cache of ingestion text (`IngestTextLru`): holds
56
+ * texts far over the SQLite cache's per-entry limit for a few minutes.
57
+ */
58
+ ingestCache?: IngestTextScope;
43
59
  }
44
60
 
45
61
  /** What the `view_attachment` tool sees on the host: session-scoped access. */
@@ -52,6 +68,32 @@ export interface AttachmentViewSurface {
52
68
  * not belong to an attachment of this session.
53
69
  */
54
70
  view(ref: string): Promise<{ meta: AttachmentMeta; view: AttachmentView }>;
71
+ /**
72
+ * The text of one of the session's attachments for ingestion into
73
+ * knowledge (never a native block): decoded locally for plain text, parsed
74
+ * locally for PDFs with a text layer, otherwise extracted by the helper
75
+ * model. Unwrapped and capped at `maxChars`. Resolves `null` when the
76
+ * reference does not belong to an attachment of this session; rejects when
77
+ * the file cannot be downloaded or read.
78
+ */
79
+ extractText(
80
+ ref: string,
81
+ opts: { maxChars: number },
82
+ ): Promise<AttachmentTextExtraction | null>;
83
+ }
84
+
85
+ /** What {@link AttachmentViewSurface.extractText} returns. */
86
+ export interface AttachmentTextExtraction {
87
+ text: string;
88
+ filename: string;
89
+ mimetype: string;
90
+ /** Size of the original file when known, else of the extracted text. */
91
+ bytes: number;
92
+ /** True when `text` was cut at the caller's `maxChars`. */
93
+ truncated: boolean;
94
+ method: 'text' | 'pdf' | 'model';
95
+ /** Page count, set when the text came from a PDF's text layer. */
96
+ pages?: number;
55
97
  }
56
98
 
57
99
  function toInput(meta: AttachmentMeta): AttachmentInput {
@@ -146,3 +188,266 @@ export async function viewAttachment(
146
188
  if (cacheKey) await deps.cache?.put(cacheKey.ref, cacheKey.model, text);
147
189
  return { kind: 'text', text };
148
190
  }
191
+
192
+ const PDF_MIME = 'application/pdf';
193
+
194
+ /** Text-cache keys of ingestion: distinct from the `view_attachment` ones. */
195
+ const INGEST_TEXT_KEY = 'ingest:local-text';
196
+ const INGEST_PDF_KEY = 'ingest:local-pdf';
197
+
198
+ function megabytes(bytes: number): string {
199
+ return (bytes / 1024 / 1024).toFixed(1);
200
+ }
201
+
202
+ /** The refusal for a scanned or binary file over the helper-model limit. */
203
+ function modelLaneTooLarge(filename: string, bytes: number): Error {
204
+ return new Error(
205
+ `"${sanitizeAttachmentFilename(filename)}" cannot be read: scanned or binary files up to ${megabytes(MAX_MODEL_LANE_BYTES)} MB can be processed; this one is ${megabytes(bytes)} MB. PDFs with a text layer and text files can be larger.`,
206
+ );
207
+ }
208
+
209
+ /**
210
+ * The ingestion counterpart of {@link viewAttachment}: always text, raw (no
211
+ * `[Content of …]` header) and capped at `maxChars`. Results are kept in the
212
+ * in-memory ingestion cache (any size up to its limit, cut or not) and, when
213
+ * small and not cut, in the session's SQLite cache under `ingest:` keys; a
214
+ * cached text is re-capped to the caller's limit. The helper-model lane
215
+ * (scans, images, Office files) refuses files over `MAX_MODEL_LANE_BYTES`.
216
+ */
217
+ export async function extractAttachmentText(
218
+ meta: AttachmentMeta,
219
+ deps: ViewAttachmentDeps,
220
+ opts: { maxChars: number },
221
+ ): Promise<AttachmentTextExtraction> {
222
+ const attachment = toInput(meta);
223
+ const category = categorizeFile(attachment.mimetype);
224
+ if (category === 'unsupported') {
225
+ throw new Error(
226
+ `"${sanitizeAttachmentFilename(attachment.filename)}" (${attachment.mimetype}) is not a supported file type`,
227
+ );
228
+ }
229
+ const plain = category === 'document' && isPlainTextType(attachment.mimetype);
230
+ const ref = attachmentRef(meta);
231
+ const modelKey = deps.extraction ? `ingest:${deps.extraction.model}` : '';
232
+ const result = (
233
+ text: string,
234
+ method: AttachmentTextExtraction['method'],
235
+ mimetype: string,
236
+ bytes: number,
237
+ truncated: boolean,
238
+ pages?: number,
239
+ ): AttachmentTextExtraction => ({
240
+ text,
241
+ filename: attachment.filename,
242
+ mimetype,
243
+ bytes,
244
+ truncated,
245
+ method,
246
+ ...(pages !== undefined ? { pages } : {}),
247
+ });
248
+
249
+ const lookups: Array<[string, AttachmentTextExtraction['method']]> = plain
250
+ ? [[INGEST_TEXT_KEY, 'text']]
251
+ : [
252
+ ...(attachment.mimetype === PDF_MIME
253
+ ? [[INGEST_PDF_KEY, 'pdf'] as [string, 'pdf']]
254
+ : []),
255
+ ...(modelKey ? [[modelKey, 'model'] as [string, 'model']] : []),
256
+ ];
257
+ if (ref) {
258
+ for (const [key, method] of lookups) {
259
+ const hit = deps.ingestCache?.get(ref, key);
260
+ if (!hit) continue;
261
+ deps.logger.log(
262
+ `[attachments] ingest "${attachment.filename}" → cached text (${key}, memory)`,
263
+ );
264
+ const capped = capText(hit.text, opts.maxChars);
265
+ return result(
266
+ capped.text,
267
+ method,
268
+ hit.mimetype,
269
+ hit.bytes,
270
+ capped.truncated || hit.truncated,
271
+ hit.pages,
272
+ );
273
+ }
274
+ }
275
+ const cache = deps.cache;
276
+ if (cache && ref) {
277
+ for (const [key, method] of lookups) {
278
+ const cached = await cache.get(ref, key);
279
+ if (cached === undefined) continue;
280
+ deps.logger.log(
281
+ `[attachments] ingest "${attachment.filename}" → cached text (${key})`,
282
+ );
283
+ const capped = capText(cached, opts.maxChars);
284
+ return result(
285
+ capped.text,
286
+ method,
287
+ attachment.mimetype,
288
+ meta.size ?? new TextEncoder().encode(cached).length,
289
+ capped.truncated,
290
+ );
291
+ }
292
+ }
293
+
294
+ // The helper model takes the bytes inline: refuse a big scan or binary
295
+ // before downloading it. (A PDF may still have a text layer, so it is
296
+ // checked once its bytes are in hand.)
297
+ if (
298
+ !plain &&
299
+ attachment.mimetype !== PDF_MIME &&
300
+ (meta.size ?? 0) > MAX_MODEL_LANE_BYTES
301
+ ) {
302
+ throw modelLaneTooLarge(attachment.filename, meta.size ?? 0);
303
+ }
304
+
305
+ const { bytes, mimetype } = await loadAttachmentBytes(
306
+ attachment,
307
+ deps.roomId,
308
+ deps.source,
309
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
310
+ );
311
+ verifyMagicBytes(bytes, category, attachment, (m) =>
312
+ deps.logger.warn(`[attachments] ${m}`),
313
+ );
314
+ deps.logger.log(
315
+ `[attachments] ingest "${attachment.filename}" (${mimetype}) (${bytes.length} bytes)`,
316
+ );
317
+ const store = async (
318
+ key: string,
319
+ value: { text: string; truncated: boolean; pages?: number },
320
+ ) => {
321
+ if (!ref || !key) return;
322
+ deps.ingestCache?.put(ref, key, {
323
+ ...value,
324
+ bytes: bytes.length,
325
+ mimetype,
326
+ });
327
+ if (cache && !value.truncated) await cache.put(ref, key, value.text);
328
+ };
329
+
330
+ if (plain) {
331
+ const { text, truncated } = await extractText(
332
+ bytes,
333
+ attachment,
334
+ category,
335
+ null,
336
+ { raw: opts },
337
+ );
338
+ await store(INGEST_TEXT_KEY, { text, truncated: truncated ?? false });
339
+ return result(text, 'text', mimetype, bytes.length, truncated ?? false);
340
+ }
341
+
342
+ if (mimetype === PDF_MIME) {
343
+ try {
344
+ const pdf = await extractPdfText(bytes, {
345
+ maxChars: opts.maxChars,
346
+ signal: deps.signal,
347
+ });
348
+ if (pdf.kind === 'text') {
349
+ await store(INGEST_PDF_KEY, {
350
+ text: pdf.text,
351
+ truncated: pdf.truncated,
352
+ pages: pdf.pages,
353
+ });
354
+ return result(
355
+ pdf.text,
356
+ 'pdf',
357
+ mimetype,
358
+ bytes.length,
359
+ pdf.truncated,
360
+ pdf.pages,
361
+ );
362
+ }
363
+ deps.logger.log(
364
+ `[attachments] ingest "${attachment.filename}": no text layer (${pdf.pages} page(s)), using the helper model`,
365
+ );
366
+ } catch (error) {
367
+ // A cancelled turn stops here; it must not start a model call.
368
+ if (deps.signal?.aborted) throw error;
369
+ deps.logger.warn(
370
+ `[attachments] ingest "${attachment.filename}": local PDF parse failed (${error instanceof Error ? error.message : String(error)}), using the helper model`,
371
+ );
372
+ }
373
+ }
374
+
375
+ if (bytes.length > MAX_MODEL_LANE_BYTES) {
376
+ throw modelLaneTooLarge(attachment.filename, bytes.length);
377
+ }
378
+ const { text, truncated } = await extractText(
379
+ bytes,
380
+ attachment,
381
+ category,
382
+ deps.extraction,
383
+ { fetchImpl: deps.fetchImpl, signal: deps.signal, raw: opts },
384
+ );
385
+ await store(modelKey, { text, truncated: truncated ?? false });
386
+ return result(text, 'model', mimetype, bytes.length, truncated ?? false);
387
+ }
388
+
389
+ /** The message sources {@link findPersistedAttachmentMeta} reads. */
390
+ export interface AttachmentMessageStore {
391
+ /** Messages whose stored text contains `contentContains`, oldest first. */
392
+ listThreadMessagesMatching(
393
+ threadId: string,
394
+ filter: { contentContains: string },
395
+ ): Promise<BaseMessage[]>;
396
+ listThreadMessages(threadId: string): Promise<BaseMessage[]>;
397
+ }
398
+
399
+ /**
400
+ * The attachment of a session with the given reference, from earlier turns.
401
+ * The reference is quoted in the text of the person's message (placeholder),
402
+ * of the hidden extraction text and of the hidden note of a natively sent
403
+ * file, and all of those carry the attachment metadata, so a SQL pre-filter
404
+ * on the reference usually inflates one or two small rows; only when none of
405
+ * them holds it (an inline attachment of an old message) is the whole session
406
+ * loaded.
407
+ */
408
+ export async function findPersistedAttachmentMeta(
409
+ store: AttachmentMessageStore,
410
+ sessionId: string,
411
+ ref: string,
412
+ ): Promise<AttachmentMeta | undefined> {
413
+ const find = (messages: BaseMessage[]) =>
414
+ messages
415
+ .filter((m) => m.type === 'human' || m.type === 'ai')
416
+ .flatMap((m) => attachmentMetas(m))
417
+ .find((meta) => attachmentRef(meta) === ref);
418
+ const candidates = await store.listThreadMessagesMatching(sessionId, {
419
+ contentContains: ref,
420
+ });
421
+ return find(candidates) ?? find(await store.listThreadMessages(sessionId));
422
+ }
423
+
424
+ /**
425
+ * The session's attachment surface. A reference resolves first against the
426
+ * attachments of the turn now running (`turnMetas`: their message may not be
427
+ * persisted yet when a tool runs), then against the persisted session via
428
+ * `findPersisted`.
429
+ */
430
+ export function createAttachmentViewSurface(input: {
431
+ offloaded: boolean;
432
+ turnMetas: readonly AttachmentMeta[];
433
+ findPersisted: (ref: string) => Promise<AttachmentMeta | undefined>;
434
+ deps: () => ViewAttachmentDeps;
435
+ }): AttachmentViewSurface {
436
+ const findMeta = async (ref: string) =>
437
+ input.turnMetas.find((meta) => attachmentRef(meta) === ref) ??
438
+ (await input.findPersisted(ref));
439
+ return {
440
+ offloaded: input.offloaded,
441
+ view: async (ref) => {
442
+ const meta = await findMeta(ref);
443
+ if (!meta) {
444
+ throw new Error(`no attachment with ref "${ref}" in this conversation`);
445
+ }
446
+ return { meta, view: await viewAttachment(meta, input.deps()) };
447
+ },
448
+ extractText: async (ref, opts) => {
449
+ const meta = await findMeta(ref);
450
+ return meta ? extractAttachmentText(meta, input.deps(), opts) : null;
451
+ },
452
+ };
453
+ }
@@ -163,7 +163,10 @@ export function draftReplyPlan(input: {
163
163
  else parts.push({ kind: 'spill', key: `step-${i}`, spill: shape.spill });
164
164
  }
165
165
  for (const call of step.toolCalls) {
166
- if (call.name === 'request_hub_action') {
166
+ if (
167
+ call.name === 'request_hub_action' ||
168
+ call.name === 'request_hub_transfer'
169
+ ) {
167
170
  try {
168
171
  const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
169
172
  if (result?.status === 'prepared') {
@@ -85,7 +85,6 @@ export interface OracleWorkerEnv {
85
85
  AUTH_HUB_URL?: string;
86
86
  AUTH_HUB_CHANNEL_SERVICE_KEY?: string;
87
87
  HUB_ACTIONS_ENABLED?: string;
88
- AUTH_HUB_ACTION_OUTCOME_KEY?: string;
89
88
 
90
89
  // --- matrix -------------------------------------------------------------
91
90
  MATRIX_BASE_URL: string;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Ordering of the messages a turn feeds the graph: hidden attachment context
3
+ * and the person's message.
4
+ */
5
+
6
+ import {
7
+ AIMessage,
8
+ type BaseMessage,
9
+ type HumanMessage,
10
+ } from '@langchain/core/messages';
11
+
12
+ import type { ExtractedAttachment } from '../attachments';
13
+
14
+ export interface TurnMessageParts {
15
+ human: HumanMessage;
16
+ /** Extraction-lane text: follows the HumanMessage (as on Node). */
17
+ extracted: readonly ExtractedAttachment[];
18
+ /**
19
+ * One line per natively-sent file naming its reference. These go BEFORE the
20
+ * HumanMessage so the turn never ends on an assistant message (providers
21
+ * reject or mis-handle an assistant-last conversation).
22
+ */
23
+ nativeNotes: readonly ExtractedAttachment[];
24
+ matrixRoom: boolean;
25
+ now?: () => Date;
26
+ }
27
+
28
+ function hiddenNote(
29
+ item: ExtractedAttachment,
30
+ matrixRoom: boolean,
31
+ now: () => Date,
32
+ ): AIMessage {
33
+ return new AIMessage({
34
+ content: item.text,
35
+ additional_kwargs: {
36
+ msgFromMatrixRoom: matrixRoom,
37
+ timestamp: now().toISOString(),
38
+ attachment: item.meta,
39
+ },
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Native notes, then the HumanMessage, then the extraction-lane text. The
45
+ * hidden messages carry an `attachment` kwarg, which the transcript hides.
46
+ */
47
+ export function assembleTurnMessages(parts: TurnMessageParts): BaseMessage[] {
48
+ const now = parts.now ?? (() => new Date());
49
+ return [
50
+ ...parts.nativeNotes.map((n) => hiddenNote(n, parts.matrixRoom, now)),
51
+ parts.human,
52
+ ...parts.extracted.map((n) => hiddenNote(n, parts.matrixRoom, now)),
53
+ ];
54
+ }