@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
@@ -23,10 +23,7 @@ import type { BaseMessage } from '@langchain/core/messages';
23
23
  import { createMiddleware, type AgentMiddleware } from 'langchain';
24
24
  import type { Logger, OracleTasksSurface } from '../../plugin-api/types';
25
25
  import { pendingApprovalOf } from '../../tasks/store';
26
- import {
27
- stripTurnTimeNote,
28
- TURN_TIME_NOTE_KWARG,
29
- } from '../../core/turn-time-note';
26
+ import { userContent } from '../../channels/replies';
30
27
 
31
28
  const APPROVE = new Set([
32
29
  'yes',
@@ -151,17 +148,15 @@ export function userDidFromContext(context: unknown): string | undefined {
151
148
 
152
149
  /**
153
150
  * Text of the latest HumanMessage — string content or joined text parts —
154
- * as the user sent it: without the turn time note the runtime puts in front.
151
+ * as the user sent it: without the turn time note the runtime puts in front,
152
+ * and without a quoted WhatsApp message (a quoted "yes" is not an approval).
155
153
  */
156
154
  export function lastHumanText(messages: BaseMessage[]): string | null {
157
155
  for (let i = messages.length - 1; i >= 0; i--) {
158
156
  const message = messages[i];
159
157
  if (!message || message.type !== 'human') continue;
160
158
  return textOfContent(
161
- stripTurnTimeNote(
162
- message.content,
163
- message.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
164
- ),
159
+ userContent(message.content, message.additional_kwargs),
165
160
  );
166
161
  }
167
162
  return null;
@@ -1,4 +1,9 @@
1
- export { VfsPlugin, type VfsConfig, type VfsPluginOptions } from './vfs.plugin';
1
+ export {
2
+ resolveVfsConfig,
3
+ VfsPlugin,
4
+ type VfsConfig,
5
+ type VfsPluginOptions,
6
+ } from './vfs.plugin';
2
7
  export {
3
8
  buildClient,
4
9
  createVfsTools,
@@ -26,7 +31,11 @@ export {
26
31
  type VfsSearchResult,
27
32
  type VfsTreeEntry,
28
33
  } from './vfs-client';
29
- export { isTextMime, readForAgent } from './vfs-content';
34
+ export {
35
+ isTextMime,
36
+ readForAgent,
37
+ renderBinaryForIngestion,
38
+ } from './vfs-content';
30
39
  export {
31
40
  isAlreadyExistsConflict,
32
41
  isWriteConflict,
@@ -1,12 +1,13 @@
1
1
  import { HumanMessage } from '@langchain/core/messages';
2
2
  import type { MessageContent } from '@langchain/core/messages';
3
+ import { MAX_MODEL_LANE_BYTES, replyText } from '../../attachments/extract';
3
4
  import type { RuntimeContext } from '../../plugin-api/types';
4
5
  import { bytesToBase64 } from '../base64';
5
- import type { VfsClient, VfsFileStat } from './vfs-client';
6
+ import type { VfsClient, VfsContentBytes, VfsFileStat } from './vfs-client';
6
7
  import { VfsContentTooLargeError, VfsHttpError } from './vfs-errors';
7
8
 
8
9
  /** Cap on the bytes we download and base64 into the vision model. */
9
- export const MAX_VISION_BYTES = 10 * 1024 * 1024;
10
+ export const MAX_VISION_BYTES = MAX_MODEL_LANE_BYTES;
10
11
 
11
12
  /** MIME types that are text even though they don't start with `text/`. */
12
13
  const TEXT_MIME_EXACT = new Set<string>([
@@ -70,17 +71,37 @@ function isRenderableMime(mime: string): boolean {
70
71
  }
71
72
 
72
73
  /**
73
- * Read a non-text file for the agent. Returns a metadata stub without
74
- * downloading when the stored size or type already rules rendering out;
75
- * otherwise fetches at most {@link MAX_VISION_BYTES} and routes the bytes
76
- * through the vision model, returning its description/transcription. A vision
77
- * failure degrades to a metadata stub.
74
+ * What rendering a binary produced: the model's text (`truncated` when the
75
+ * provider stopped it at its output limit), or a metadata stub.
78
76
  */
79
- async function renderBinary(
77
+ type BinaryRendering = { text: string; truncated: boolean } | { stub: string };
78
+
79
+ /** True when the provider cut the reply at its output-token limit. */
80
+ function stoppedAtLimit(metadata: unknown): boolean {
81
+ if (typeof metadata !== 'object' || metadata === null) return false;
82
+ const reason =
83
+ 'finish_reason' in metadata
84
+ ? metadata.finish_reason
85
+ : 'stop_reason' in metadata
86
+ ? metadata.stop_reason
87
+ : undefined;
88
+ return reason === 'length' || reason === 'max_tokens';
89
+ }
90
+
91
+ /**
92
+ * Shared body of {@link renderBinary} and {@link renderBinaryForIngestion}.
93
+ * Returns a metadata stub without downloading when the stored size or type
94
+ * already rules rendering out; otherwise fetches at most
95
+ * {@link MAX_VISION_BYTES} and routes the bytes through the vision model. A
96
+ * vision failure is a stub too; the caller decides whether a stub is
97
+ * acceptable (`vfs_read`) or an error (ingestion).
98
+ */
99
+ async function renderBinaryOrStub(
80
100
  client: VfsClient,
81
101
  rtCtx: RuntimeContext,
82
102
  stat: VfsFileStat,
83
- ): Promise<string> {
103
+ preloaded?: VfsContentBytes,
104
+ ): Promise<BinaryRendering> {
84
105
  const name = basename(stat.path) || stat.id;
85
106
  const publicSuffix = stat.publicUrl
86
107
  ? ` — public link: ${stat.publicUrl}`
@@ -89,17 +110,23 @@ async function renderBinary(
89
110
  `[binary file "${name}" — ${mime}, ${size} bytes — too large to render (limit ${MAX_VISION_BYTES} bytes)${publicSuffix}]`;
90
111
  const storedMime = stat.mimeType || 'application/octet-stream';
91
112
 
92
- if (stat.size > MAX_VISION_BYTES) return tooLarge(storedMime, stat.size);
113
+ if (stat.size > MAX_VISION_BYTES)
114
+ return { stub: tooLarge(storedMime, stat.size) };
93
115
  if (stat.mimeType && !isRenderableMime(stat.mimeType)) {
94
- return `[binary file "${name}" — ${stat.mimeType}, ${stat.size} bytes — not rendered${publicSuffix}]`;
116
+ return {
117
+ stub: `[binary file "${name}" — ${stat.mimeType}, ${stat.size} bytes — not rendered${publicSuffix}]`,
118
+ };
95
119
  }
96
120
 
97
- let downloaded: Awaited<ReturnType<VfsClient['contentBytes']>>;
121
+ let downloaded: VfsContentBytes;
98
122
  try {
99
- downloaded = await client.contentBytes(stat.id, MAX_VISION_BYTES);
123
+ downloaded =
124
+ preloaded ?? (await client.contentBytes(stat.id, MAX_VISION_BYTES));
100
125
  } catch (err) {
101
126
  if (err instanceof VfsContentTooLargeError) {
102
- return tooLarge(storedMime, `more than ${MAX_VISION_BYTES}`);
127
+ return {
128
+ stub: tooLarge(storedMime, `more than ${MAX_VISION_BYTES}`),
129
+ };
103
130
  }
104
131
  throw err;
105
132
  }
@@ -109,7 +136,9 @@ async function renderBinary(
109
136
  const isImage = mime.startsWith('image/');
110
137
  const isDoc = isDocMime(mime);
111
138
  if (!isImage && !isDoc) {
112
- return `[binary file "${name}" — ${mime}, ${size} bytes — not rendered${publicSuffix}]`;
139
+ return {
140
+ stub: `[binary file "${name}" — ${mime}, ${size} bytes — not rendered${publicSuffix}]`,
141
+ };
113
142
  }
114
143
 
115
144
  const base64 = bytesToBase64(new Uint8Array(bytes));
@@ -138,20 +167,55 @@ async function renderBinary(
138
167
  const result = await model.invoke([new HumanMessage({ content })], {
139
168
  signal: rtCtx.abortSignal,
140
169
  });
141
- const text =
142
- typeof result.content === 'string'
143
- ? result.content
144
- : String(result.content);
145
- if (text.trim().length > 0) return text;
146
- return `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — no description returned${publicSuffix}]`;
170
+ const text = replyText(result.content);
171
+ if (text.trim().length > 0) {
172
+ return { text, truncated: stoppedAtLimit(result.response_metadata) };
173
+ }
174
+ return {
175
+ stub: `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — no description returned${publicSuffix}]`,
176
+ };
147
177
  } catch (err) {
148
178
  rtCtx.logger.warn(
149
179
  `[vfs] vision render failed for ${name}: ${err instanceof Error ? err.message : String(err)}`,
150
180
  );
151
- return `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — could not be rendered${publicSuffix}]`;
181
+ return {
182
+ stub: `[${isImage ? 'image' : 'document'} "${name}" — ${mime}, ${size} bytes — could not be rendered${publicSuffix}]`,
183
+ };
152
184
  }
153
185
  }
154
186
 
187
+ /**
188
+ * Read a non-text file for the agent: the vision model's description or
189
+ * transcription, or a metadata stub when the file cannot be rendered.
190
+ */
191
+ async function renderBinary(
192
+ client: VfsClient,
193
+ rtCtx: RuntimeContext,
194
+ stat: VfsFileStat,
195
+ ): Promise<string> {
196
+ const rendered = await renderBinaryOrStub(client, rtCtx, stat);
197
+ return 'text' in rendered ? rendered.text : rendered.stub;
198
+ }
199
+
200
+ /**
201
+ * Like {@link renderBinary} for callers that need the file's real text (the
202
+ * memory plugin's document ingestion): a file that is too large, not
203
+ * renderable or that the vision model fails on throws instead of returning a
204
+ * stub, so a stub is never mistaken for the document's content. Pass
205
+ * `preloaded` when the bytes are already in hand (no second download);
206
+ * `truncated` reports a reply the provider cut at its output limit.
207
+ */
208
+ export async function renderBinaryForIngestion(
209
+ client: VfsClient,
210
+ rtCtx: RuntimeContext,
211
+ stat: VfsFileStat,
212
+ preloaded?: VfsContentBytes,
213
+ ): Promise<{ text: string; truncated: boolean }> {
214
+ const rendered = await renderBinaryOrStub(client, rtCtx, stat, preloaded);
215
+ if ('text' in rendered) return rendered;
216
+ throw new Error(rendered.stub);
217
+ }
218
+
155
219
  /**
156
220
  * Resolve a path to a file and return content the agent can read: numbered
157
221
  * lines for text, a vision transcription for images/PDFs, or a metadata stub
@@ -42,6 +42,33 @@ export interface VfsConfig {
42
42
  VFS_REQUEST_TIMEOUT_MS: number;
43
43
  }
44
44
 
45
+ /**
46
+ * Assemble the {@link VfsConfig} from a runtime config: worker URLs derived
47
+ * from `NETWORK` (or the per-deployment overrides), tuning knobs defaulted
48
+ * when absent or invalid. Shared by the plugin and by code that talks to the
49
+ * VFS on its own (the memory plugin's document ingestion).
50
+ */
51
+ export function resolveVfsConfig(config: unknown): VfsConfig {
52
+ const tuning = configSchema.safeParse(config);
53
+ const siblings = siblingEnvSchema.safeParse(config);
54
+ const network = (siblings.success && siblings.data.NETWORK) || 'mainnet';
55
+ return {
56
+ VFS_BASE_URL:
57
+ (siblings.success && siblings.data.VFS_BASE_URL) ||
58
+ VFS_DEFAULT_BASE_URLS[network] ||
59
+ // Unreachable: `network` is one of the three keys the map declares.
60
+ 'https://vfs.ixo.earth',
61
+ UCAN_STORE_URL:
62
+ (siblings.success && siblings.data.UCAN_STORE_URL) ||
63
+ UCAN_STORE_DEFAULT_URLS[network] ||
64
+ 'https://store.ucan.ixo.earth',
65
+ VFS_MAX_READ_LINES: tuning.success ? tuning.data.VFS_MAX_READ_LINES : 2000,
66
+ VFS_REQUEST_TIMEOUT_MS: tuning.success
67
+ ? tuning.data.VFS_REQUEST_TIMEOUT_MS
68
+ : 20000,
69
+ };
70
+ }
71
+
45
72
  const manifest: PluginManifest = {
46
73
  title: 'Files',
47
74
  summary:
@@ -149,26 +176,8 @@ export class VfsPlugin extends OraclePlugin {
149
176
  return [];
150
177
  }
151
178
 
152
- const tuning = configSchema.safeParse(rtCtx.config);
179
+ const cfg = resolveVfsConfig(rtCtx.config);
153
180
  const siblings = siblingEnvSchema.safeParse(rtCtx.config);
154
- const network = (siblings.success && siblings.data.NETWORK) || 'mainnet';
155
- const cfg: VfsConfig = {
156
- VFS_BASE_URL:
157
- (siblings.success && siblings.data.VFS_BASE_URL) ||
158
- VFS_DEFAULT_BASE_URLS[network] ||
159
- // Unreachable: `network` is one of the three keys the map declares.
160
- 'https://vfs.ixo.earth',
161
- UCAN_STORE_URL:
162
- (siblings.success && siblings.data.UCAN_STORE_URL) ||
163
- UCAN_STORE_DEFAULT_URLS[network] ||
164
- 'https://store.ucan.ixo.earth',
165
- VFS_MAX_READ_LINES: tuning.success
166
- ? tuning.data.VFS_MAX_READ_LINES
167
- : 2000,
168
- VFS_REQUEST_TIMEOUT_MS: tuning.success
169
- ? tuning.data.VFS_REQUEST_TIMEOUT_MS
170
- : 20000,
171
- };
172
181
 
173
182
  const tools = createVfsTools({
174
183
  cfg,
package/src/shell/app.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  ChannelError,
21
21
  ChannelTurnBody,
22
+ ChannelControlBody,
22
23
  channelRequestHash,
23
24
  readChannelBody,
24
25
  } from '../channels/contract';
@@ -247,6 +248,7 @@ export function rateLimitKey(
247
248
  | 'user'
248
249
  | 'poll'
249
250
  | 'channel'
251
+ | 'channel-control'
250
252
  | 'action'
251
253
  | 'artifact'
252
254
  | 'socket'
@@ -455,6 +457,74 @@ export function createShell(
455
457
  );
456
458
  });
457
459
 
460
+ app.post('/channels/control', async (c) => {
461
+ try {
462
+ if (parseChannelServiceDids(c.env.CHANNEL_SERVICE_DID).length === 0)
463
+ throw new ChannelError(
464
+ 503,
465
+ 'Channels are not configured',
466
+ 'not_configured',
467
+ );
468
+ const raw = await readChannelBody(c.req.raw);
469
+ let json: unknown;
470
+ try {
471
+ json = JSON.parse(raw);
472
+ } catch {
473
+ throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
474
+ }
475
+ const parsed = ChannelControlBody.safeParse(json);
476
+ if (!parsed.success)
477
+ throw new ChannelError(
478
+ 400,
479
+ 'Invalid channel control',
480
+ 'invalid_request',
481
+ );
482
+ const identity = await authenticateChannel(
483
+ c.req.raw.headers,
484
+ raw,
485
+ parsed.data,
486
+ channelAuthConfig(c.env),
487
+ );
488
+ if (
489
+ c.env.RATE_LIMIT &&
490
+ !(
491
+ await c.env.RATE_LIMIT.limit({
492
+ key: rateLimitKey(c.env, 'channel-control', identity.userDid),
493
+ })
494
+ ).success
495
+ )
496
+ throw new ChannelError(429, 'Too many requests', 'rate_limited');
497
+ await assertActiveChannelBindingCached(identity, c.env);
498
+ const outcome = await userStub(c.env, identity.userDid).channelControl(
499
+ identity,
500
+ parsed.data,
501
+ await channelRequestHash(raw),
502
+ );
503
+ return outcome.ok
504
+ ? c.json(outcome.result)
505
+ : c.json(
506
+ { code: outcome.code, message: outcome.message },
507
+ outcome.status,
508
+ );
509
+ } catch (error) {
510
+ if (error instanceof ChannelError)
511
+ return c.json(
512
+ { code: error.code, message: error.message },
513
+ error.status,
514
+ );
515
+ console.error(
516
+ `[channels] control failed: ${describeChannelFailure(error)}`,
517
+ );
518
+ return c.json(
519
+ {
520
+ code: 'unavailable',
521
+ message: 'Channel control is temporarily unavailable',
522
+ },
523
+ 503,
524
+ );
525
+ }
526
+ });
527
+
458
528
  app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
459
529
  // Auth Hub's signed outcome callbacks: on whenever Hub actions are
460
530
  // configured. The signature is checked against the Hub's own did:web key
@@ -13,6 +13,7 @@
13
13
  * circular-safe variant, typed.
14
14
  */
15
15
  import { TURN_TIME_NOTE_KWARG } from '../core/turn-time-note';
16
+ import { CHANNEL_REPLY_NOTE_KWARG } from '../channels/replies';
16
17
 
17
18
  /**
18
19
  * `additional_kwargs` key of the tool calls a mid-turn summary carries for
@@ -259,6 +260,11 @@ export interface CleanAdditionalKwargs {
259
260
  lc_source?: string;
260
261
  /** The exact time note the turn's content starts with (see core/turn-time-note.ts). */
261
262
  turn_time_note?: string;
263
+ /** The exact quoted-reply note after it (see channels/replies.ts). */
264
+ channel_reply_note?: string;
265
+ /** Where a channel message came from, and what it replied to, as the turn recorded them. */
266
+ 'org.ixo.qi.origin'?: unknown;
267
+ 'org.ixo.qi.reply'?: unknown;
262
268
  /** Tool calls of the turn a mid-turn summary condensed (newest kept, capped). */
263
269
  turn_carry?: unknown[];
264
270
  reasoning?: string;
@@ -361,6 +367,20 @@ export function cleanAdditionalKwargs(
361
367
  const turnTimeNote = additionalKwargs[TURN_TIME_NOTE_KWARG];
362
368
  if (typeof turnTimeNote === 'string')
363
369
  cleaned[TURN_TIME_NOTE_KWARG] = turnTimeNote;
370
+ // Kept for the same reason: without it the quoted text would read as the
371
+ // person's own words in the transcript, in memory indexing and in the
372
+ // evidence a proposal is checked against.
373
+ const replyNote = additionalKwargs[CHANNEL_REPLY_NOTE_KWARG];
374
+ if (typeof replyNote === 'string')
375
+ cleaned[CHANNEL_REPLY_NOTE_KWARG] = replyNote;
376
+ // Channel provenance, read back typed by the transcript (ChannelOrigin,
377
+ // ChannelReplyReference); anything malformed is dropped there.
378
+ for (const key of ['org.ixo.qi.origin', 'org.ixo.qi.reply'] as const)
379
+ if (
380
+ typeof additionalKwargs[key] === 'object' &&
381
+ additionalKwargs[key] !== null
382
+ )
383
+ cleaned[key] = additionalKwargs[key];
364
384
  // Kept so a run resumed after the summary still knows what this turn ran.
365
385
  const carry = cappedTurnCarry(additionalKwargs[TURN_CARRY_KWARG]);
366
386
  if (carry !== undefined) cleaned[TURN_CARRY_KWARG] = carry;
@@ -65,6 +65,9 @@ type SessionRow = {
65
65
  slack_thread_ts: string | null;
66
66
  };
67
67
 
68
+ /** Ids per statement in `summaries`. */
69
+ const SUMMARY_CHUNK = 50;
70
+
68
71
  type SessionRowWithTotal = SessionRow & { total: number };
69
72
 
70
73
  const SESSION_COLUMNS = `session_id, title, last_updated_at, created_at, oracle_name,
@@ -242,6 +245,46 @@ export class SessionsStore {
242
245
  return rooms;
243
246
  }
244
247
 
248
+ /**
249
+ * Title and times of many sessions, without their heavier columns, in
250
+ * statements of at most `SUMMARY_CHUNK` ids (a Durable Object binds at most
251
+ * 100 parameters). Sessions without a row are absent from the map.
252
+ */
253
+ async summaries(
254
+ sessionIds: readonly string[],
255
+ ): Promise<
256
+ Map<
257
+ string,
258
+ { title: string | null; createdAt: string; lastUpdatedAt: string }
259
+ >
260
+ > {
261
+ const found = new Map<
262
+ string,
263
+ { title: string | null; createdAt: string; lastUpdatedAt: string }
264
+ >();
265
+ if (sessionIds.length === 0) return found;
266
+ await this.setup();
267
+ for (let at = 0; at < sessionIds.length; at += SUMMARY_CHUNK) {
268
+ const chunk = sessionIds.slice(at, at + SUMMARY_CHUNK);
269
+ const rows = await this.db.exec<{
270
+ session_id: string;
271
+ title: string | null;
272
+ created_at: string;
273
+ last_updated_at: string;
274
+ }>(
275
+ `SELECT session_id, title, created_at, last_updated_at FROM sessions WHERE session_id IN (${chunk.map(() => '?').join(', ')})`,
276
+ [...chunk],
277
+ );
278
+ for (const row of rows)
279
+ found.set(row.session_id, {
280
+ title: row.title,
281
+ createdAt: row.created_at,
282
+ lastUpdatedAt: row.last_updated_at,
283
+ });
284
+ }
285
+ return found;
286
+ }
287
+
245
288
  async getSession(sessionId: string): Promise<SessionRecord | undefined> {
246
289
  await this.setup();
247
290
  const row = await this.db.get<SessionRow>(