@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.
@@ -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
+ }
@@ -0,0 +1,352 @@
1
+ import { z } from 'zod';
2
+ import type { RuntimeContext } from '../../plugin-api/types';
3
+ import { MEMORY_CAPABILITY } from '../delegated-capabilities';
4
+ import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
5
+ import { memoryEntityDid } from './memory-entity';
6
+
7
+ export type KnowledgeSpaceType = 'public' | 'private';
8
+
9
+ export interface MemoryEngineClientDeps {
10
+ fetchImpl?: typeof fetch;
11
+ /** Per-request timeout (default 30 s). */
12
+ timeoutMs?: number;
13
+ }
14
+
15
+ const DEFAULT_TIMEOUT_MS = 30_000;
16
+
17
+ let warnedMissingEntityDid = false;
18
+
19
+ /** Forget that the missing-entity warning was logged (tests only). */
20
+ export function resetMemoryEngineWarningsForTests(): void {
21
+ warnedMissingEntityDid = false;
22
+ }
23
+
24
+ /**
25
+ * The DID sent as `x-oracle-entity-did`: the configured `ORACLE_ENTITY_DID`
26
+ * when it is a well-formed domain entity DID, never the `ORACLE_DID` fallback
27
+ * (see the memory engine contract in docs/configuration.md). Logs once per
28
+ * isolate when there is none.
29
+ */
30
+ export function entityDidOrWarn(ctx: RuntimeContext): string | undefined {
31
+ const did = memoryEntityDid(ctx.config.ORACLE_ENTITY_DID);
32
+ if (did === undefined && !warnedMissingEntityDid) {
33
+ warnedMissingEntityDid = true;
34
+ ctx.logger.warn(
35
+ "[memory] documents unavailable: set ORACLE_ENTITY_DID to the oracle's domain entity DID (did:ixo:entity:<32 hex digits>)",
36
+ );
37
+ }
38
+ return did;
39
+ }
40
+
41
+ /**
42
+ * An answer the Memory Engine refused (or a transport failure, `status` 0).
43
+ * `message` never carries the response body or the bearer; the engine's own
44
+ * `detail` is kept on the error so a tool can show it to the person, and is
45
+ * never logged.
46
+ */
47
+ export class MemoryEngineError extends Error {
48
+ readonly status: number;
49
+
50
+ readonly code?: string;
51
+
52
+ readonly detail?: string;
53
+
54
+ readonly retryable: boolean;
55
+
56
+ readonly retryAfterSeconds?: number;
57
+
58
+ /** Parts the engine had queued before it failed (`PARTIAL_QUEUE`). */
59
+ readonly queued?: number;
60
+
61
+ constructor(init: {
62
+ status: number;
63
+ code?: string;
64
+ detail?: string;
65
+ retryable?: boolean;
66
+ retryAfterSeconds?: number;
67
+ queued?: number;
68
+ }) {
69
+ super(
70
+ `memory engine answered ${init.status}${init.code ? ` ${init.code}` : ''}`,
71
+ );
72
+ this.name = 'MemoryEngineError';
73
+ this.status = init.status;
74
+ if (init.code !== undefined) this.code = init.code;
75
+ if (init.detail !== undefined) this.detail = init.detail;
76
+ this.retryable = init.retryable ?? false;
77
+ if (init.retryAfterSeconds !== undefined) {
78
+ this.retryAfterSeconds = init.retryAfterSeconds;
79
+ }
80
+ if (init.queued !== undefined) this.queued = init.queued;
81
+ }
82
+ }
83
+
84
+ export interface DocumentChunkInput {
85
+ label?: string;
86
+ content: string;
87
+ }
88
+
89
+ export interface AddDocumentRequest {
90
+ knowledge_space_type: KnowledgeSpaceType;
91
+ confirmed_insertion_from_user: true;
92
+ /** Present for a grouped document, absent for loose knowledge. */
93
+ document?: { id?: string; title: string; source_description?: string };
94
+ if_exists?: 'replace' | 'fail';
95
+ chunks: DocumentChunkInput[];
96
+ }
97
+
98
+ const documentStatusSchema = z.enum([
99
+ 'ingesting',
100
+ 'ready',
101
+ 'partial',
102
+ 'deleting',
103
+ 'delete_failed',
104
+ ]);
105
+
106
+ const addDocumentResponseSchema = z.object({
107
+ success: z.boolean().optional(),
108
+ queued: z.number(),
109
+ document: z
110
+ .object({
111
+ id: z.string(),
112
+ generation: z.number(),
113
+ chunk_count: z.number(),
114
+ status: documentStatusSchema,
115
+ replaced: z.boolean(),
116
+ /** Chunks of the replaced generation still being removed. */
117
+ retiring_chunks: z.number().optional(),
118
+ })
119
+ .optional(),
120
+ chunk_ids: z.array(z.string()).optional(),
121
+ });
122
+ export type AddDocumentResponse = z.infer<typeof addDocumentResponseSchema>;
123
+
124
+ const documentSummarySchema = z.object({
125
+ id: z.string(),
126
+ title: z.string(),
127
+ source_description: z.string().nullable().optional(),
128
+ generation: z.number(),
129
+ chunk_count: z.number(),
130
+ chunks_indexed: z.number(),
131
+ retiring_chunks: z.number().optional(),
132
+ status: documentStatusSchema,
133
+ /** Why the last deletion failed (status `delete_failed`). */
134
+ delete_error: z.string().nullable().optional(),
135
+ created_by: z.string().nullable().optional(),
136
+ updated_by: z.string().nullable().optional(),
137
+ created_at: z.string().nullable().optional(),
138
+ updated_at: z.string().nullable().optional(),
139
+ });
140
+ export type DocumentSummary = z.infer<typeof documentSummarySchema>;
141
+
142
+ const listDocumentsResponseSchema = z.object({
143
+ documents: z.array(documentSummarySchema),
144
+ });
145
+ export type ListDocumentsResponse = z.infer<typeof listDocumentsResponseSchema>;
146
+
147
+ const documentViewSchema = documentSummarySchema.extend({
148
+ chunks: z.array(z.unknown()).optional(),
149
+ });
150
+ export type DocumentView = z.infer<typeof documentViewSchema>;
151
+
152
+ const deleteDocumentResponseSchema = z.object({
153
+ success: z.boolean().optional(),
154
+ document: z.object({
155
+ id: z.string(),
156
+ status: documentStatusSchema,
157
+ chunks: z.number(),
158
+ }),
159
+ });
160
+ export type DeleteDocumentResponse = z.infer<
161
+ typeof deleteDocumentResponseSchema
162
+ >;
163
+
164
+ export interface MemoryEngineClient {
165
+ addDocument(body: AddDocumentRequest): Promise<AddDocumentResponse>;
166
+ listDocuments(space: KnowledgeSpaceType): Promise<ListDocumentsResponse>;
167
+ getDocument(id: string, space: KnowledgeSpaceType): Promise<DocumentView>;
168
+ /** Deletion runs in the background; the answer says it was scheduled. */
169
+ deleteDocument(
170
+ id: string,
171
+ space: KnowledgeSpaceType,
172
+ ): Promise<DeleteDocumentResponse['document']>;
173
+ }
174
+
175
+ const configSchema = z.object({
176
+ MEMORY_ENGINE_URL: z.string().url(),
177
+ });
178
+
179
+ function describe(error: unknown): string {
180
+ return error instanceof Error ? error.message : String(error);
181
+ }
182
+
183
+ function parseRetryAfter(res: Response): number | undefined {
184
+ const raw = res.headers.get('Retry-After');
185
+ if (!raw) return undefined;
186
+ const seconds = Number(raw);
187
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
188
+ }
189
+
190
+ function detailText(detail: unknown): string | undefined {
191
+ if (typeof detail === 'string') return detail.slice(0, 500);
192
+ if (detail === undefined || detail === null) return undefined;
193
+ try {
194
+ return JSON.stringify(detail).slice(0, 500);
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ const errorBodySchema = z.object({
201
+ code: z.string().optional(),
202
+ detail: z.unknown().optional(),
203
+ retryable: z.boolean().optional(),
204
+ queued: z.number().optional(),
205
+ });
206
+
207
+ async function toEngineError(res: Response): Promise<MemoryEngineError> {
208
+ let raw: unknown;
209
+ try {
210
+ raw = await res.json();
211
+ } catch {
212
+ raw = undefined;
213
+ }
214
+ const body = errorBodySchema.safeParse(raw);
215
+ const parsed = body.success ? body.data : {};
216
+ const detail = detailText(parsed.detail);
217
+ const retryAfterSeconds = parseRetryAfter(res);
218
+ return new MemoryEngineError({
219
+ status: res.status,
220
+ ...(parsed.code !== undefined ? { code: parsed.code } : {}),
221
+ ...(detail !== undefined ? { detail } : {}),
222
+ retryable: parsed.retryable === true,
223
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
224
+ ...(parsed.queued !== undefined ? { queued: parsed.queued } : {}),
225
+ });
226
+ }
227
+
228
+ /**
229
+ * A client for the Memory Engine's document routes, authenticated as the
230
+ * current user (UCAN invocation minted once per client) and as the oracle's
231
+ * entity (`x-oracle-entity-did`). Returns `null`, after logging why, when the
232
+ * engine URL, the entity DID or an invocation is unavailable; a tool then tells
233
+ * the model the engine is not reachable.
234
+ */
235
+ export async function createMemoryEngineClient(
236
+ ctx: RuntimeContext,
237
+ deps: MemoryEngineClientDeps = {},
238
+ ): Promise<MemoryEngineClient | null> {
239
+ const config = configSchema.safeParse(ctx.config);
240
+ if (!config.success) {
241
+ ctx.logger.warn(
242
+ '[memory] documents unavailable: MEMORY_ENGINE_URL missing',
243
+ );
244
+ return null;
245
+ }
246
+ const engineUrl = config.data.MEMORY_ENGINE_URL.replace(/\/+$/, '');
247
+ const entityDid = entityDidOrWarn(ctx);
248
+ if (!entityDid) return null;
249
+
250
+ const serviceDid = await resolveServiceDidSafely(ctx, engineUrl, 'memory');
251
+ if (!serviceDid) return null;
252
+ const invocation = await mintInvocationSafely(
253
+ ctx,
254
+ { did: serviceDid, capability: MEMORY_CAPABILITY.resource },
255
+ 'memory',
256
+ { can: MEMORY_CAPABILITY.action },
257
+ );
258
+ if (!invocation) return null;
259
+
260
+ const headers: Record<string, string> = {
261
+ Authorization: `Bearer ${invocation}`,
262
+ 'X-Auth-Type': 'ucan',
263
+ 'x-oracle-entity-did': entityDid,
264
+ };
265
+ if (ctx.session.roomId) headers['x-room-id'] = ctx.session.roomId;
266
+
267
+ const fetchImpl = deps.fetchImpl ?? fetch;
268
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
269
+
270
+ async function request<T>(
271
+ method: 'GET' | 'POST' | 'DELETE',
272
+ path: string,
273
+ schema: z.ZodType<T>,
274
+ body?: unknown,
275
+ ): Promise<T> {
276
+ let res: Response;
277
+ try {
278
+ res = await fetchImpl(`${engineUrl}${path}`, {
279
+ method,
280
+ headers:
281
+ body === undefined
282
+ ? headers
283
+ : { ...headers, 'Content-Type': 'application/json' },
284
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
285
+ signal: AbortSignal.any([
286
+ ctx.abortSignal,
287
+ AbortSignal.timeout(timeoutMs),
288
+ ]),
289
+ });
290
+ } catch (error) {
291
+ // A cancelled turn is not an engine failure.
292
+ if (ctx.abortSignal.aborted) throw error;
293
+ const timedOut = error instanceof Error && error.name === 'TimeoutError';
294
+ throw new MemoryEngineError({
295
+ status: 0,
296
+ code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR',
297
+ detail: timedOut
298
+ ? `no answer within ${timeoutMs} ms`
299
+ : describe(error).slice(0, 200),
300
+ retryable: true,
301
+ });
302
+ }
303
+ if (!res.ok) throw await toEngineError(res);
304
+ let json: unknown;
305
+ try {
306
+ json = await res.json();
307
+ } catch {
308
+ throw new MemoryEngineError({
309
+ status: res.status,
310
+ code: 'MALFORMED_RESPONSE',
311
+ detail: 'the answer was not valid JSON',
312
+ });
313
+ }
314
+ const parsed = schema.safeParse(json);
315
+ if (!parsed.success) {
316
+ throw new MemoryEngineError({
317
+ status: res.status,
318
+ code: 'MALFORMED_RESPONSE',
319
+ detail: 'the answer did not have the expected shape',
320
+ });
321
+ }
322
+ return parsed.data;
323
+ }
324
+
325
+ const spaceQuery = (space: KnowledgeSpaceType): string =>
326
+ `knowledge_space_type=${space}`;
327
+
328
+ return {
329
+ addDocument: (body) =>
330
+ request('POST', '/knowledge/documents', addDocumentResponseSchema, body),
331
+ listDocuments: (space) =>
332
+ request(
333
+ 'GET',
334
+ `/knowledge/documents?${spaceQuery(space)}`,
335
+ listDocumentsResponseSchema,
336
+ ),
337
+ getDocument: (id, space) =>
338
+ request(
339
+ 'GET',
340
+ `/knowledge/documents/${encodeURIComponent(id)}?${spaceQuery(space)}`,
341
+ documentViewSchema,
342
+ ),
343
+ deleteDocument: async (id, space) => {
344
+ const out = await request(
345
+ 'DELETE',
346
+ `/knowledge/documents/${encodeURIComponent(id)}?${spaceQuery(space)}`,
347
+ deleteDocumentResponseSchema,
348
+ );
349
+ return out.document;
350
+ },
351
+ };
352
+ }
@@ -15,6 +15,7 @@ import {
15
15
  type MemoryMcpFactory,
16
16
  } from './memory-tools';
17
17
  import { oracleEntityDidSchema } from './memory-entity';
18
+ import { createOracleDocumentTools } from './oracle-document-tools';
18
19
 
19
20
  const configSchema = z.object({
20
21
  MEMORY_MCP_URL: z.string().url('MEMORY_MCP_URL must be a valid HTTP(S) URL.'),
@@ -36,7 +37,8 @@ const manifest: PluginManifest = {
36
37
  'You create an artifact for the user (file, document, edit, generated content): record what it is, what it is for, and the structural choices you made.',
37
38
  'The user expresses satisfaction or dissatisfaction with something you produced: capture what worked or what did not — tone, length, structure, style — against that artifact entry.',
38
39
  'The user references something they told you before, or something you made before.',
39
- 'A controller or owner of this oracle asks you to store something every user of the oracle should know (a policy, a procedure, reference material): tell them it will be shared with every user of the oracle (or, for the private space, with its controllers and owners only), get their confirmation, then store it as oracle knowledge rather than as a memory about them.',
40
+ 'A controller or owner of this oracle TYPES into the chat something every user of the oracle should know (a policy, a procedure, reference material): tell them it will be shared with every user of the oracle (or, for the private space, with the members of the domain only: its controllers, owner, admins and DAO members), get their confirmation, then store it as oracle knowledge rather than as a memory about them. This line is only for knowledge typed in the chat.',
41
+ 'A controller or owner of this oracle hands you a file (an attachment or a Library file) and asks you to store it as oracle knowledge: a file the person attached or a Library file ALWAYS goes to add_oracle_document, never to add_oracle_knowledge, even when its text is visible in the conversation. If the file is one coherent record (a board resolution, a contract, a report) group it as a document (group_as_document true); if it is a file where someone dumped many unrelated company facts to upload them easily, store it as loose facts (group_as_document false). When it is not obvious which, ask the person first, and always confirm the public or private space with them before writing. list_oracle_documents and delete_oracle_document show and remove grouped documents.',
40
42
  ],
41
43
  whenNotToUse: [
42
44
  'Ephemeral conversation-only state (use the current message thread).',
@@ -44,6 +46,7 @@ const manifest: PluginManifest = {
44
46
  'Public web facts that are not specific to this user (use a web-search capability).',
45
47
  'Anything the user asked you to forget or framed as temporary.',
46
48
  'Oracle knowledge for something about one user: the shared knowledge is only for what every user of this oracle should know.',
49
+ 'Storing a file the person did not ask you to store as oracle knowledge, or choosing between a grouped document and loose facts without asking when the file could be either.',
47
50
  ],
48
51
  tags: ['memory', 'recall', 'artifacts', 'personalization'],
49
52
  category: 'memory',
@@ -66,6 +69,15 @@ export interface MemoryPluginOptions {
66
69
  * explicit list to drop one of them or to expose `delete_edge`.
67
70
  */
68
71
  selectedTools?: readonly string[];
72
+ /**
73
+ * Surface `add_oracle_document`, `list_oracle_documents` and
74
+ * `delete_oracle_document` next to the upstream tools (default true). They
75
+ * talk to `MEMORY_ENGINE_URL` directly and are not part of
76
+ * {@link selectedTools}, and appear only when the upstream tools do (a user
77
+ * without a memory delegation gets neither). Pass `false` to keep documents
78
+ * out of the oracle.
79
+ */
80
+ documentTools?: boolean;
69
81
  }
70
82
 
71
83
  function resolveMemoryConfig(config: MergedConfig): {
@@ -109,10 +121,13 @@ export class MemoryPlugin extends OraclePlugin {
109
121
 
110
122
  private readonly selectedTools: readonly string[];
111
123
 
124
+ private readonly documentTools: boolean;
125
+
112
126
  constructor(options: MemoryPluginOptions = {}) {
113
127
  super();
114
128
  this.mcpFactory = options.mcpFactory ?? createDefaultMemoryMcpFactory;
115
129
  this.selectedTools = options.selectedTools ?? DEFAULT_MEMORY_TOOLS;
130
+ this.documentTools = options.documentTools ?? true;
116
131
  }
117
132
 
118
133
  override autoDetect(env: PluginEnv): boolean {
@@ -124,7 +139,15 @@ export class MemoryPlugin extends OraclePlugin {
124
139
  override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
125
140
  const { memoryMcpUrl, entityDid } = resolveMemoryConfig(rtCtx.config);
126
141
  const factory = this.mcpFactory(memoryMcpUrl, entityDid);
127
- return fetchMemoryTools(rtCtx, factory, this.selectedTools);
142
+ const mcpTools = await fetchMemoryTools(rtCtx, factory, this.selectedTools);
143
+ // The document tools come first so a file never lands on the upstream
144
+ // add_oracle_knowledge by position.
145
+ // No upstream tools means memory is unavailable for this user (no
146
+ // delegation, unresolved service DID): the document tools would only
147
+ // answer "not reachable", so they are left out too.
148
+ return this.documentTools && mcpTools.length > 0
149
+ ? [...createOracleDocumentTools(), ...mcpTools]
150
+ : mcpTools;
128
151
  }
129
152
 
130
153
  override getSharedState(): Record<