@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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Drops a room's prefetched user context (`UserContextFetcher.invalidate`)
|
|
3
3
|
* after a tool call that changed the user's memory succeeded, so the next
|
|
4
4
|
* session in the room gathers again instead of reading the pre-write answer
|
|
5
|
-
* for the rest of the cache TTL.
|
|
6
|
-
*
|
|
5
|
+
* for the rest of the cache TTL. Channel turns adopt the refreshed context
|
|
6
|
+
* on a later turn; other clients keep their first-contact context.
|
|
7
7
|
*
|
|
8
8
|
* Applied as a tool middleware to the main agent and every sub-agent; a
|
|
9
9
|
* failed call (thrown, or an error `ToolMessage`) leaves the cache alone.
|
|
@@ -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 {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
2
|
-
import {
|
|
3
|
-
stripTurnTimeNote,
|
|
4
|
-
TURN_TIME_NOTE_KWARG,
|
|
5
|
-
} from '../../core/turn-time-note';
|
|
2
|
+
import { userContent } from '../../channels/replies';
|
|
6
3
|
import { contentToText } from '../../do/transcript';
|
|
7
4
|
|
|
8
5
|
/** How many of the user's own messages count as evidence, and how much of each. */
|
|
@@ -18,8 +15,10 @@ export const NEGATION =
|
|
|
18
15
|
|
|
19
16
|
/**
|
|
20
17
|
* The user's own words from the last few turns. The runtime's time note
|
|
21
|
-
* (`Current time: … 14:05 …`) rides on every message the user sent
|
|
22
|
-
*
|
|
18
|
+
* (`Current time: … 14:05 …`) rides on every message the user sent, and a
|
|
19
|
+
* WhatsApp reply carries the quoted message; both are stripped here so
|
|
20
|
+
* neither the note's digits nor an address or amount the user merely quoted
|
|
21
|
+
* can stand in for what they typed.
|
|
23
22
|
*/
|
|
24
23
|
export function userEvidence(messages: readonly BaseMessage[]): string[] {
|
|
25
24
|
return messages
|
|
@@ -27,9 +26,9 @@ export function userEvidence(messages: readonly BaseMessage[]): string[] {
|
|
|
27
26
|
.slice(-EVIDENCE_MESSAGES)
|
|
28
27
|
.map((m) =>
|
|
29
28
|
contentToText(
|
|
30
|
-
|
|
29
|
+
userContent(
|
|
31
30
|
m.content,
|
|
32
|
-
m.additional_kwargs
|
|
31
|
+
m.additional_kwargs as Record<string, unknown> | undefined,
|
|
33
32
|
),
|
|
34
33
|
).slice(0, EVIDENCE_MESSAGE_CHARS),
|
|
35
34
|
);
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits extracted document text into chunks the Memory Engine accepts: every
|
|
3
|
+
* chunk is at most `maxChunkBytes` UTF-8 bytes, cut at the largest natural
|
|
4
|
+
* boundary that fits (paragraph, line, sentence) and only then mid-text, never
|
|
5
|
+
* inside a UTF-8 sequence or surrogate pair.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ChunkerOptions {
|
|
9
|
+
/** Upper bound of one chunk in UTF-8 bytes (default 12 000). */
|
|
10
|
+
maxChunkBytes: number;
|
|
11
|
+
/** Most chunks to return (default 64). */
|
|
12
|
+
maxChunks: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DocumentChunk {
|
|
16
|
+
/** Zero-based position in the document. */
|
|
17
|
+
index: number;
|
|
18
|
+
/** `p. 3`, `p. 3–5` when the text carries page markers, else `part 2 of 7`. */
|
|
19
|
+
label: string;
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChunkedDocument {
|
|
24
|
+
chunks: DocumentChunk[];
|
|
25
|
+
/** More chunks were needed than `maxChunks`; only the first ones are returned. */
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
/** UTF-8 size of the normalised text. */
|
|
28
|
+
totalBytes: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_CHUNK_BYTES = 12_000;
|
|
32
|
+
export const DEFAULT_MAX_CHUNKS = 64;
|
|
33
|
+
|
|
34
|
+
const MIN_CHUNK_BYTES = 4;
|
|
35
|
+
/** A whole line that only names a page: `--- page 3 ---`, `Page 3`, `p. 3`, `Page 3 of 10`. */
|
|
36
|
+
const PAGE_MARKER =
|
|
37
|
+
/^\s*(?:---\s*page\s+(\d+)\s*---|(?:page|p\.)\s*(\d+)(?:\s*(?:of|\/)\s*\d+)?)\s*$/i;
|
|
38
|
+
|
|
39
|
+
/** UTF-8 length of a string, counting a lone surrogate as U+FFFD (3 bytes). */
|
|
40
|
+
export function utf8ByteLength(text: string): number {
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
43
|
+
const code = text.charCodeAt(i);
|
|
44
|
+
if (code < 0x80) bytes += 1;
|
|
45
|
+
else if (code < 0x800) bytes += 2;
|
|
46
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
47
|
+
const next = text.charCodeAt(i + 1);
|
|
48
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
49
|
+
bytes += 4;
|
|
50
|
+
i += 1;
|
|
51
|
+
} else bytes += 3;
|
|
52
|
+
} else bytes += 3;
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Paragraph {
|
|
58
|
+
text: string;
|
|
59
|
+
page: number | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Unit {
|
|
63
|
+
text: string;
|
|
64
|
+
/** What joins this unit to the previous one inside a chunk. */
|
|
65
|
+
sep: string;
|
|
66
|
+
page: number | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drops trailing spaces and tabs in one backwards pass (no regex backtracking; a form feed stays). */
|
|
70
|
+
function trimLineEnd(line: string): string {
|
|
71
|
+
let end = line.length;
|
|
72
|
+
while (end > 0 && (line[end - 1] === ' ' || line[end - 1] === '\t')) end -= 1;
|
|
73
|
+
return end === line.length ? line : line.slice(0, end);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalise(text: string): string {
|
|
77
|
+
return text
|
|
78
|
+
.replace(/\r\n?/g, '\n')
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map(trimLineEnd)
|
|
81
|
+
.join('\n')
|
|
82
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isMarkerOnly(text: string): boolean {
|
|
87
|
+
return !text.includes('\n') && PAGE_MARKER.test(text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function groupText(group: readonly Unit[]): string {
|
|
91
|
+
return group.reduce(
|
|
92
|
+
(acc, u, i) => (i === 0 ? u.text : acc + u.sep + u.text),
|
|
93
|
+
'',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A group that only names pages (`--- page 4 ---`) says nothing: it joins the
|
|
99
|
+
* start of the next group, else the end of the previous one, if it fits, and
|
|
100
|
+
* is dropped otherwise (the page of every unit is already known).
|
|
101
|
+
*/
|
|
102
|
+
function absorbMarkerGroups(groups: Unit[][], maxBytes: number): Unit[][] {
|
|
103
|
+
const out: Unit[][] = [];
|
|
104
|
+
let carry: Unit[] = [];
|
|
105
|
+
for (const group of groups) {
|
|
106
|
+
if (group.every((u) => isMarkerOnly(u.text))) {
|
|
107
|
+
carry = [...carry, ...group];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
carry.length > 0 &&
|
|
112
|
+
utf8ByteLength(groupText([...carry, ...group])) <= maxBytes
|
|
113
|
+
) {
|
|
114
|
+
out.push([...carry, ...group]);
|
|
115
|
+
} else {
|
|
116
|
+
const last = out[out.length - 1];
|
|
117
|
+
if (
|
|
118
|
+
carry.length > 0 &&
|
|
119
|
+
last &&
|
|
120
|
+
utf8ByteLength(groupText([...last, ...carry])) <= maxBytes
|
|
121
|
+
) {
|
|
122
|
+
last.push(...carry);
|
|
123
|
+
}
|
|
124
|
+
out.push(group);
|
|
125
|
+
}
|
|
126
|
+
carry = [];
|
|
127
|
+
}
|
|
128
|
+
const last = out[out.length - 1];
|
|
129
|
+
if (
|
|
130
|
+
carry.length > 0 &&
|
|
131
|
+
last &&
|
|
132
|
+
utf8ByteLength(groupText([...last, ...carry])) <= maxBytes
|
|
133
|
+
) {
|
|
134
|
+
last.push(...carry);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Paragraphs in order, each tagged with the page it starts on (if paged). */
|
|
140
|
+
function splitParagraphs(text: string): {
|
|
141
|
+
paragraphs: Paragraph[];
|
|
142
|
+
paged: boolean;
|
|
143
|
+
} {
|
|
144
|
+
const lines = text.replace(/\f/g, '\n\f\n').split('\n');
|
|
145
|
+
const paged =
|
|
146
|
+
text.includes('\f') || lines.some((line) => PAGE_MARKER.test(line));
|
|
147
|
+
const paragraphs: Paragraph[] = [];
|
|
148
|
+
let page: number | undefined = paged ? 1 : undefined;
|
|
149
|
+
let current: string[] = [];
|
|
150
|
+
let currentPage = page;
|
|
151
|
+
|
|
152
|
+
const flush = (): void => {
|
|
153
|
+
const joined = current.join('\n').trim();
|
|
154
|
+
if (joined) paragraphs.push({ text: joined, page: currentPage });
|
|
155
|
+
current = [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (line === '\f') {
|
|
160
|
+
flush();
|
|
161
|
+
page = (page ?? 1) + 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (line.trim() === '') {
|
|
165
|
+
flush();
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const marker = PAGE_MARKER.exec(line);
|
|
169
|
+
if (marker) {
|
|
170
|
+
flush();
|
|
171
|
+
page = Number(marker[1] ?? marker[2]);
|
|
172
|
+
}
|
|
173
|
+
if (current.length === 0) currentPage = page;
|
|
174
|
+
current.push(line);
|
|
175
|
+
}
|
|
176
|
+
flush();
|
|
177
|
+
return { paragraphs, paged };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface Level {
|
|
181
|
+
sep: string;
|
|
182
|
+
split: (text: string) => string[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Boundaries tried in order when a piece is still too large. */
|
|
186
|
+
const LEVELS: readonly Level[] = [
|
|
187
|
+
{
|
|
188
|
+
sep: '\n',
|
|
189
|
+
split: (t) =>
|
|
190
|
+
t
|
|
191
|
+
.split('\n')
|
|
192
|
+
.map((l) => l.trim())
|
|
193
|
+
.filter(Boolean),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
sep: ' ',
|
|
197
|
+
split: (t) =>
|
|
198
|
+
t
|
|
199
|
+
.split(/(?<=[.!?。!?])\s+/u)
|
|
200
|
+
.map((s) => s.trim())
|
|
201
|
+
.filter(Boolean),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
function hardSplit(text: string, maxBytes: number): string[] {
|
|
206
|
+
const pieces: string[] = [];
|
|
207
|
+
let start = 0;
|
|
208
|
+
let bytes = 0;
|
|
209
|
+
let i = 0;
|
|
210
|
+
while (i < text.length) {
|
|
211
|
+
const code = text.codePointAt(i) ?? 0;
|
|
212
|
+
const width = code > 0xffff ? 2 : 1;
|
|
213
|
+
const size = utf8ByteLength(text.slice(i, i + width));
|
|
214
|
+
if (bytes + size > maxBytes) {
|
|
215
|
+
pieces.push(text.slice(start, i));
|
|
216
|
+
start = i;
|
|
217
|
+
bytes = 0;
|
|
218
|
+
}
|
|
219
|
+
bytes += size;
|
|
220
|
+
i += width;
|
|
221
|
+
}
|
|
222
|
+
if (start < text.length) pieces.push(text.slice(start));
|
|
223
|
+
return pieces;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function fit(
|
|
227
|
+
text: string,
|
|
228
|
+
sep: string,
|
|
229
|
+
levels: readonly Level[],
|
|
230
|
+
maxBytes: number,
|
|
231
|
+
page: number | undefined,
|
|
232
|
+
): Unit[] {
|
|
233
|
+
if (utf8ByteLength(text) <= maxBytes) return [{ text, sep, page }];
|
|
234
|
+
const [level, ...rest] = levels;
|
|
235
|
+
if (!level) {
|
|
236
|
+
return hardSplit(text, maxBytes).map((piece, i) => ({
|
|
237
|
+
text: piece,
|
|
238
|
+
sep: i === 0 ? sep : '',
|
|
239
|
+
page,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
const parts = level.split(text);
|
|
243
|
+
if (parts.length <= 1) return fit(text, sep, rest, maxBytes, page);
|
|
244
|
+
return parts.flatMap((part, i) =>
|
|
245
|
+
fit(part, i === 0 ? sep : level.sep, rest, maxBytes, page),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function pageLabel(first: number, last: number): string {
|
|
250
|
+
return first === last ? `p. ${first}` : `p. ${first}–${last}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function chunkDocument(
|
|
254
|
+
text: string,
|
|
255
|
+
opts: Partial<ChunkerOptions> = {},
|
|
256
|
+
): ChunkedDocument {
|
|
257
|
+
const maxChunkBytes = opts.maxChunkBytes ?? DEFAULT_CHUNK_BYTES;
|
|
258
|
+
const maxChunks = opts.maxChunks ?? DEFAULT_MAX_CHUNKS;
|
|
259
|
+
if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < MIN_CHUNK_BYTES) {
|
|
260
|
+
throw new RangeError(
|
|
261
|
+
`maxChunkBytes must be an integer >= ${MIN_CHUNK_BYTES}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!Number.isInteger(maxChunks) || maxChunks < 1) {
|
|
265
|
+
throw new RangeError('maxChunks must be a positive integer');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const normalised = normalise(text);
|
|
269
|
+
if (!normalised) return { chunks: [], truncated: false, totalBytes: 0 };
|
|
270
|
+
|
|
271
|
+
const { paragraphs, paged } = splitParagraphs(normalised);
|
|
272
|
+
const units = paragraphs
|
|
273
|
+
.flatMap((p) => fit(p.text, '\n\n', LEVELS, maxChunkBytes, p.page))
|
|
274
|
+
.filter((u) => u.text.trim() !== '');
|
|
275
|
+
|
|
276
|
+
const groups: Unit[][] = [];
|
|
277
|
+
let group: Unit[] = [];
|
|
278
|
+
let groupBytes = 0;
|
|
279
|
+
for (const unit of units) {
|
|
280
|
+
const unitBytes = utf8ByteLength(unit.text);
|
|
281
|
+
// Separators are ASCII, so their length is their byte length.
|
|
282
|
+
const joined = groupBytes + unit.sep.length + unitBytes;
|
|
283
|
+
if (group.length > 0 && joined <= maxChunkBytes) {
|
|
284
|
+
group.push(unit);
|
|
285
|
+
groupBytes = joined;
|
|
286
|
+
} else {
|
|
287
|
+
if (group.length > 0) groups.push(group);
|
|
288
|
+
group = [unit];
|
|
289
|
+
groupBytes = unitBytes;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (group.length > 0) groups.push(group);
|
|
293
|
+
|
|
294
|
+
const kept = absorbMarkerGroups(groups, maxChunkBytes);
|
|
295
|
+
const total = kept.length;
|
|
296
|
+
const chunks = kept.slice(0, maxChunks).map((members, index) => {
|
|
297
|
+
const body = groupText(members);
|
|
298
|
+
const pages = members
|
|
299
|
+
.map((u) => u.page)
|
|
300
|
+
.filter((p): p is number => p !== undefined);
|
|
301
|
+
const label =
|
|
302
|
+
paged && pages.length > 0
|
|
303
|
+
? pageLabel(Math.min(...pages), Math.max(...pages))
|
|
304
|
+
: `part ${index + 1} of ${total}`;
|
|
305
|
+
return { index, label, content: body };
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
chunks,
|
|
310
|
+
truncated: total > maxChunks,
|
|
311
|
+
totalBytes: utf8ByteLength(normalised),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the memory plugin's document tools get a document's text from: a chat
|
|
3
|
+
* attachment of the session (through the host's `AttachmentViewSurface`) or a
|
|
4
|
+
* file in the user's VFS. Both lanes return the document's own words — plain
|
|
5
|
+
* text decoded locally, a PDF's text layer parsed locally, anything else (a
|
|
6
|
+
* scan, an image, an Office file) transcribed by the helper model — never a
|
|
7
|
+
* stub or a `[Content of …]` wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Every error thrown here carries a message meant for the person (the tools
|
|
10
|
+
* relay it as is).
|
|
11
|
+
*/
|
|
12
|
+
import { capText } from '../../attachments/extract';
|
|
13
|
+
import { extractPdfText } from '../../attachments/pdf-text';
|
|
14
|
+
import type { RuntimeContext } from '../../plugin-api/types';
|
|
15
|
+
import type { VfsClient, VfsContentBytes } from '../vfs/vfs-client';
|
|
16
|
+
import { mapVfsError, VfsAuthError, VfsHttpError } from '../vfs/vfs-errors';
|
|
17
|
+
import {
|
|
18
|
+
isTextMime,
|
|
19
|
+
MAX_VISION_BYTES,
|
|
20
|
+
renderBinaryForIngestion,
|
|
21
|
+
} from '../vfs/vfs-content';
|
|
22
|
+
import { buildClient } from '../vfs/vfs-tools';
|
|
23
|
+
import { resolveVfsConfig, type VfsConfig } from '../vfs/vfs.plugin';
|
|
24
|
+
|
|
25
|
+
export type DocumentSource = { attachment_ref: string } | { vfs_path: string };
|
|
26
|
+
|
|
27
|
+
export interface LoadedDocument {
|
|
28
|
+
text: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
mimetype: string;
|
|
31
|
+
/** Size of the original file in bytes (of the text when the host did not report it). */
|
|
32
|
+
bytes: number;
|
|
33
|
+
method: 'text' | 'pdf' | 'model';
|
|
34
|
+
/** Page count, set when the text came from a PDF's text layer. */
|
|
35
|
+
pages?: number;
|
|
36
|
+
/** True when the text was cut at `maxChars`. */
|
|
37
|
+
truncated: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DocumentSourceDeps {
|
|
41
|
+
/** Longest text returned (characters); more is cut and reported as `truncated`. */
|
|
42
|
+
maxChars: number;
|
|
43
|
+
/** Injected transport for the VFS client (tests). */
|
|
44
|
+
fetchImpl?: typeof fetch;
|
|
45
|
+
/** Retry backoff forwarded to the VFS client (tests pass `0`). */
|
|
46
|
+
retryDelayMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PDF_MIME = 'application/pdf';
|
|
50
|
+
|
|
51
|
+
const LIBRARY_DISABLED = 'Library files are not enabled on this oracle.';
|
|
52
|
+
|
|
53
|
+
function basename(path: string): string {
|
|
54
|
+
const i = path.lastIndexOf('/');
|
|
55
|
+
return i >= 0 ? path.slice(i + 1) : path;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function describe(error: unknown): string {
|
|
59
|
+
return error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requireText(loaded: LoadedDocument): LoadedDocument {
|
|
63
|
+
if (loaded.text.trim().length === 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`No text could be extracted from "${loaded.filename}" (${loaded.mimetype}).`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return loaded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function loadDocumentText(
|
|
72
|
+
ctx: RuntimeContext,
|
|
73
|
+
source: DocumentSource,
|
|
74
|
+
deps: DocumentSourceDeps,
|
|
75
|
+
): Promise<LoadedDocument> {
|
|
76
|
+
return requireText(
|
|
77
|
+
'attachment_ref' in source
|
|
78
|
+
? await loadAttachment(ctx, source.attachment_ref, deps)
|
|
79
|
+
: await loadVfsFile(ctx, source.vfs_path, deps),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadAttachment(
|
|
84
|
+
ctx: RuntimeContext,
|
|
85
|
+
ref: string,
|
|
86
|
+
deps: DocumentSourceDeps,
|
|
87
|
+
): Promise<LoadedDocument> {
|
|
88
|
+
if (!ctx.attachments) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'Chat attachments cannot be read from this session; use a file from the Library (vfs_path) instead.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
let extracted: Awaited<ReturnType<typeof ctx.attachments.extractText>>;
|
|
94
|
+
try {
|
|
95
|
+
extracted = await ctx.attachments.extractText(ref, {
|
|
96
|
+
maxChars: deps.maxChars,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`Could not read attachment ${ref}: ${describe(error)}`);
|
|
100
|
+
}
|
|
101
|
+
if (!extracted) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`No attachment ${ref} in this conversation; ask the person to attach the file again.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return extracted;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function loadVfsFile(
|
|
110
|
+
ctx: RuntimeContext,
|
|
111
|
+
path: string,
|
|
112
|
+
deps: DocumentSourceDeps,
|
|
113
|
+
): Promise<LoadedDocument> {
|
|
114
|
+
// Library files are the Files plugin's: an oracle that does not run it
|
|
115
|
+
// does not expose them through the memory tools either.
|
|
116
|
+
if (!ctx.availablePlugins.has('vfs')) throw new Error(LIBRARY_DISABLED);
|
|
117
|
+
if (!ctx.ucan.hasSigningKey()) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'The Library (files) is not available: this oracle has no UCAN signing key.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
let cfg: VfsConfig;
|
|
123
|
+
try {
|
|
124
|
+
cfg = resolveVfsConfig(ctx.config);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(LIBRARY_DISABLED);
|
|
127
|
+
}
|
|
128
|
+
const client = buildClient(ctx, {
|
|
129
|
+
cfg,
|
|
130
|
+
fetchImpl: deps.fetchImpl,
|
|
131
|
+
retryDelayMs: deps.retryDelayMs,
|
|
132
|
+
});
|
|
133
|
+
const oracleDid = oracleDidOf(ctx);
|
|
134
|
+
try {
|
|
135
|
+
return await readVfsFile(ctx, client, path, deps);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Errors of the VFS family become the same sentences `vfs_read` gives;
|
|
138
|
+
// anything else already carries its own message.
|
|
139
|
+
if (error instanceof VfsAuthError || error instanceof VfsHttpError) {
|
|
140
|
+
throw new Error(mapVfsError(error, { path, oracleDid }));
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function oracleDidOf(ctx: RuntimeContext): string | undefined {
|
|
147
|
+
const did = (ctx.config as { ORACLE_DID?: unknown } | undefined)?.ORACLE_DID;
|
|
148
|
+
return typeof did === 'string' && did.length > 0 ? did : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readVfsFile(
|
|
152
|
+
ctx: RuntimeContext,
|
|
153
|
+
client: VfsClient,
|
|
154
|
+
path: string,
|
|
155
|
+
deps: DocumentSourceDeps,
|
|
156
|
+
): Promise<LoadedDocument> {
|
|
157
|
+
const stat = await client.statByPath(path);
|
|
158
|
+
if (!stat) throw new Error(`No file at ${path} in your files.`);
|
|
159
|
+
const mimetype = stat.mimeType || 'application/octet-stream';
|
|
160
|
+
const filename = stat.name || basename(stat.path) || stat.id;
|
|
161
|
+
if (stat.size > MAX_VISION_BYTES) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`"${filename}" is ${stat.size} bytes, over the ${MAX_VISION_BYTES} byte limit for documents.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isTextMime(mimetype)) {
|
|
168
|
+
const { bytes } = await client.contentBytes(stat.id, MAX_VISION_BYTES);
|
|
169
|
+
// At most 3 UTF-8 bytes per UTF-16 unit: decoding past this adds nothing.
|
|
170
|
+
const head = new Uint8Array(bytes).subarray(0, deps.maxChars * 3 + 4);
|
|
171
|
+
const capped = capText(new TextDecoder().decode(head), deps.maxChars);
|
|
172
|
+
return {
|
|
173
|
+
...capped,
|
|
174
|
+
filename,
|
|
175
|
+
mimetype,
|
|
176
|
+
bytes: bytes.byteLength,
|
|
177
|
+
method: 'text',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Only a PDF is downloaded here (to try its text layer); any other type
|
|
182
|
+
// is downloaded by the render step itself, after its own type/size checks.
|
|
183
|
+
let downloaded: VfsContentBytes | undefined;
|
|
184
|
+
if (mimetype === PDF_MIME) {
|
|
185
|
+
downloaded = await client.contentBytes(stat.id, MAX_VISION_BYTES);
|
|
186
|
+
try {
|
|
187
|
+
const pdf = await extractPdfText(new Uint8Array(downloaded.bytes), {
|
|
188
|
+
maxChars: deps.maxChars,
|
|
189
|
+
signal: ctx.abortSignal,
|
|
190
|
+
});
|
|
191
|
+
if (pdf.kind === 'text') {
|
|
192
|
+
return {
|
|
193
|
+
text: pdf.text,
|
|
194
|
+
truncated: pdf.truncated,
|
|
195
|
+
filename,
|
|
196
|
+
mimetype,
|
|
197
|
+
bytes: downloaded.bytes.byteLength,
|
|
198
|
+
method: 'pdf',
|
|
199
|
+
pages: pdf.pages,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
ctx.logger.log(
|
|
203
|
+
`[memory] document "${filename}": no text layer (${pdf.pages} page(s)), using the helper model`,
|
|
204
|
+
);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (ctx.abortSignal.aborted) throw error;
|
|
207
|
+
ctx.logger.warn(
|
|
208
|
+
`[memory] document "${filename}": local PDF parse failed (${describe(error)}), using the helper model`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The bytes are already in hand: the render step does not download again.
|
|
214
|
+
const rendered = await renderBinaryForIngestion(
|
|
215
|
+
client,
|
|
216
|
+
ctx,
|
|
217
|
+
stat,
|
|
218
|
+
downloaded,
|
|
219
|
+
);
|
|
220
|
+
const capped = capText(rendered.text, deps.maxChars);
|
|
221
|
+
return {
|
|
222
|
+
text: capped.text,
|
|
223
|
+
truncated: capped.truncated || rendered.truncated,
|
|
224
|
+
filename,
|
|
225
|
+
mimetype,
|
|
226
|
+
bytes: stat.size,
|
|
227
|
+
method: 'model',
|
|
228
|
+
};
|
|
229
|
+
}
|