@ixo/oracle-runtime-workers 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/attachments/extract.ts +108 -14
- package/src/attachments/index.ts +15 -0
- package/src/attachments/pdf-text.ts +99 -0
- package/src/attachments/pipeline.ts +47 -4
- package/src/attachments/sanitize.ts +14 -0
- package/src/attachments/view-cache.ts +95 -0
- package/src/attachments/view.ts +308 -3
- package/src/channels/auth.ts +2 -1
- package/src/channels/contract.ts +104 -0
- package/src/channels/replies.ts +58 -0
- package/src/channels/routing.ts +222 -0
- package/src/channels/turns.ts +1252 -16
- package/src/core/env.ts +17 -0
- package/src/do/contracts.ts +19 -0
- package/src/do/room-mirror.ts +8 -1
- package/src/do/run-coordinator.ts +27 -0
- package/src/do/transcript.ts +14 -11
- package/src/do/turn-messages.ts +54 -0
- package/src/do/user-oracle-do.ts +321 -135
- package/src/memory/session-history-indexer.ts +442 -103
- package/src/memory/user-context-fetcher.ts +13 -0
- package/src/memory/user-context-invalidation.ts +8 -2
- package/src/plugins/action-proposals/evidence.ts +7 -8
- package/src/plugins/memory/document-chunker.ts +313 -0
- package/src/plugins/memory/document-source.ts +229 -0
- package/src/plugins/memory/memory-engine-client.ts +352 -0
- package/src/plugins/memory/memory.plugin.ts +25 -2
- package/src/plugins/memory/oracle-document-tools.ts +677 -0
- package/src/plugins/tasks/middleware.ts +4 -9
- package/src/plugins/vfs/index.ts +11 -2
- package/src/plugins/vfs/vfs-content.ts +86 -22
- package/src/plugins/vfs/vfs.plugin.ts +28 -19
- package/src/shell/app.ts +70 -0
- package/src/sqlite/serialization.ts +20 -0
- package/src/sqlite/sessions-store.ts +43 -0
|
@@ -3,12 +3,28 @@
|
|
|
3
3
|
* runtime's `SessionHistoryProcessor` (`modules/sessions/session-history-
|
|
4
4
|
* processor.service.ts`).
|
|
5
5
|
*
|
|
6
|
-
* Policy (
|
|
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)
|
|
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
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
68
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
192
|
-
* `failed` (upload failed after retries).
|
|
193
|
-
* session
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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);
|