@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 +4 -3
- 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/do/turn-messages.ts +54 -0
- package/src/do/user-oracle-do.ts +53 -73
- package/src/memory/user-context-invalidation.ts +6 -0
- 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/attachments/view.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -111,16 +111,17 @@ import {
|
|
|
111
111
|
applyAttachmentRetention,
|
|
112
112
|
ATTACHMENT_PAYLOAD_TURNS,
|
|
113
113
|
ATTACHMENT_PLACEHOLDER_PREFIX,
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
createAttachmentViewSurface,
|
|
115
|
+
findPersistedAttachmentMeta,
|
|
116
|
+
IngestTextLru,
|
|
116
117
|
INLINE_PAYLOAD_NEEDLE,
|
|
117
118
|
isAttachmentViewMessage,
|
|
118
119
|
offloadInlinePayloads,
|
|
119
120
|
parseAttachmentInputs,
|
|
120
121
|
prepareAttachments,
|
|
121
|
-
viewAttachment,
|
|
122
122
|
type AttachmentViewSurface,
|
|
123
123
|
type MatrixMediaSource,
|
|
124
|
+
type ViewAttachmentDeps,
|
|
124
125
|
type SandboxUploadConfig,
|
|
125
126
|
DOWNLOAD_TIMEOUT_MS,
|
|
126
127
|
MAX_FILE_SIZE,
|
|
@@ -199,6 +200,7 @@ import {
|
|
|
199
200
|
} from '../sqlite/cache-config';
|
|
200
201
|
import { DoSqliteDatabase, type DoSqliteOpenOptions } from '../sqlite/database';
|
|
201
202
|
import type { TierFlushResult } from '../sqlite/do-vfs';
|
|
203
|
+
import type { AttachmentMeta } from '../sqlite/serialization';
|
|
202
204
|
import {
|
|
203
205
|
DEFAULT_TIER_HOT_BUDGET_BYTES,
|
|
204
206
|
type PageTierOptions,
|
|
@@ -272,6 +274,7 @@ import { decideBootDirty } from './boot-dirty';
|
|
|
272
274
|
import { decideFlush } from './flush-schedule';
|
|
273
275
|
import { mirrorTxnId, mirrorEventContent, RoomMirror } from './room-mirror';
|
|
274
276
|
import { prefixSpeaker } from './speaker-prefix';
|
|
277
|
+
import { assembleTurnMessages } from './turn-messages';
|
|
275
278
|
import {
|
|
276
279
|
type ObservedMessage,
|
|
277
280
|
summarizeObservedMessages,
|
|
@@ -1040,6 +1043,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1040
1043
|
private lastActionPruneAt: number | null = null;
|
|
1041
1044
|
/** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
|
|
1042
1045
|
private attachmentTextCache: AttachmentTextCacheStore | null = null;
|
|
1046
|
+
/** Large texts of document ingestion, in memory (a few, minutes). */
|
|
1047
|
+
private readonly ingestTextLru = new IngestTextLru();
|
|
1043
1048
|
/**
|
|
1044
1049
|
* Per-model context windows; learned limits persist in this object's KV
|
|
1045
1050
|
* storage (`ctxwin:<model>`), so a provider's rejection is remembered
|
|
@@ -6877,54 +6882,34 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
6877
6882
|
ambientLlm: LlmAdapter;
|
|
6878
6883
|
platform: OpenRouterLlmAdapter;
|
|
6879
6884
|
signal: AbortSignal;
|
|
6885
|
+
/** Attachments of the turn now running (not necessarily persisted yet). */
|
|
6886
|
+
turnMetas: readonly AttachmentMeta[];
|
|
6880
6887
|
}): Promise<AttachmentViewSurface> {
|
|
6881
6888
|
const saver = this.saver!;
|
|
6882
6889
|
const offloaded = await saver.hasThreadMessageMatching(input.sessionId, {
|
|
6883
6890
|
messageType: 'human',
|
|
6884
6891
|
contentContains: ATTACHMENT_PLACEHOLDER_PREFIX,
|
|
6885
6892
|
});
|
|
6886
|
-
const findMeta =
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
};
|
|
6902
|
-
return {
|
|
6893
|
+
const findMeta = (ref: string) =>
|
|
6894
|
+
findPersistedAttachmentMeta(saver, input.sessionId, ref);
|
|
6895
|
+
const viewDeps = (): ViewAttachmentDeps => ({
|
|
6896
|
+
source: this.matrixMediaSource(),
|
|
6897
|
+
extraction: this.extractionProvider(input.ambientLlm, input.platform),
|
|
6898
|
+
caps: getModelCapabilities(input.model),
|
|
6899
|
+
roomId: input.roomId,
|
|
6900
|
+
signal: input.signal,
|
|
6901
|
+
logger: console,
|
|
6902
|
+
...(this.attachmentTextCache
|
|
6903
|
+
? { cache: this.attachmentTextCache.forSession(input.sessionId) }
|
|
6904
|
+
: {}),
|
|
6905
|
+
ingestCache: this.ingestTextLru.forSession(input.sessionId),
|
|
6906
|
+
});
|
|
6907
|
+
return createAttachmentViewSurface({
|
|
6903
6908
|
offloaded,
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
`no attachment with ref "${ref}" in this conversation`,
|
|
6909
|
-
);
|
|
6910
|
-
}
|
|
6911
|
-
const view = await viewAttachment(meta, {
|
|
6912
|
-
source: this.matrixMediaSource(),
|
|
6913
|
-
extraction: this.extractionProvider(
|
|
6914
|
-
input.ambientLlm,
|
|
6915
|
-
input.platform,
|
|
6916
|
-
),
|
|
6917
|
-
caps: getModelCapabilities(input.model),
|
|
6918
|
-
roomId: input.roomId,
|
|
6919
|
-
signal: input.signal,
|
|
6920
|
-
logger: console,
|
|
6921
|
-
...(this.attachmentTextCache
|
|
6922
|
-
? { cache: this.attachmentTextCache.forSession(input.sessionId) }
|
|
6923
|
-
: {}),
|
|
6924
|
-
});
|
|
6925
|
-
return { meta, view };
|
|
6926
|
-
},
|
|
6927
|
-
};
|
|
6909
|
+
turnMetas: input.turnMetas,
|
|
6910
|
+
findPersisted: findMeta,
|
|
6911
|
+
deps: viewDeps,
|
|
6912
|
+
});
|
|
6928
6913
|
}
|
|
6929
6914
|
|
|
6930
6915
|
/**
|
|
@@ -7300,6 +7285,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7300
7285
|
ambientLlm: baseAmbient.llm,
|
|
7301
7286
|
platform: core.llm,
|
|
7302
7287
|
signal: abortController.signal,
|
|
7288
|
+
turnMetas: prepared?.metas ?? [],
|
|
7303
7289
|
});
|
|
7304
7290
|
|
|
7305
7291
|
// Request metadata (editor room, space, session run, entity) → state,
|
|
@@ -7697,36 +7683,30 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
7697
7683
|
? undefined
|
|
7698
7684
|
: renderTurnTimeNote(turnAt, req.identity.timezone);
|
|
7699
7685
|
const content = timeNote ? withTurnTimeNote(spoken, timeNote) : spoken;
|
|
7686
|
+
const human = new HumanMessage({
|
|
7687
|
+
content,
|
|
7688
|
+
additional_kwargs: {
|
|
7689
|
+
timestamp: turnAt.toISOString(),
|
|
7690
|
+
...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
|
|
7691
|
+
oracleName: core.identity.name,
|
|
7692
|
+
msgFromMatrixRoom: req.client === 'matrix',
|
|
7693
|
+
...speakerKwargs,
|
|
7694
|
+
...(req.channel
|
|
7695
|
+
? { 'org.ixo.qi.origin': channelOrigin(req.channel) }
|
|
7696
|
+
: {}),
|
|
7697
|
+
...attachmentKwargs,
|
|
7698
|
+
},
|
|
7699
|
+
});
|
|
7700
7700
|
const stateInput = {
|
|
7701
|
-
messages
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
...(req.channel
|
|
7711
|
-
? { 'org.ixo.qi.origin': channelOrigin(req.channel) }
|
|
7712
|
-
: {}),
|
|
7713
|
-
...attachmentKwargs,
|
|
7714
|
-
},
|
|
7715
|
-
}),
|
|
7716
|
-
// Extraction-lane text rides as hidden context messages (the
|
|
7717
|
-
// transcript hides AI messages that carry an `attachment`), as on Node.
|
|
7718
|
-
...(prepared?.extracted ?? []).map(
|
|
7719
|
-
(item) =>
|
|
7720
|
-
new AIMessage({
|
|
7721
|
-
content: item.text,
|
|
7722
|
-
additional_kwargs: {
|
|
7723
|
-
msgFromMatrixRoom: req.client === 'matrix',
|
|
7724
|
-
timestamp: new Date().toISOString(),
|
|
7725
|
-
attachment: item.meta,
|
|
7726
|
-
},
|
|
7727
|
-
}),
|
|
7728
|
-
),
|
|
7729
|
-
],
|
|
7701
|
+
// Hidden attachment context (the transcript hides AI messages that
|
|
7702
|
+
// carry an `attachment`): native-lane notes precede the person's
|
|
7703
|
+
// message, extraction-lane text follows it, as on Node.
|
|
7704
|
+
messages: assembleTurnMessages({
|
|
7705
|
+
human,
|
|
7706
|
+
extracted: prepared?.extracted ?? [],
|
|
7707
|
+
nativeNotes: prepared?.nativeNotes ?? [],
|
|
7708
|
+
matrixRoom: req.client === 'matrix',
|
|
7709
|
+
}),
|
|
7730
7710
|
config: { did: req.identity.userDid },
|
|
7731
7711
|
client: req.client,
|
|
7732
7712
|
// First contact's gathered context is checkpointed with the turn,
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
MEMORY_DELETE_EDGE_MCP_NAME,
|
|
20
20
|
MEMORY_DELETE_EPISODE_MCP_NAME,
|
|
21
21
|
} from '../plugins/memory/memory-tools';
|
|
22
|
+
import {
|
|
23
|
+
ADD_ORACLE_DOCUMENT_TOOL_NAME,
|
|
24
|
+
DELETE_ORACLE_DOCUMENT_TOOL_NAME,
|
|
25
|
+
} from '../plugins/memory/oracle-document-tools';
|
|
22
26
|
|
|
23
27
|
/** The memory engine tools that write or delete what the prefetch reads. */
|
|
24
28
|
export const MEMORY_WRITE_TOOL_NAMES: readonly string[] = [
|
|
@@ -27,6 +31,8 @@ export const MEMORY_WRITE_TOOL_NAMES: readonly string[] = [
|
|
|
27
31
|
MEMORY_DELETE_EPISODE_MCP_NAME,
|
|
28
32
|
MEMORY_DELETE_EDGE_MCP_NAME,
|
|
29
33
|
MEMORY_CLEAR_MCP_NAME,
|
|
34
|
+
ADD_ORACLE_DOCUMENT_TOOL_NAME,
|
|
35
|
+
DELETE_ORACLE_DOCUMENT_TOOL_NAME,
|
|
30
36
|
];
|
|
31
37
|
|
|
32
38
|
export interface UserContextInvalidationOptions {
|