@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
@@ -3,12 +3,28 @@
3
3
  * runtime's `SessionHistoryProcessor` (`modules/sessions/session-history-
4
4
  * processor.service.ts`).
5
5
  *
6
- * Policy (identical to Node):
6
+ * Policy (Node's, plus the channel trigger and the bounds below):
7
7
  * - Runs in the background when a session is CREATED (indexes the most
8
8
  * recent previous session) and when a session is DELETED (indexes it
9
- * before it goes). The request never waits for, or fails on, indexing.
9
+ * before it goes), on the last realtime disconnection, and once a channel
10
+ * session has been quiet for a while after its last finished run
11
+ * (`processAfterQuiet`: a new finish restarts the wait, so a burst of
12
+ * turns costs one upload). The request never waits for, or fails on,
13
+ * indexing.
10
14
  * - Only messages after the session's `lastProcessedCount` are sent, then
11
- * the count is advanced — a session is never indexed twice.
15
+ * the count is advanced — a session is never indexed twice. The tail is
16
+ * read page by page from a resume cursor this indexer keeps per session
17
+ * (valid while the stored count still matches it), so a long session is
18
+ * not re-read on every pass; without one (a fresh object, a rewritten
19
+ * transcript) the transcript is scanned from the start once. The tail is
20
+ * uploaded in chunks (`chunkMaxMessages` / `chunkMaxBytes`) and the count
21
+ * advances after each accepted chunk, so a failure never grows the next
22
+ * request body.
23
+ * - After a pass fails, the session is not tried again for a backoff that
24
+ * doubles from `backoffInitialMs` up to `backoffMaxMs` and resets on
25
+ * success: an engine outage costs one retry envelope per window, not one
26
+ * per finished turn. Deleting a session ignores the backoff (it is the
27
+ * transcript's last chance).
12
28
  * - Speakers get real identities (the user's preferred name / the oracle's
13
29
  * preferred or configured name) so Graphiti's extractor does not pin
14
30
  * facts to literal "user"/"assistant" nodes. Tool replies fold into the
@@ -20,8 +36,11 @@
20
36
  * `x-oracle-entity-did`: the engine keys the user's space by the oracle's
21
37
  * domain entity (`ORACLE_ENTITY_DID`). Without one nothing is indexed
22
38
  * (one warning per indexer) and the watermark stays put.
23
- * - A per-session lock prevents overlapping runs; a failed upload is
24
- * retried 3× with a 10 s pause (Node's numbers).
39
+ * - A per-session lock prevents overlapping runs; a call that arrives while
40
+ * the session is being indexed (at any stage) schedules one more pass for
41
+ * when the current one ends, so the final tail of a chat is never left
42
+ * behind. A failed upload is retried within the pass (see
43
+ * `DEFAULT_MAX_RETRIES`).
25
44
  *
26
45
  * Not ported: Node also asks Matrix for the user's display name when no
27
46
  * preferred name is set; here the fallback is the literal "Me" straight away.
@@ -49,6 +68,50 @@ export interface IndexableSession {
49
68
  lastProcessedCount?: number;
50
69
  }
51
70
 
71
+ /** One indexable message and the point a read resumes from after it. */
72
+ export interface HistoryEntry {
73
+ message: HistoryMessage;
74
+ /** Opaque to the indexer; handed back as `after` to read what follows. */
75
+ cursor: string;
76
+ }
77
+
78
+ /** A page of a session transcript, oldest first. */
79
+ export interface HistoryPage {
80
+ entries: HistoryEntry[];
81
+ /** More messages may follow the last entry. */
82
+ more: boolean;
83
+ }
84
+
85
+ /** Cursor of an in-memory transcript (`pageOfMessages`): the index of the next message. */
86
+ const ARRAY_CURSOR_PREFIX = '#';
87
+
88
+ /**
89
+ * `readMessages` over a transcript already held in memory (a deleted
90
+ * session's snapshot, tests). Its cursors are `#<index>`; any other cursor
91
+ * is not one of its own and reads as lost (`null`).
92
+ */
93
+ export function pageOfMessages(
94
+ messages: readonly HistoryMessage[],
95
+ after: string | null,
96
+ limit: number,
97
+ ): HistoryPage | null {
98
+ let start = 0;
99
+ if (after !== null) {
100
+ if (!after.startsWith(ARRAY_CURSOR_PREFIX)) return null;
101
+ start = Number(after.slice(ARRAY_CURSOR_PREFIX.length));
102
+ if (!Number.isInteger(start) || start < 0 || start > messages.length)
103
+ return null;
104
+ }
105
+ const end = Math.min(messages.length, start + Math.max(1, limit));
106
+ const entries: HistoryEntry[] = [];
107
+ for (let i = start; i < end; i += 1)
108
+ entries.push({
109
+ message: messages[i]!,
110
+ cursor: `${ARRAY_CURSOR_PREFIX}${i + 1}`,
111
+ });
112
+ return { entries, more: end < messages.length };
113
+ }
114
+
52
115
  export interface SpeakerLabels {
53
116
  user: string;
54
117
  oracle: string;
@@ -64,8 +127,17 @@ export interface SessionHistoryIndexerDeps {
64
127
  */
65
128
  entityDid: string | undefined;
66
129
  getSession(sessionId: string): Promise<IndexableSession | undefined>;
67
- /** The session transcript, summarisation bookkeeping already removed. */
68
- listMessages(sessionId: string): Promise<HistoryMessage[]>;
130
+ /**
131
+ * Up to about `limit` transcript messages after `after` (from the start
132
+ * when null), summarisation bookkeeping already removed: the count
133
+ * watermark counts these messages. Null when `after` no longer names a
134
+ * point in the transcript; the indexer then scans from the start.
135
+ */
136
+ readMessages(
137
+ sessionId: string,
138
+ after: string | null,
139
+ limit: number,
140
+ ): Promise<HistoryPage | null>;
69
141
  setProcessedCount(sessionId: string, count: number): Promise<void>;
70
142
  /** The user↔oracle room, for sessions whose row carries none. */
71
143
  resolveUserRoom(): Promise<{ roomId: string } | null>;
@@ -88,20 +160,75 @@ export interface SessionHistoryIndexerDeps {
88
160
  retryDelayMs?: number;
89
161
  /** Hard timeout for the memory-engine request (default 20 s). */
90
162
  requestTimeoutMs?: number;
163
+ /** Clock for the failure backoff (default `Date.now`). */
164
+ now?: () => number;
165
+ /** Messages per upload (default 200). */
166
+ chunkMaxMessages?: number;
167
+ /** Message content bytes (UTF-8) per upload (default 256 KiB; one larger message goes alone). */
168
+ chunkMaxBytes?: number;
169
+ /** First backoff after a failed pass (default 1 min); doubles per failure. */
170
+ backoffInitialMs?: number;
171
+ /** Longest backoff (default 1 h). */
172
+ backoffMaxMs?: number;
91
173
  }
92
174
 
93
175
  export type IndexOutcome = 'processed' | 'skipped' | 'failed';
94
176
 
177
+ export interface ProcessOptions {
178
+ /** Run even while the session is backing off after a failure (deletion). */
179
+ ignoreBackoff?: boolean;
180
+ }
181
+
182
+ /** Quiet period after a channel session's last finished run before it is indexed. */
183
+ export const CHANNEL_HISTORY_INDEX_DEBOUNCE_MS_DEFAULT = 180_000;
184
+
185
+ /** `CHANNEL_HISTORY_INDEX_DEBOUNCE_MS` from the validated env (its schema default otherwise). */
186
+ export function channelHistoryIndexDebounceMs(
187
+ env: Record<string, unknown>,
188
+ ): number {
189
+ const value = env['CHANNEL_HISTORY_INDEX_DEBOUNCE_MS'];
190
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
191
+ ? value
192
+ : CHANNEL_HISTORY_INDEX_DEBOUNCE_MS_DEFAULT;
193
+ }
194
+
95
195
  /**
96
196
  * Bounded background work: the indexer runs under `ctx.waitUntil`, so the
97
197
  * whole retry envelope keeps the user object resident (and un-hibernatable
98
198
  * with sockets attached). Two attempts, a short pause, and a hard timeout on
99
199
  * the memory-engine request keep that under a minute; a failed session is
100
- * simply retried on the next session create (the watermark did not move).
200
+ * retried by its next trigger once its backoff has passed (the watermark
201
+ * moved only for the chunks the engine accepted).
101
202
  */
102
203
  const DEFAULT_MAX_RETRIES = 2;
103
204
  const DEFAULT_RETRY_DELAY_MS = 3_000;
104
205
  const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
206
+ const DEFAULT_CHUNK_MAX_MESSAGES = 200;
207
+ const DEFAULT_CHUNK_MAX_BYTES = 256 * 1024;
208
+ const DEFAULT_BACKOFF_INITIAL_MS = 60_000;
209
+ const DEFAULT_BACKOFF_MAX_MS = 3_600_000;
210
+ /** Sessions whose resume cursor / backoff is remembered, least recent dropped first. */
211
+ const MAX_TRACKED_SESSIONS = 256;
212
+
213
+ const utf8 = new TextEncoder();
214
+
215
+ /** Insert as most recent; drop the least recent entries beyond `max`. */
216
+ function remember<V>(map: Map<string, V>, key: string, value: V): void {
217
+ map.delete(key);
218
+ map.set(key, value);
219
+ while (map.size > MAX_TRACKED_SESSIONS) {
220
+ const oldest = map.keys().next();
221
+ if (oldest.done) break;
222
+ map.delete(oldest.value);
223
+ }
224
+ }
225
+
226
+ interface QuietWait {
227
+ timer: ReturnType<typeof setTimeout>;
228
+ /** Settles once the pass the wait ends in has finished. */
229
+ done: Promise<void>;
230
+ resolve: () => void;
231
+ }
105
232
 
106
233
  /** Node's `transformMessagesToMemoryEngineFormat`, verbatim in mapping. */
107
234
  export function transformMessagesToMemoryEngineFormat(
@@ -151,10 +278,39 @@ function describe(err: unknown): string {
151
278
  }
152
279
 
153
280
  export class SessionHistoryIndexer {
154
- private readonly inFlight = new Set<string>();
281
+ /** Session → the pass running for it (reruns included). */
282
+ private readonly inFlight = new Map<string, Promise<IndexOutcome>>();
283
+
284
+ /** Sessions asked for again while a pass ran: one more pass follows it. */
285
+ private readonly rerun = new Map<string, ProcessOptions>();
286
+
287
+ /** Session → where its indexed tail ends, valid while the stored count is `count`. */
288
+ private readonly cursors = new Map<
289
+ string,
290
+ { count: number; cursor: string }
291
+ >();
292
+
293
+ /** Session → no new pass before `until`; `delayMs` doubles per failure. */
294
+ private readonly backoff = new Map<
295
+ string,
296
+ { until: number; delayMs: number }
297
+ >();
298
+
299
+ /** Session → its pending quiet-period wait (`processAfterQuiet`). */
300
+ private readonly quiet = new Map<string, QuietWait>();
155
301
 
156
302
  private readonly fetchImpl: typeof fetch;
157
303
 
304
+ private readonly now: () => number;
305
+
306
+ private readonly chunkMaxMessages: number;
307
+
308
+ private readonly chunkMaxBytes: number;
309
+
310
+ private readonly backoffInitialMs: number;
311
+
312
+ private readonly backoffMaxMs: number;
313
+
158
314
  private readonly sleep: (ms: number) => Promise<void>;
159
315
 
160
316
  private readonly maxRetries: number;
@@ -174,6 +330,20 @@ export class SessionHistoryIndexer {
174
330
  this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
175
331
  this.retryDelayMs = deps.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
176
332
  this.requestTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
333
+ this.now = deps.now ?? Date.now;
334
+ this.chunkMaxMessages = Math.max(
335
+ 1,
336
+ deps.chunkMaxMessages ?? DEFAULT_CHUNK_MAX_MESSAGES,
337
+ );
338
+ this.chunkMaxBytes = Math.max(
339
+ 1,
340
+ deps.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES,
341
+ );
342
+ this.backoffInitialMs = deps.backoffInitialMs ?? DEFAULT_BACKOFF_INITIAL_MS;
343
+ this.backoffMaxMs = Math.max(
344
+ this.backoffInitialMs,
345
+ deps.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS,
346
+ );
177
347
  }
178
348
 
179
349
  /** Sessions currently being indexed (background work keeping the object busy). */
@@ -188,17 +358,21 @@ export class SessionHistoryIndexer {
188
358
 
189
359
  /**
190
360
  * Index a session's unprocessed messages. Never throws: outcomes are
191
- * `processed`, `skipped` (nothing to do / prerequisites missing) or
192
- * `failed` (upload failed after retries). Concurrent calls for the same
193
- * session collapse into one.
361
+ * `processed`, `skipped` (nothing to do / prerequisites missing / backing
362
+ * off / joined a running pass) or `failed` (upload failed after retries).
363
+ * A call while the session is being indexed — at any stage, from the
364
+ * session read to the last upload — schedules one more pass after the
365
+ * running one and settles when that pass has, so a caller's `waitUntil`
366
+ * covers the tail it asked for. A pending quiet-period wait for the
367
+ * session is taken over by the pass that starts here.
194
368
  */
195
- async process(sessionId: string): Promise<IndexOutcome> {
369
+ process(sessionId: string, opts: ProcessOptions = {}): Promise<IndexOutcome> {
196
370
  const { logger } = this.deps;
197
371
  if (!this.enabled) {
198
372
  logger.log(
199
373
  `[history-indexer] MEMORY_ENGINE_URL not configured; not indexing session ${sessionId}`,
200
374
  );
201
- return 'skipped';
375
+ return Promise.resolve('skipped');
202
376
  }
203
377
  const entityDid = this.deps.entityDid;
204
378
  if (!entityDid) {
@@ -208,42 +382,115 @@ export class SessionHistoryIndexer {
208
382
  `[history-indexer] ORACLE_ENTITY_DID is not a domain entity DID (did:ixo:entity:<32 hex>); the memory engine keys every space by it, so session history is not indexed`,
209
383
  );
210
384
  }
211
- return 'skipped';
385
+ return Promise.resolve('skipped');
212
386
  }
213
- if (this.inFlight.has(sessionId)) {
387
+ const running = this.inFlight.get(sessionId);
388
+ if (running) {
389
+ const queued = this.rerun.get(sessionId);
390
+ this.rerun.set(sessionId, {
391
+ ignoreBackoff: Boolean(queued?.ignoreBackoff || opts.ignoreBackoff),
392
+ });
214
393
  logger.log(
215
- `[history-indexer] session ${sessionId} is already being indexed, skipping`,
394
+ `[history-indexer] session ${sessionId} is already being indexed, scheduling another pass`,
216
395
  );
217
- return 'skipped';
396
+ return running.then((): IndexOutcome => 'skipped');
218
397
  }
219
- this.inFlight.add(sessionId);
220
- try {
221
- for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
222
- try {
223
- const outcome = await this.processOnce(sessionId, entityDid);
224
- if (outcome === 'processed') {
225
- logger.log(
226
- `[history-indexer] indexed session ${sessionId} into the memory engine`,
227
- );
228
- }
229
- return outcome;
230
- } catch (err) {
231
- logger.warn(
232
- `[history-indexer] attempt ${attempt}/${this.maxRetries} failed for session ${sessionId}: ${describe(err)}`,
398
+ const blocked = this.backoff.get(sessionId);
399
+ if (!opts.ignoreBackoff && blocked && this.now() < blocked.until) {
400
+ logger.log(
401
+ `[history-indexer] session ${sessionId} failed recently; not retrying for another ${Math.ceil((blocked.until - this.now()) / 1000)}s`,
402
+ );
403
+ return Promise.resolve('skipped');
404
+ }
405
+ const waiting = this.quiet.get(sessionId);
406
+ if (waiting) {
407
+ clearTimeout(waiting.timer);
408
+ this.quiet.delete(sessionId);
409
+ }
410
+ const pass = (async (): Promise<IndexOutcome> => {
411
+ try {
412
+ return await this.attempts(sessionId, entityDid);
413
+ } finally {
414
+ this.inFlight.delete(sessionId);
415
+ const again = this.rerun.get(sessionId);
416
+ if (again) {
417
+ this.rerun.delete(sessionId);
418
+ await this.process(sessionId, again);
419
+ }
420
+ waiting?.resolve();
421
+ }
422
+ })();
423
+ this.inFlight.set(sessionId, pass);
424
+ return pass;
425
+ }
426
+
427
+ /**
428
+ * Index the session once it has been quiet for `delayMs`: each call
429
+ * restarts the wait, so a burst of finished turns ends in one pass. The
430
+ * promise settles when that pass has (hand it to `waitUntil`; a pending
431
+ * timer is also what keeps a Durable Object resident until it fires). A
432
+ * `delayMs` of 0 or less indexes at once.
433
+ */
434
+ processAfterQuiet(sessionId: string, delayMs: number): Promise<void> {
435
+ if (delayMs <= 0 || !this.enabled || !this.deps.entityDid)
436
+ return this.process(sessionId).then(() => undefined);
437
+ const pending = this.quiet.get(sessionId);
438
+ if (pending) clearTimeout(pending.timer);
439
+ let resolve: () => void = () => {};
440
+ const done =
441
+ pending?.done ??
442
+ new Promise<void>((settle) => {
443
+ resolve = settle;
444
+ });
445
+ const wait: QuietWait = {
446
+ done,
447
+ resolve: pending?.resolve ?? resolve,
448
+ timer: setTimeout(() => {
449
+ if (this.quiet.get(sessionId) !== wait) return;
450
+ this.quiet.delete(sessionId);
451
+ // `process` never rejects.
452
+ void this.process(sessionId).then(() => wait.resolve());
453
+ }, delayMs),
454
+ };
455
+ this.quiet.set(sessionId, wait);
456
+ return done;
457
+ }
458
+
459
+ /** The pass's attempts, with the failure backoff booked at its end. */
460
+ private async attempts(
461
+ sessionId: string,
462
+ entityDid: string,
463
+ ): Promise<IndexOutcome> {
464
+ const { logger } = this.deps;
465
+ for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
466
+ try {
467
+ const outcome = await this.processOnce(sessionId, entityDid);
468
+ if (outcome === 'processed') {
469
+ this.backoff.delete(sessionId);
470
+ logger.log(
471
+ `[history-indexer] indexed session ${sessionId} into the memory engine`,
233
472
  );
234
- if (attempt === this.maxRetries) {
235
- logger.error(
236
- `[history-indexer] giving up on session ${sessionId} after ${this.maxRetries} attempts`,
237
- );
238
- return 'failed';
239
- }
240
- await this.sleep(this.retryDelayMs);
241
473
  }
474
+ return outcome;
475
+ } catch (err) {
476
+ logger.warn(
477
+ `[history-indexer] attempt ${attempt}/${this.maxRetries} failed for session ${sessionId}: ${describe(err)}`,
478
+ );
479
+ if (attempt < this.maxRetries) await this.sleep(this.retryDelayMs);
242
480
  }
243
- return 'failed';
244
- } finally {
245
- this.inFlight.delete(sessionId);
246
481
  }
482
+ const previous = this.backoff.get(sessionId);
483
+ const delayMs = previous
484
+ ? Math.min(previous.delayMs * 2, this.backoffMaxMs)
485
+ : this.backoffInitialMs;
486
+ remember(this.backoff, sessionId, {
487
+ until: this.now() + delayMs,
488
+ delayMs,
489
+ });
490
+ logger.error(
491
+ `[history-indexer] giving up on session ${sessionId} after ${this.maxRetries} attempts; next try in ${Math.round(delayMs / 1000)}s at the earliest`,
492
+ );
493
+ return 'failed';
247
494
  }
248
495
 
249
496
  private async processOnce(
@@ -269,90 +516,182 @@ export class SessionHistoryIndexer {
269
516
  return 'skipped';
270
517
  }
271
518
 
272
- const messages = await deps.listMessages(sessionId);
273
- if (messages.length === 0) {
274
- logger.log(`[history-indexer] no messages in session ${sessionId}`);
275
- return 'skipped';
276
- }
277
-
278
519
  const lastProcessedCount = session.lastProcessedCount ?? 0;
279
- const newMessages = messages.slice(lastProcessedCount);
280
- if (newMessages.length === 0) {
520
+ const tail = this.tailReader(sessionId, lastProcessedCount);
521
+ let chunk = await tail.next();
522
+ if (chunk.length === 0) {
281
523
  logger.log(
282
524
  `[history-indexer] no new messages for session ${sessionId} (lastProcessedCount: ${lastProcessedCount})`,
283
525
  );
284
526
  return 'skipped';
285
527
  }
286
528
 
287
- const labels = await deps.speakerLabels(roomId);
288
- const transformed = transformMessagesToMemoryEngineFormat(
289
- // Tool-call-only AI turns carry no text; the engine has nothing to
290
- // extract from them and rejects empty episodes.
291
- newMessages.filter((m) => m.content.trim().length > 0),
292
- session.title ?? '',
293
- labels.user,
294
- labels.oracle,
295
- );
296
-
297
529
  if (!deps.ucan.hasSigningKey()) {
298
530
  logger.warn(
299
531
  `[history-indexer] no UCAN signing key; skipping memory engine processing for session ${sessionId}`,
300
532
  );
301
533
  return 'skipped';
302
534
  }
303
-
304
- // Node: a failed mint is a warn + skip, not a retry — the user simply has
305
- // not delegated `ixo:memory` to this oracle (yet).
306
- let invocation: string | null = null;
307
- try {
308
- const memoryDid = await deps.ucan.resolveServiceDid(memoryEngineUrl);
309
- if (memoryDid) {
310
- invocation = await deps.ucan.mintInvocation(
535
+ const memoryDid = await deps.ucan
536
+ .resolveServiceDid(memoryEngineUrl)
537
+ .catch((err: unknown) => {
538
+ logger.warn(
539
+ `[history-indexer] could not resolve the memory engine DID: ${describe(err)}`,
540
+ );
541
+ return null;
542
+ });
543
+ // One invocation per upload request.
544
+ const mint = async (): Promise<string | null> => {
545
+ if (!memoryDid) return null;
546
+ try {
547
+ return await deps.ucan.mintInvocation(
311
548
  { did: memoryDid, capability: 'ixo:memory' },
312
549
  // Claim the ability the user's delegation actually grants. A `'*'`
313
550
  // claim is satisfiable only by a `'*'` grant.
314
551
  { can: 'memory/*' },
315
552
  );
553
+ } catch (err) {
554
+ logger.warn(
555
+ `[history-indexer] could not mint a memory engine invocation: ${describe(err)}`,
556
+ );
557
+ return null;
316
558
  }
317
- } catch (err) {
318
- logger.warn(
319
- `[history-indexer] could not mint a memory engine invocation: ${describe(err)}`,
320
- );
321
- }
322
- if (!invocation) {
323
- logger.warn(
324
- `[history-indexer] no memory engine invocation for session ${sessionId}; skipping`,
325
- );
326
- return 'skipped';
327
- }
559
+ };
328
560
 
329
- if (transformed.length > 0) {
330
- const res = await this.fetchImpl(`${memoryEngineUrl}/messages`, {
331
- method: 'POST',
332
- headers: {
333
- Authorization: `Bearer ${invocation}`,
334
- 'X-Auth-Type': 'ucan',
335
- [MEMORY_ENTITY_HEADER]: entityDid,
336
- 'x-room-id': roomId,
337
- 'Content-Type': 'application/json',
338
- },
339
- body: JSON.stringify({ messages: transformed }),
340
- signal: AbortSignal.timeout(this.requestTimeoutMs),
341
- });
342
- if (!res.ok) {
343
- const text = await res.text().catch(() => '');
344
- throw new Error(
345
- `memory engine rejected the conversation (${res.status}): ${text.slice(0, 200)}`,
561
+ const labels = await deps.speakerLabels(roomId);
562
+ let count = lastProcessedCount;
563
+ let uploaded = false;
564
+ try {
565
+ while (chunk.length > 0) {
566
+ const transformed = transformMessagesToMemoryEngineFormat(
567
+ // Tool-call-only AI turns carry no text; the engine has nothing to
568
+ // extract from them and rejects empty episodes.
569
+ chunk
570
+ .map((entry) => entry.message)
571
+ .filter((m) => m.content.trim().length > 0),
572
+ session.title ?? '',
573
+ labels.user,
574
+ labels.oracle,
346
575
  );
576
+ if (transformed.length > 0) {
577
+ const invocation = await mint();
578
+ if (!invocation) {
579
+ // Node: a failed mint is a warn + skip, not a retry — the user
580
+ // simply has not delegated `ixo:memory` to this oracle (yet).
581
+ if (!uploaded) {
582
+ logger.warn(
583
+ `[history-indexer] no memory engine invocation for session ${sessionId}; skipping`,
584
+ );
585
+ return 'skipped';
586
+ }
587
+ throw new Error('no memory engine invocation for the next chunk');
588
+ }
589
+ const res = await this.fetchImpl(`${memoryEngineUrl}/messages`, {
590
+ method: 'POST',
591
+ headers: {
592
+ Authorization: `Bearer ${invocation}`,
593
+ 'X-Auth-Type': 'ucan',
594
+ [MEMORY_ENTITY_HEADER]: entityDid,
595
+ 'x-room-id': roomId,
596
+ 'Content-Type': 'application/json',
597
+ },
598
+ body: JSON.stringify({ messages: transformed }),
599
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
600
+ });
601
+ if (!res.ok) {
602
+ const text = await res.text().catch(() => '');
603
+ throw new Error(
604
+ `memory engine rejected the conversation (${res.status}): ${text.slice(0, 200)}`,
605
+ );
606
+ }
607
+ uploaded = true;
608
+ }
609
+ count += chunk.length;
610
+ await deps.setProcessedCount(sessionId, count);
611
+ remember(this.cursors, sessionId, {
612
+ count,
613
+ cursor: chunk[chunk.length - 1]!.cursor,
614
+ });
615
+ logger.log(
616
+ `[history-indexer] processed ${chunk.length} new messages for session ${sessionId} (lastProcessedCount → ${count})`,
617
+ );
618
+ chunk = await tail.next();
347
619
  }
348
- deps.onIndexed?.(roomId);
620
+ } finally {
621
+ // Once per pass, also when a later chunk failed: what the engine
622
+ // accepted already changes what the prefetch would recall.
623
+ if (uploaded) deps.onIndexed?.(roomId);
349
624
  }
350
-
351
- const newCount = lastProcessedCount + newMessages.length;
352
- await deps.setProcessedCount(sessionId, newCount);
353
- logger.log(
354
- `[history-indexer] processed ${newMessages.length} new messages for session ${sessionId} (lastProcessedCount → ${newCount})`,
355
- );
356
625
  return 'processed';
357
626
  }
627
+
628
+ /**
629
+ * Reads the unindexed tail in upload-sized chunks. It resumes from the
630
+ * remembered cursor when that cursor belongs to the stored watermark;
631
+ * otherwise (a fresh object, a count moved elsewhere, a cursor the store
632
+ * no longer knows) it scans from the start and skips the `watermark`
633
+ * messages already indexed.
634
+ */
635
+ private tailReader(
636
+ sessionId: string,
637
+ watermark: number,
638
+ ): { next(): Promise<HistoryEntry[]> } {
639
+ const known = this.cursors.get(sessionId);
640
+ let cursor: string | null =
641
+ known && known.count === watermark ? known.cursor : null;
642
+ let skip = cursor === null ? watermark : 0;
643
+ /** Messages handed out (and indexed, if the caller got that far) so far. */
644
+ let consumed = watermark;
645
+ let exhausted = false;
646
+ const pageSize = this.chunkMaxMessages;
647
+ return {
648
+ next: async (): Promise<HistoryEntry[]> => {
649
+ const chunk: HistoryEntry[] = [];
650
+ let bytes = 0;
651
+ while (!exhausted) {
652
+ const page = await this.deps.readMessages(
653
+ sessionId,
654
+ cursor,
655
+ pageSize,
656
+ );
657
+ if (page === null) {
658
+ if (cursor === null) {
659
+ exhausted = true;
660
+ break;
661
+ }
662
+ // The transcript was rewritten under the cursor: start over,
663
+ // skip what is indexed so far and collect this chunk again.
664
+ this.cursors.delete(sessionId);
665
+ cursor = null;
666
+ skip = consumed;
667
+ chunk.length = 0;
668
+ bytes = 0;
669
+ continue;
670
+ }
671
+ for (const entry of page.entries) {
672
+ if (skip > 0) {
673
+ skip -= 1;
674
+ cursor = entry.cursor;
675
+ continue;
676
+ }
677
+ const size = utf8.encode(entry.message.content).byteLength;
678
+ if (
679
+ chunk.length > 0 &&
680
+ (chunk.length >= this.chunkMaxMessages ||
681
+ bytes + size > this.chunkMaxBytes)
682
+ ) {
683
+ consumed += chunk.length;
684
+ return chunk;
685
+ }
686
+ chunk.push(entry);
687
+ bytes += size;
688
+ cursor = entry.cursor;
689
+ }
690
+ if (!page.more || page.entries.length === 0) exhausted = true;
691
+ }
692
+ consumed += chunk.length;
693
+ return chunk;
694
+ },
695
+ };
696
+ }
358
697
  }
@@ -725,6 +725,19 @@ export class UserContextFetcher {
725
725
  }
726
726
  }
727
727
 
728
+ /**
729
+ * The room's answer when the cache holds a live one, else undefined —
730
+ * synchronous: it never waits and never starts a gather (pair it with
731
+ * `refresh`). A hit is recorded as the room's last outcome, as `fetch`
732
+ * records one.
733
+ */
734
+ readCached(
735
+ roomId: string,
736
+ ): Extract<UserContextFetchOutcome, { kind: 'cached' }> | undefined {
737
+ const cached = this.readCache(roomId);
738
+ return cached ? this.record(roomId, cached) : undefined;
739
+ }
740
+
728
741
  /** Cache / in-flight state of one room, for `/debug/context` and tests. */
729
742
  status(roomId: string): UserContextStatus {
730
743
  const cached = this.readCache(roomId, false);