@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.
Files changed (36) hide show
  1. package/package.json +3 -2
  2. package/src/attachments/extract.ts +108 -14
  3. package/src/attachments/index.ts +15 -0
  4. package/src/attachments/pdf-text.ts +99 -0
  5. package/src/attachments/pipeline.ts +47 -4
  6. package/src/attachments/sanitize.ts +14 -0
  7. package/src/attachments/view-cache.ts +95 -0
  8. package/src/attachments/view.ts +308 -3
  9. package/src/channels/auth.ts +2 -1
  10. package/src/channels/contract.ts +104 -0
  11. package/src/channels/replies.ts +58 -0
  12. package/src/channels/routing.ts +222 -0
  13. package/src/channels/turns.ts +1252 -16
  14. package/src/core/env.ts +17 -0
  15. package/src/do/contracts.ts +19 -0
  16. package/src/do/room-mirror.ts +8 -1
  17. package/src/do/run-coordinator.ts +27 -0
  18. package/src/do/transcript.ts +14 -11
  19. package/src/do/turn-messages.ts +54 -0
  20. package/src/do/user-oracle-do.ts +321 -135
  21. package/src/memory/session-history-indexer.ts +442 -103
  22. package/src/memory/user-context-fetcher.ts +13 -0
  23. package/src/memory/user-context-invalidation.ts +8 -2
  24. package/src/plugins/action-proposals/evidence.ts +7 -8
  25. package/src/plugins/memory/document-chunker.ts +313 -0
  26. package/src/plugins/memory/document-source.ts +229 -0
  27. package/src/plugins/memory/memory-engine-client.ts +352 -0
  28. package/src/plugins/memory/memory.plugin.ts +25 -2
  29. package/src/plugins/memory/oracle-document-tools.ts +677 -0
  30. package/src/plugins/tasks/middleware.ts +4 -9
  31. package/src/plugins/vfs/index.ts +11 -2
  32. package/src/plugins/vfs/vfs-content.ts +86 -22
  33. package/src/plugins/vfs/vfs.plugin.ts +28 -19
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -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<