@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
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '../../plugin-api/tool-helper';
|
|
3
|
+
import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
|
|
4
|
+
import {
|
|
5
|
+
chunkDocument,
|
|
6
|
+
DEFAULT_CHUNK_BYTES,
|
|
7
|
+
DEFAULT_MAX_CHUNKS,
|
|
8
|
+
} from './document-chunker';
|
|
9
|
+
import { loadDocumentText } from './document-source';
|
|
10
|
+
import {
|
|
11
|
+
createMemoryEngineClient,
|
|
12
|
+
entityDidOrWarn,
|
|
13
|
+
MemoryEngineError,
|
|
14
|
+
type AddDocumentRequest,
|
|
15
|
+
type DocumentSummary,
|
|
16
|
+
type MemoryEngineClient,
|
|
17
|
+
} from './memory-engine-client';
|
|
18
|
+
|
|
19
|
+
export const ADD_ORACLE_DOCUMENT_TOOL_NAME = 'add_oracle_document';
|
|
20
|
+
export const LIST_ORACLE_DOCUMENTS_TOOL_NAME = 'list_oracle_documents';
|
|
21
|
+
export const DELETE_ORACLE_DOCUMENT_TOOL_NAME = 'delete_oracle_document';
|
|
22
|
+
|
|
23
|
+
/** The engine refuses one document whose chunks total more than this (bytes). */
|
|
24
|
+
export const ENGINE_DOCUMENT_TOTAL_BYTES = 900_000;
|
|
25
|
+
|
|
26
|
+
export interface OracleDocumentToolsDeps {
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
|
31
|
+
|
|
32
|
+
const spaceSchema = z
|
|
33
|
+
.enum(['public', 'private'])
|
|
34
|
+
.describe(
|
|
35
|
+
"Which knowledge space: 'public' is shared with every user of this oracle; 'private' is visible only to the domain's members (its controllers, owner, admins and DAO members).",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const sourceSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
kind: z
|
|
41
|
+
.enum(['attachment', 'vfs'])
|
|
42
|
+
.describe(
|
|
43
|
+
"'attachment' for a file the person attached in this conversation, 'vfs' for a file in their Library.",
|
|
44
|
+
),
|
|
45
|
+
ref: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1)
|
|
48
|
+
.describe(
|
|
49
|
+
'For kind attachment: the value inside the `[source: eventId="…"]` (or `[source: url="…"]`) line that accompanies the attached file, passed exactly as given (an event id such as "$abc…" or an mxc:// URL). For kind vfs: the file path in the Library, for example /private/policies/travel.pdf.',
|
|
50
|
+
),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
.describe('Where the file is.');
|
|
54
|
+
|
|
55
|
+
export const addOracleDocumentSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
source: sourceSchema,
|
|
58
|
+
title: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.max(200)
|
|
62
|
+
.describe('A short human title for the document, 1 to 200 characters.'),
|
|
63
|
+
knowledge_space_type: spaceSchema,
|
|
64
|
+
group_as_document: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.describe(
|
|
67
|
+
'Required, no default. true: the file is ONE coherent record and is kept together under a document id (it can be listed and removed as one). false: the file is a collection of unrelated facts and each part is stored as ordinary loose knowledge with no document grouping. Decide with the person, see the tool description.',
|
|
68
|
+
),
|
|
69
|
+
document_id: z
|
|
70
|
+
.string()
|
|
71
|
+
.regex(DOCUMENT_ID_PATTERN)
|
|
72
|
+
.optional()
|
|
73
|
+
.describe(
|
|
74
|
+
"Only with group_as_document true (ignored otherwise). A stable id such as 'board-resolution-2026-10' (letters, digits, '.', '_' and '-', up to 120 characters, starting with a letter or digit). Adding a document with an id that already exists replaces it. Leave out and the tool derives one from the title and the content, so adding the same file again replaces it.",
|
|
75
|
+
),
|
|
76
|
+
source_description: z
|
|
77
|
+
.string()
|
|
78
|
+
.max(500)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(
|
|
81
|
+
"Only with group_as_document true (ignored otherwise). One line saying where the document comes from, up to 500 characters (for example 'Signed PDF from the board secretary, 12 Oct 2026').",
|
|
82
|
+
),
|
|
83
|
+
if_exists: z
|
|
84
|
+
.enum(['replace', 'fail'])
|
|
85
|
+
.optional()
|
|
86
|
+
.describe(
|
|
87
|
+
"Only with group_as_document true (ignored otherwise). 'replace' (default) overwrites a grouped document with the same document_id; 'fail' refuses instead.",
|
|
88
|
+
),
|
|
89
|
+
allow_truncation: z
|
|
90
|
+
.boolean()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
'Default false. When the file is larger than the limits the tool refuses and tells you the size. Set true only after the person agreed that the first part of the file is enough.',
|
|
94
|
+
),
|
|
95
|
+
confirmed_insertion_from_user: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.describe(
|
|
98
|
+
'true only when the person has explicitly asked you to store this file as oracle knowledge after you told them who will be able to see it. Otherwise ask first.',
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
.strict();
|
|
102
|
+
|
|
103
|
+
export const DEFAULT_LIST_LIMIT = 100;
|
|
104
|
+
export const MAX_LIST_LIMIT = 500;
|
|
105
|
+
|
|
106
|
+
export const listOracleDocumentsSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
knowledge_space_type: spaceSchema,
|
|
109
|
+
status: z
|
|
110
|
+
.enum(['ingesting', 'ready', 'partial', 'deleting', 'delete_failed'])
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('Only list documents in this status. Leave out for all.'),
|
|
113
|
+
limit: z
|
|
114
|
+
.number()
|
|
115
|
+
.int()
|
|
116
|
+
.min(1)
|
|
117
|
+
.max(MAX_LIST_LIMIT)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(
|
|
120
|
+
`Most documents to list, 1 to ${MAX_LIST_LIMIT} (default ${DEFAULT_LIST_LIMIT}).`,
|
|
121
|
+
),
|
|
122
|
+
})
|
|
123
|
+
.strict();
|
|
124
|
+
|
|
125
|
+
export const deleteOracleDocumentSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
document_id: z
|
|
128
|
+
.string()
|
|
129
|
+
.regex(DOCUMENT_ID_PATTERN)
|
|
130
|
+
.describe(
|
|
131
|
+
'The id of a grouped document, as shown by list_oracle_documents.',
|
|
132
|
+
),
|
|
133
|
+
knowledge_space_type: spaceSchema,
|
|
134
|
+
confirmed_deletion_from_user: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.describe(
|
|
137
|
+
'true only when the person has explicitly asked you to remove this document after you told them it will be deleted for everyone who can see that space.',
|
|
138
|
+
),
|
|
139
|
+
})
|
|
140
|
+
.strict();
|
|
141
|
+
|
|
142
|
+
export const ADD_ORACLE_DOCUMENT_DESCRIPTION = [
|
|
143
|
+
"Use this, not memory-engine__add_oracle_knowledge, whenever the knowledge comes from a file: one the person attached or one in their Library, even when the file's text is visible in the conversation. Store that file as this oracle's domain knowledge. For an attachment, set source to kind 'attachment' and ref to the value inside the `[source: eventId=…]` (or `[source: url=…]`) line that accompanies the attached file: pass that eventId or mxc URL as ref. For a Library file, kind 'vfs' and ref the path. The text is read out of the file, split into parts and written to the public or private knowledge space of this oracle. Only a controller, owner, admin or DAO member of the domain can do this; the memory engine checks and refuses anyone else.",
|
|
144
|
+
'',
|
|
145
|
+
'Decide with group_as_document, which is required and has no default:',
|
|
146
|
+
'- true: the file is ONE coherent record that belongs together, such as a board resolution, a contract or a report. It is stored under one document id, so it can be listed with list_oracle_documents, replaced by adding it again with the same document_id, and removed as one with delete_oracle_document.',
|
|
147
|
+
'- false: the file is a place where someone dumped many unrelated company facts to upload them easily (for example a notes file with the office address, the refund rule, the CEO name and the opening hours). Each part is stored as an ordinary knowledge fact with no document grouping. Loose facts cannot be listed as a document or removed as a group; they are found by search and removed one by one with delete_episode.',
|
|
148
|
+
'',
|
|
149
|
+
'If it is not obvious which one the file is, ask the person before you call this tool. Say what you found in the file in one sentence and offer the two options in plain words. Never pick one silently when the file could be either.',
|
|
150
|
+
'',
|
|
151
|
+
'Always confirm first: tell the person whether the public space (every user of this oracle) or the private space (the members of the domain only: controllers, owner, admins and DAO members) will hold the content, and set confirmed_insertion_from_user to true only after they say yes. Do not store files the person did not ask you to store.',
|
|
152
|
+
'',
|
|
153
|
+
'Limits: a text file or a PDF with a text layer up to 25 MB; a scanned PDF, an image or an Office file (read by a helper model) up to 10 MB. Text is split into parts of about 12 KB, at most 64 parts (about 750 KB of text, the memory engine accepts 900 KB per document). A larger file is refused with its size unless allow_truncation is true, which keeps only the first part of the file; use that only if the person agrees. Without a document_id a grouped document gets one derived from the title and content, so repeating the call after a timeout replaces rather than duplicates; the result reports the id.',
|
|
154
|
+
'The result says how many parts were queued. Indexing continues in the background; for a grouped document list_oracle_documents shows the status (ingesting, ready, partial, deleting or delete_failed).',
|
|
155
|
+
].join('\n');
|
|
156
|
+
|
|
157
|
+
export const LIST_ORACLE_DOCUMENTS_DESCRIPTION =
|
|
158
|
+
"List the grouped documents in one of this oracle's knowledge spaces with their id, title, status (ingesting, ready, partial, deleting or delete_failed) and number of parts. Optional status filter and limit (default 100, at most 500). Loose facts stored with group_as_document false are not listed here; they are ordinary knowledge found by search.";
|
|
159
|
+
|
|
160
|
+
export const DELETE_ORACLE_DOCUMENT_DESCRIPTION =
|
|
161
|
+
'Remove a grouped document and every one of its parts from the public or private knowledge space of this oracle. Use the id from list_oracle_documents. This cannot be undone and it takes effect for everyone who can see that space, so tell the person which document will go and set confirmed_deletion_from_user to true only after they say yes. Loose facts are not documents; remove those with delete_episode. The removal runs in the background; if list_oracle_documents later shows the document as delete_failed, call this again to retry it.';
|
|
162
|
+
|
|
163
|
+
const NO_ENGINE_MESSAGE =
|
|
164
|
+
'Memory engine is not reachable from this oracle (missing MEMORY_ENGINE_URL or signing key).';
|
|
165
|
+
|
|
166
|
+
const knobsSchema = z.object({
|
|
167
|
+
MEMORY_DOCUMENT_CHUNK_BYTES: z.coerce
|
|
168
|
+
.number()
|
|
169
|
+
.int()
|
|
170
|
+
.min(1024)
|
|
171
|
+
.max(16_384)
|
|
172
|
+
.default(DEFAULT_CHUNK_BYTES),
|
|
173
|
+
MEMORY_DOCUMENT_MAX_CHUNKS: z.coerce
|
|
174
|
+
.number()
|
|
175
|
+
.int()
|
|
176
|
+
.min(1)
|
|
177
|
+
.max(64)
|
|
178
|
+
.default(DEFAULT_MAX_CHUNKS),
|
|
179
|
+
MEMORY_DOCUMENT_MAX_CHARS: z.coerce.number().int().min(1_000).optional(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export interface DocumentKnobs {
|
|
183
|
+
chunkBytes: number;
|
|
184
|
+
maxChunks: number;
|
|
185
|
+
/** Most characters read from one file. */
|
|
186
|
+
maxChars: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let warnedInvalid = false;
|
|
190
|
+
let warnedClamp = false;
|
|
191
|
+
|
|
192
|
+
/** Forget that the knob warnings were logged (tests only). */
|
|
193
|
+
export function resetDocumentKnobWarningsForTests(): void {
|
|
194
|
+
warnedInvalid = false;
|
|
195
|
+
warnedClamp = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The effective limits. `chunkBytes × maxChunks` never exceeds the engine's
|
|
200
|
+
* 900 KB per-document cap (the part count is clamped, with one warning per
|
|
201
|
+
* isolate), and the character cap defaults to that product so the reader,
|
|
202
|
+
* the chunker and the engine agree on one limit.
|
|
203
|
+
*/
|
|
204
|
+
export function readKnobs(ctx: RuntimeContext): DocumentKnobs {
|
|
205
|
+
const parsed = knobsSchema.safeParse({
|
|
206
|
+
MEMORY_DOCUMENT_CHUNK_BYTES: ctx.config.MEMORY_DOCUMENT_CHUNK_BYTES,
|
|
207
|
+
MEMORY_DOCUMENT_MAX_CHUNKS: ctx.config.MEMORY_DOCUMENT_MAX_CHUNKS,
|
|
208
|
+
MEMORY_DOCUMENT_MAX_CHARS: ctx.config.MEMORY_DOCUMENT_MAX_CHARS,
|
|
209
|
+
});
|
|
210
|
+
const knobs = parsed.success ? parsed.data : knobsSchema.parse({});
|
|
211
|
+
if (!parsed.success && !warnedInvalid) {
|
|
212
|
+
warnedInvalid = true;
|
|
213
|
+
ctx.logger.warn(
|
|
214
|
+
'[memory] invalid MEMORY_DOCUMENT_* setting; using the defaults',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const chunkBytes = knobs.MEMORY_DOCUMENT_CHUNK_BYTES;
|
|
218
|
+
let maxChunks = knobs.MEMORY_DOCUMENT_MAX_CHUNKS;
|
|
219
|
+
if (chunkBytes * maxChunks > ENGINE_DOCUMENT_TOTAL_BYTES) {
|
|
220
|
+
maxChunks = Math.floor(ENGINE_DOCUMENT_TOTAL_BYTES / chunkBytes);
|
|
221
|
+
if (!warnedClamp) {
|
|
222
|
+
warnedClamp = true;
|
|
223
|
+
ctx.logger.warn(
|
|
224
|
+
`[memory] MEMORY_DOCUMENT_CHUNK_BYTES × MEMORY_DOCUMENT_MAX_CHUNKS exceeds the engine's ${ENGINE_DOCUMENT_TOTAL_BYTES}-byte document cap; using ${maxChunks} parts`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
chunkBytes,
|
|
230
|
+
maxChunks,
|
|
231
|
+
maxChars: knobs.MEMORY_DOCUMENT_MAX_CHARS ?? chunkBytes * maxChunks,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function retryNote(err: MemoryEngineError): string {
|
|
236
|
+
return err.retryAfterSeconds !== undefined
|
|
237
|
+
? ` Retry in about ${err.retryAfterSeconds} s.`
|
|
238
|
+
: '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What the failed call was doing, so a retry hint can be right for it. */
|
|
242
|
+
export interface ErrorContext {
|
|
243
|
+
/** `grouped` writes replace on a repeat call, `loose` writes do not. */
|
|
244
|
+
mode?: 'grouped' | 'loose';
|
|
245
|
+
/** The id of the grouped document being written. */
|
|
246
|
+
documentId?: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const ENTITY_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
250
|
+
'ENTITY_HEADER_MISSING',
|
|
251
|
+
'ENTITY_HEADER_INVALID',
|
|
252
|
+
'ENTITY_NOT_VOUCHED',
|
|
253
|
+
'ENTITY_UNVERIFIED',
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
function describeEntityError(err: MemoryEngineError): string {
|
|
257
|
+
switch (err.code) {
|
|
258
|
+
case 'ENTITY_HEADER_MISSING':
|
|
259
|
+
case 'ENTITY_HEADER_INVALID':
|
|
260
|
+
return "The memory engine did not accept the oracle entity DID this oracle sent. This is a setup problem for the operator: ORACLE_ENTITY_DID must be the oracle's domain entity DID (did:ixo:entity:<32 hex digits>). Nothing was written.";
|
|
261
|
+
case 'ENTITY_NOT_VOUCHED':
|
|
262
|
+
return "The memory engine does not know this oracle's domain entity as an oracle it serves. This is a setup problem for the operator: the domain entity's on-chain document must list this oracle's account as a linked entity with service matrix and relationship admin. Nothing was written.";
|
|
263
|
+
default:
|
|
264
|
+
return "The memory engine could not verify this oracle's domain entity right now. This is usually temporary; try again shortly. Nothing was written.";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function describeTimeout(ctx: ErrorContext): string {
|
|
269
|
+
if (ctx.mode === 'grouped') {
|
|
270
|
+
return `The memory engine did not answer in time, so the write may or may not have happened. Call add_oracle_document again with the same document_id${ctx.documentId ? ` ("${ctx.documentId}")` : ''}: it replaces the document, so nothing is duplicated.`;
|
|
271
|
+
}
|
|
272
|
+
if (ctx.mode === 'loose') {
|
|
273
|
+
return 'The memory engine did not answer in time, so some or all of the parts may have been stored. Do not retry the write: search for the content to see what landed.';
|
|
274
|
+
}
|
|
275
|
+
return 'The memory engine did not answer in time. Try again in a moment.';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function describePartialQueue(
|
|
279
|
+
err: MemoryEngineError,
|
|
280
|
+
ctx: ErrorContext,
|
|
281
|
+
): string {
|
|
282
|
+
const stored =
|
|
283
|
+
err.queued !== undefined
|
|
284
|
+
? `${err.queued} part(s) were stored before the engine stopped.`
|
|
285
|
+
: 'Some parts were stored before the engine stopped.';
|
|
286
|
+
if (ctx.mode === 'grouped') {
|
|
287
|
+
return `${stored} Call add_oracle_document again with the same document_id${ctx.documentId ? ` ("${ctx.documentId}")` : ''} to replace the document with a complete one.`;
|
|
288
|
+
}
|
|
289
|
+
return `${stored} Do NOT retry: the stored parts are already there as loose knowledge and a second call would duplicate them.`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The agent-facing sentence for a refusal from the engine. */
|
|
293
|
+
export function describeEngineError(
|
|
294
|
+
err: MemoryEngineError,
|
|
295
|
+
ctx: ErrorContext = {},
|
|
296
|
+
): string {
|
|
297
|
+
if (err.code !== undefined && ENTITY_ERROR_CODES.has(err.code)) {
|
|
298
|
+
return describeEntityError(err);
|
|
299
|
+
}
|
|
300
|
+
switch (err.code) {
|
|
301
|
+
case 'DOMAIN_ACCESS_DENIED':
|
|
302
|
+
return 'The memory engine refused: this person is not a controller, owner, admin or DAO member of this domain, so they cannot change its knowledge.';
|
|
303
|
+
case 'DOCUMENT_EXISTS':
|
|
304
|
+
return "A document with that id already exists and if_exists is 'fail'. Choose another document_id or use if_exists 'replace'.";
|
|
305
|
+
case 'DOCUMENT_NOT_FOUND':
|
|
306
|
+
return 'No document with that id exists in that knowledge space. Check the id with list_oracle_documents.';
|
|
307
|
+
case 'DOCUMENT_INGESTING':
|
|
308
|
+
return 'That document is still being processed. Try again in about 10 s.';
|
|
309
|
+
case 'DOCUMENT_TOO_LARGE':
|
|
310
|
+
return `The memory engine refused: the document is too large in total${err.detail ? ` (${err.detail})` : ''}. Nothing was written. Split the file into smaller files, or store fewer or shorter parts.`;
|
|
311
|
+
case 'DOCUMENT_LIMIT':
|
|
312
|
+
return 'This knowledge space already holds the most documents the memory engine allows. Ask the person to delete old documents (list_oracle_documents shows them) before adding another.';
|
|
313
|
+
case 'DOCUMENT_BACKLOG':
|
|
314
|
+
return `The memory engine is busy ingesting documents.${err.retryAfterSeconds !== undefined ? retryNote(err) : ' Try again in about a minute.'}`;
|
|
315
|
+
case 'EPISODE_TOO_LARGE':
|
|
316
|
+
return 'The memory engine refused: a part of the document is larger than it accepts. Ask the operator to lower MEMORY_DOCUMENT_CHUNK_BYTES.';
|
|
317
|
+
case 'CONFIRMATION_REQUIRED':
|
|
318
|
+
return 'The memory engine refused: the person has not confirmed this write.';
|
|
319
|
+
case 'QUEUE_FULL':
|
|
320
|
+
return `The memory engine is busy and its queue is full.${retryNote(err)}`;
|
|
321
|
+
case 'BUDGET_EXCEEDED':
|
|
322
|
+
return `The memory engine refused: this person's daily memory write budget is used up.${retryNote(err)}`;
|
|
323
|
+
case 'USER_RATE_LIMITED':
|
|
324
|
+
return `The memory engine is limiting how often this person can write.${retryNote(err)}`;
|
|
325
|
+
case 'PARTIAL_QUEUE':
|
|
326
|
+
return describePartialQueue(err, ctx);
|
|
327
|
+
case 'TIMEOUT':
|
|
328
|
+
return describeTimeout(ctx);
|
|
329
|
+
case 'NETWORK_ERROR':
|
|
330
|
+
return 'The memory engine could not be reached.';
|
|
331
|
+
default: {
|
|
332
|
+
if (err.status === 413) {
|
|
333
|
+
return 'The memory engine refused: a part of the document is larger than it accepts. Ask the operator to lower MEMORY_DOCUMENT_CHUNK_BYTES.';
|
|
334
|
+
}
|
|
335
|
+
const what = err.code ?? `status ${err.status}`;
|
|
336
|
+
const detail = err.detail ? ` — ${err.detail}` : '';
|
|
337
|
+
return `The memory engine refused: ${what}${detail}.${retryNote(err)}`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Run a tool body: a refusal from the engine becomes the agent-facing string,
|
|
344
|
+
* a cancelled turn propagates, any other failure is logged and reported
|
|
345
|
+
* without its message.
|
|
346
|
+
*/
|
|
347
|
+
async function guard(
|
|
348
|
+
ctx: RuntimeContext,
|
|
349
|
+
errorContext: () => ErrorContext,
|
|
350
|
+
fn: () => Promise<string>,
|
|
351
|
+
): Promise<string> {
|
|
352
|
+
try {
|
|
353
|
+
return await fn();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (err instanceof MemoryEngineError) {
|
|
356
|
+
if (err.code !== undefined && ENTITY_ERROR_CODES.has(err.code)) {
|
|
357
|
+
ctx.logger.warn(
|
|
358
|
+
`[memory] the engine rejected this oracle's entity: ${err.code}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return describeEngineError(err, errorContext());
|
|
362
|
+
}
|
|
363
|
+
if (ctx.abortSignal.aborted) throw err;
|
|
364
|
+
ctx.logger.warn(
|
|
365
|
+
`[memory] document tool failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
366
|
+
);
|
|
367
|
+
return 'The document request failed unexpectedly.';
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Loose chunks have no document to name them, so the label carries the title. */
|
|
372
|
+
function looseLabel(title: string, label: string): string {
|
|
373
|
+
return `${title} — ${label}`.slice(0, 120);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function formatDocumentRow(doc: DocumentSummary): string {
|
|
377
|
+
const updated = doc.updated_at ?? doc.created_at ?? 'unknown';
|
|
378
|
+
const retiring = doc.retiring_chunks
|
|
379
|
+
? ` (${doc.retiring_chunks} old chunks being removed)`
|
|
380
|
+
: '';
|
|
381
|
+
const failure =
|
|
382
|
+
doc.status === 'delete_failed' && doc.delete_error
|
|
383
|
+
? `: ${doc.delete_error}`
|
|
384
|
+
: '';
|
|
385
|
+
return `${doc.id} | ${doc.title} | ${doc.status}${failure}${retiring} | ${doc.chunks_indexed}/${doc.chunk_count} parts indexed | updated ${updated}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const NO_ENTITY_MESSAGE =
|
|
389
|
+
'This oracle is not configured for domain documents (ORACLE_ENTITY_DID missing).';
|
|
390
|
+
|
|
391
|
+
async function withClient(
|
|
392
|
+
ctx: RuntimeContext,
|
|
393
|
+
deps: OracleDocumentToolsDeps,
|
|
394
|
+
fn: (client: MemoryEngineClient) => Promise<string>,
|
|
395
|
+
): Promise<string> {
|
|
396
|
+
if (entityDidOrWarn(ctx) === undefined) return NO_ENTITY_MESSAGE;
|
|
397
|
+
const client = await createMemoryEngineClient(ctx, {
|
|
398
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
399
|
+
});
|
|
400
|
+
if (!client) return NO_ENGINE_MESSAGE;
|
|
401
|
+
return fn(client);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Before reading a file (or deleting), ask the engine for the private list: it
|
|
406
|
+
* needs the same domain access as a write, so a person without it is stopped
|
|
407
|
+
* before any download, parse or helper-model call. Only an access or entity
|
|
408
|
+
* refusal stops the call; anything else is left for the write to report.
|
|
409
|
+
*/
|
|
410
|
+
async function precheckAccess(
|
|
411
|
+
client: MemoryEngineClient,
|
|
412
|
+
ctx: RuntimeContext,
|
|
413
|
+
): Promise<void> {
|
|
414
|
+
try {
|
|
415
|
+
await client.listDocuments('private');
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (
|
|
418
|
+
err instanceof MemoryEngineError &&
|
|
419
|
+
(err.status === 400 || err.status === 403)
|
|
420
|
+
) {
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
if (ctx.abortSignal.aborted) throw err;
|
|
424
|
+
ctx.logger.debug?.('[memory] document access pre-check was inconclusive');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** `slug(title)` for a derived document id: lowercase `[a-z0-9.-]`, at most 80 characters. */
|
|
429
|
+
export function slugifyTitle(title: string): string {
|
|
430
|
+
const slug = title
|
|
431
|
+
.toLowerCase()
|
|
432
|
+
.replace(/[^a-z0-9.]+/g, '-')
|
|
433
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
434
|
+
.slice(0, 80)
|
|
435
|
+
.replace(/[-.]+$/g, '');
|
|
436
|
+
return slug || 'document';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function sha256Hex8(text: string): Promise<string> {
|
|
440
|
+
const digest = await crypto.subtle.digest(
|
|
441
|
+
'SHA-256',
|
|
442
|
+
new TextEncoder().encode(text),
|
|
443
|
+
);
|
|
444
|
+
return Array.from(new Uint8Array(digest).subarray(0, 4), (b) =>
|
|
445
|
+
b.toString(16).padStart(2, '0'),
|
|
446
|
+
).join('');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The id a grouped document gets when the caller gives none: the title slug and
|
|
451
|
+
* the first 8 hex digits of the SHA-256 of the part contents. The same file
|
|
452
|
+
* added again (a retry after a timeout) lands on the same id and replaces
|
|
453
|
+
* instead of duplicating.
|
|
454
|
+
*/
|
|
455
|
+
export async function deriveDocumentId(
|
|
456
|
+
title: string,
|
|
457
|
+
contents: readonly string[],
|
|
458
|
+
): Promise<string> {
|
|
459
|
+
return `${slugifyTitle(title)}-${await sha256Hex8(contents.join('\u0000'))}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function kb(bytes: number): string {
|
|
463
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function addDocument(
|
|
467
|
+
rawArgs: unknown,
|
|
468
|
+
ctx: RuntimeContext,
|
|
469
|
+
deps: OracleDocumentToolsDeps,
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
// The agent runtime validates the arguments against the schema before the
|
|
472
|
+
// handler runs; this parse only types them.
|
|
473
|
+
const args = addOracleDocumentSchema.parse(rawArgs);
|
|
474
|
+
|
|
475
|
+
// Checks the schema cannot express.
|
|
476
|
+
if (args.source.kind === 'vfs' && !args.source.ref.startsWith('/')) {
|
|
477
|
+
return 'Invalid arguments — source.ref for kind vfs must be an absolute Library path starting with "/".';
|
|
478
|
+
}
|
|
479
|
+
if (args.source.kind === 'attachment' && args.source.ref.startsWith('/')) {
|
|
480
|
+
return 'Invalid arguments — source.ref looks like a Library path; use kind vfs for a Library file, or pass the eventId or mxc URL for an attachment.';
|
|
481
|
+
}
|
|
482
|
+
if (args.confirmed_insertion_from_user !== true) {
|
|
483
|
+
return `Confirmation required: tell the person that this file will be stored in the ${args.knowledge_space_type} knowledge space of this oracle (${args.knowledge_space_type === 'public' ? 'visible to every user of the oracle' : "visible to the domain's members only: its controllers, owner, admins and DAO members"}), ask whether they want that, and call again with confirmed_insertion_from_user true once they say yes.`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const errorContext: ErrorContext = {
|
|
487
|
+
mode: args.group_as_document ? 'grouped' : 'loose',
|
|
488
|
+
...(args.document_id ? { documentId: args.document_id } : {}),
|
|
489
|
+
};
|
|
490
|
+
return guard(
|
|
491
|
+
ctx,
|
|
492
|
+
() => errorContext,
|
|
493
|
+
() =>
|
|
494
|
+
withClient(ctx, deps, async (client) => {
|
|
495
|
+
await precheckAccess(client, ctx);
|
|
496
|
+
|
|
497
|
+
const knobs = readKnobs(ctx);
|
|
498
|
+
const source =
|
|
499
|
+
args.source.kind === 'attachment'
|
|
500
|
+
? { attachment_ref: args.source.ref }
|
|
501
|
+
: { vfs_path: args.source.ref };
|
|
502
|
+
let loaded;
|
|
503
|
+
try {
|
|
504
|
+
loaded = await loadDocumentText(ctx, source, {
|
|
505
|
+
maxChars: knobs.maxChars,
|
|
506
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
507
|
+
});
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (ctx.abortSignal.aborted) throw err;
|
|
510
|
+
return `Could not read the file: ${err instanceof Error ? err.message : String(err)}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const chunked = chunkDocument(loaded.text, {
|
|
514
|
+
maxChunkBytes: knobs.chunkBytes,
|
|
515
|
+
maxChunks: knobs.maxChunks,
|
|
516
|
+
});
|
|
517
|
+
if (chunked.chunks.length === 0) {
|
|
518
|
+
return `Nothing to store: no text could be read from "${loaded.filename}".`;
|
|
519
|
+
}
|
|
520
|
+
const truncated = loaded.truncated || chunked.truncated;
|
|
521
|
+
if (truncated && args.allow_truncation !== true) {
|
|
522
|
+
return `The file "${loaded.filename}" (${kb(loaded.bytes)}) is too large to store whole: ${loaded.truncated ? `reading stopped at ${knobs.maxChars} characters` : `its text is about ${kb(chunked.totalBytes)}`}, and at most ${knobs.maxChunks} parts of ${kb(knobs.chunkBytes)} (${kb(knobs.maxChunks * knobs.chunkBytes)} in all) can be stored. Nothing was written. Ask the person whether the first ${chunked.chunks.length} parts are enough (then call again with allow_truncation true) or whether to split the file.`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const body: AddDocumentRequest = {
|
|
526
|
+
knowledge_space_type: args.knowledge_space_type,
|
|
527
|
+
confirmed_insertion_from_user: true,
|
|
528
|
+
chunks: chunked.chunks.map((c) => ({
|
|
529
|
+
label: args.group_as_document
|
|
530
|
+
? c.label
|
|
531
|
+
: looseLabel(args.title, c.label),
|
|
532
|
+
content: c.content,
|
|
533
|
+
})),
|
|
534
|
+
};
|
|
535
|
+
let derivedId = false;
|
|
536
|
+
if (args.group_as_document) {
|
|
537
|
+
let id = args.document_id;
|
|
538
|
+
if (id === undefined) {
|
|
539
|
+
id = await deriveDocumentId(
|
|
540
|
+
args.title,
|
|
541
|
+
chunked.chunks.map((c) => c.content),
|
|
542
|
+
);
|
|
543
|
+
derivedId = true;
|
|
544
|
+
}
|
|
545
|
+
errorContext.documentId = id;
|
|
546
|
+
body.document = {
|
|
547
|
+
id,
|
|
548
|
+
title: args.title,
|
|
549
|
+
...(args.source_description
|
|
550
|
+
? { source_description: args.source_description }
|
|
551
|
+
: {}),
|
|
552
|
+
};
|
|
553
|
+
body.if_exists = args.if_exists ?? 'replace';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
ctx.logger.debug?.(
|
|
557
|
+
`[memory] adding ${args.group_as_document ? 'grouped' : 'loose'} document: ${chunked.chunks.length} chunks, method=${loaded.method}`,
|
|
558
|
+
);
|
|
559
|
+
const res = await client.addDocument(body);
|
|
560
|
+
|
|
561
|
+
if (res.document) {
|
|
562
|
+
return JSON.stringify({
|
|
563
|
+
document_id: res.document.id,
|
|
564
|
+
...(derivedId ? { document_id_derived: true } : {}),
|
|
565
|
+
generation: res.document.generation,
|
|
566
|
+
chunks: res.document.chunk_count,
|
|
567
|
+
status: res.document.status,
|
|
568
|
+
replaced: res.document.replaced,
|
|
569
|
+
...(res.document.retiring_chunks
|
|
570
|
+
? { retiring_chunks: res.document.retiring_chunks }
|
|
571
|
+
: {}),
|
|
572
|
+
method: loaded.method,
|
|
573
|
+
truncated,
|
|
574
|
+
note: 'Indexing continues in the background; list_oracle_documents shows the status.',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
const ignored = [
|
|
578
|
+
args.document_id !== undefined ? 'document_id' : undefined,
|
|
579
|
+
args.if_exists !== undefined ? 'if_exists' : undefined,
|
|
580
|
+
args.source_description !== undefined
|
|
581
|
+
? 'source_description'
|
|
582
|
+
: undefined,
|
|
583
|
+
].filter((name): name is string => name !== undefined);
|
|
584
|
+
return JSON.stringify({
|
|
585
|
+
queued: res.queued,
|
|
586
|
+
chunk_ids_count: res.chunk_ids?.length ?? res.queued,
|
|
587
|
+
method: loaded.method,
|
|
588
|
+
truncated,
|
|
589
|
+
...(ignored.length > 0 ? { ignored_in_loose_mode: ignored } : {}),
|
|
590
|
+
note: 'Stored as loose knowledge; it is found by search and has no document to list.',
|
|
591
|
+
});
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function listDocuments(
|
|
597
|
+
rawArgs: unknown,
|
|
598
|
+
ctx: RuntimeContext,
|
|
599
|
+
deps: OracleDocumentToolsDeps,
|
|
600
|
+
): Promise<string> {
|
|
601
|
+
const args = listOracleDocumentsSchema.parse(rawArgs);
|
|
602
|
+
const space = args.knowledge_space_type;
|
|
603
|
+
const limit = args.limit ?? DEFAULT_LIST_LIMIT;
|
|
604
|
+
return guard(
|
|
605
|
+
ctx,
|
|
606
|
+
() => ({}),
|
|
607
|
+
() =>
|
|
608
|
+
withClient(ctx, deps, async (client) => {
|
|
609
|
+
const { documents } = await client.listDocuments(space);
|
|
610
|
+
const matching = args.status
|
|
611
|
+
? documents.filter((d) => d.status === args.status)
|
|
612
|
+
: documents;
|
|
613
|
+
const filter = args.status ? ` with status ${args.status}` : '';
|
|
614
|
+
if (matching.length === 0) {
|
|
615
|
+
return `No grouped documents${filter} in the ${space} knowledge space.`;
|
|
616
|
+
}
|
|
617
|
+
const shown = matching.slice(0, limit);
|
|
618
|
+
const count =
|
|
619
|
+
shown.length < matching.length
|
|
620
|
+
? `${shown.length} of ${matching.length} document(s)`
|
|
621
|
+
: `${matching.length} document(s)`;
|
|
622
|
+
return [
|
|
623
|
+
`${count}${filter} in the ${space} knowledge space (id | title | status | parts | updated):`,
|
|
624
|
+
...shown.map(formatDocumentRow),
|
|
625
|
+
].join('\n');
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function deleteDocument(
|
|
631
|
+
rawArgs: unknown,
|
|
632
|
+
ctx: RuntimeContext,
|
|
633
|
+
deps: OracleDocumentToolsDeps,
|
|
634
|
+
): Promise<string> {
|
|
635
|
+
const args = deleteOracleDocumentSchema.parse(rawArgs);
|
|
636
|
+
if (args.confirmed_deletion_from_user !== true) {
|
|
637
|
+
return `Confirmation required: tell the person that document "${args.document_id}" will be removed from the ${args.knowledge_space_type} knowledge space for everyone who can see it, ask whether they want that, and call again with confirmed_deletion_from_user true once they say yes.`;
|
|
638
|
+
}
|
|
639
|
+
return guard(
|
|
640
|
+
ctx,
|
|
641
|
+
() => ({}),
|
|
642
|
+
() =>
|
|
643
|
+
withClient(ctx, deps, async (client) => {
|
|
644
|
+
await precheckAccess(client, ctx);
|
|
645
|
+
const out = await client.deleteDocument(
|
|
646
|
+
args.document_id,
|
|
647
|
+
args.knowledge_space_type,
|
|
648
|
+
);
|
|
649
|
+
return `Deletion scheduled for document "${args.document_id}" (${out.chunks} chunks) in the ${args.knowledge_space_type} knowledge space; it is removed in the background.`;
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function createOracleDocumentTools(
|
|
655
|
+
deps: OracleDocumentToolsDeps = {},
|
|
656
|
+
): PluginTool[] {
|
|
657
|
+
return [
|
|
658
|
+
tool((args, ctx) => addDocument(args, ctx, deps), {
|
|
659
|
+
name: ADD_ORACLE_DOCUMENT_TOOL_NAME,
|
|
660
|
+
description: ADD_ORACLE_DOCUMENT_DESCRIPTION,
|
|
661
|
+
schema: addOracleDocumentSchema,
|
|
662
|
+
repeatable: false,
|
|
663
|
+
}),
|
|
664
|
+
tool((args, ctx) => listDocuments(args, ctx, deps), {
|
|
665
|
+
name: LIST_ORACLE_DOCUMENTS_TOOL_NAME,
|
|
666
|
+
description: LIST_ORACLE_DOCUMENTS_DESCRIPTION,
|
|
667
|
+
schema: listOracleDocumentsSchema,
|
|
668
|
+
effect: 'read',
|
|
669
|
+
}),
|
|
670
|
+
tool((args, ctx) => deleteDocument(args, ctx, deps), {
|
|
671
|
+
name: DELETE_ORACLE_DOCUMENT_TOOL_NAME,
|
|
672
|
+
description: DELETE_ORACLE_DOCUMENT_DESCRIPTION,
|
|
673
|
+
schema: deleteOracleDocumentSchema,
|
|
674
|
+
repeatable: false,
|
|
675
|
+
}),
|
|
676
|
+
];
|
|
677
|
+
}
|