@ixo/oracle-runtime-workers 0.19.0 → 0.20.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 -3
- package/src/core/env.ts +32 -0
- package/src/core/main-agent.ts +20 -0
- package/src/core/middlewares/tool-marks.ts +2 -1
- package/src/core/prompt-composer.ts +3 -1
- package/src/do/contracts.ts +27 -0
- package/src/do/user-oracle-do.ts +323 -9
- package/src/memory/session-history-indexer.ts +3 -0
- package/src/memory/user-context-fetcher.ts +977 -0
- package/src/memory/user-context-invalidation.ts +66 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-turn user-context prefetch — the Workers port of the Node runtime's
|
|
3
|
+
* `UserContextFetcher` plus `@ixo/common`'s
|
|
4
|
+
* `MemoryEngineService.gatherUserContext`.
|
|
5
|
+
*
|
|
6
|
+
* Six memory-engine queries (identity, work, goals, interests,
|
|
7
|
+
* relationships, recent) go out in one `POST /search-enhanced-batch`; the
|
|
8
|
+
* answers fill `state.userContext`, which the prompt composer renders as the
|
|
9
|
+
* memory-context block before the model's first call.
|
|
10
|
+
*
|
|
11
|
+
* Policy:
|
|
12
|
+
* - The result is a function of the room (and the oracle), not the session,
|
|
13
|
+
* so the cache is keyed by room: a new session in the same room reuses it.
|
|
14
|
+
* Positive entries live `cacheTtlMs` (5 min) in an LRU of `maxCachedRooms`.
|
|
15
|
+
* A successful batch is cached even when every slot is empty — "no
|
|
16
|
+
* memory yet" is a valid answer — but only for `emptyCacheTtlMs` (30 s):
|
|
17
|
+
* the user's first memories should show up soon after they are written.
|
|
18
|
+
* - A transport failure (HTTP error, abort, thrown fetch, malformed body)
|
|
19
|
+
* or a batch whose every slot failed is negative-cached for
|
|
20
|
+
* `failureTtlMs` (60 s), checked AFTER the positive cache so a late
|
|
21
|
+
* success wins over an earlier failure. A degraded engine costs one
|
|
22
|
+
* attempt per window, not one per turn. A failure reason never carries
|
|
23
|
+
* the engine's response body.
|
|
24
|
+
* - `invalidate(room)` drops the room's answer; the runtime calls it after
|
|
25
|
+
* a memory write and after session history is indexed.
|
|
26
|
+
* - A missing signing key or a failed invocation mint is a skip, never a
|
|
27
|
+
* negative-cache entry: the user may delegate `ixo:memory` before the next
|
|
28
|
+
* turn.
|
|
29
|
+
* - One gather per room at a time; a second caller joins it. The blocking
|
|
30
|
+
* `fetch` waits at most `blockingCapMs`; past the cap the gather keeps
|
|
31
|
+
* running and fills the cache for later turns (the caller keeps the
|
|
32
|
+
* object alive with `ctx.waitUntil(refresh(...))`).
|
|
33
|
+
* - Stored slots are trimmed to what the composer renders, every stored
|
|
34
|
+
* string is capped at `USER_CONTEXT_MAX_TEXT_CHARS`, and the whole
|
|
35
|
+
* context is held under `USER_CONTEXT_MAX_JSON_CHARS` (episodes go
|
|
36
|
+
* first, then entity summaries), so the checkpointed state stays small;
|
|
37
|
+
* a slot with nothing to render is omitted.
|
|
38
|
+
*
|
|
39
|
+
* Nothing here touches Workers globals at module scope: timers, `fetch` and
|
|
40
|
+
* `AbortSignal.timeout` only run inside request-time calls.
|
|
41
|
+
*/
|
|
42
|
+
import type { MemoryContextSection } from '../core/prompt-composer';
|
|
43
|
+
import type { Logger, UserContextData } from '../plugin-api/types';
|
|
44
|
+
import type {
|
|
45
|
+
ComparisonOperator,
|
|
46
|
+
EntityType,
|
|
47
|
+
KnowledgeLevel,
|
|
48
|
+
SearchStrategy,
|
|
49
|
+
} from '../plugins/memory/types';
|
|
50
|
+
|
|
51
|
+
// ─── Slots and queries ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** Query order of the batch; the response's `results` follow it. */
|
|
54
|
+
export const USER_CONTEXT_SLOTS = [
|
|
55
|
+
'identity',
|
|
56
|
+
'work',
|
|
57
|
+
'goals',
|
|
58
|
+
'interests',
|
|
59
|
+
'relationships',
|
|
60
|
+
'recent',
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export type UserContextSlot = (typeof USER_CONTEXT_SLOTS)[number];
|
|
64
|
+
|
|
65
|
+
/** The memory engine's date filter; `date: null` pairs with `IS NULL`. */
|
|
66
|
+
export interface UserContextDateFilter {
|
|
67
|
+
date: string | null;
|
|
68
|
+
comparison_operator: ComparisonOperator;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One `/search-enhanced` request inside the batch. */
|
|
72
|
+
export interface UserContextQuery {
|
|
73
|
+
oracle_dids: string[];
|
|
74
|
+
query: string;
|
|
75
|
+
strategy: SearchStrategy;
|
|
76
|
+
max_facts: number;
|
|
77
|
+
max_entities: number;
|
|
78
|
+
max_episodes: number;
|
|
79
|
+
max_communities: number;
|
|
80
|
+
knowledge_level: KnowledgeLevel;
|
|
81
|
+
search_filters: {
|
|
82
|
+
node_labels?: EntityType[];
|
|
83
|
+
invalid_at?: UserContextDateFilter[][];
|
|
84
|
+
created_at?: UserContextDateFilter[][];
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Superseded facts carry an `invalid_at`; every slot but `recent` drops them. */
|
|
89
|
+
const CURRENT_FACTS_ONLY: UserContextDateFilter[][] = [
|
|
90
|
+
[{ date: null, comparison_operator: 'IS NULL' }],
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const RECENT_WINDOW_DAYS = 90;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The six requests, identical to `@ixo/common`'s `MemoryEngineService`
|
|
97
|
+
* builders: natural-language queries (embedding search ranks sentences
|
|
98
|
+
* better than keyword bags), no `edge_types` (AND-ed with labels, too
|
|
99
|
+
* brittle), `node_labels` only where it discriminates, and the `recent`
|
|
100
|
+
* slot bounded to the last 90 days explicitly (the server's
|
|
101
|
+
* `recent_memory` strategy injects the same lower bound and keeps an
|
|
102
|
+
* existing one).
|
|
103
|
+
*/
|
|
104
|
+
export function buildUserContextQueries(
|
|
105
|
+
oracleDid: string,
|
|
106
|
+
now: Date,
|
|
107
|
+
): UserContextQuery[] {
|
|
108
|
+
const recentSince = new Date(now.getTime());
|
|
109
|
+
recentSince.setUTCDate(recentSince.getUTCDate() - RECENT_WINDOW_DAYS);
|
|
110
|
+
const oracle_dids = [oracleDid];
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
oracle_dids,
|
|
114
|
+
query:
|
|
115
|
+
'Who is the user — their name, age, background, personality traits, communication style, beliefs, values, and the core attributes that define how they see themselves and how they like to be addressed.',
|
|
116
|
+
strategy: 'balanced',
|
|
117
|
+
max_facts: 12,
|
|
118
|
+
max_entities: 8,
|
|
119
|
+
max_episodes: 3,
|
|
120
|
+
max_communities: 2,
|
|
121
|
+
knowledge_level: 'both',
|
|
122
|
+
search_filters: {
|
|
123
|
+
node_labels: [
|
|
124
|
+
'Person',
|
|
125
|
+
'Trait',
|
|
126
|
+
'Value',
|
|
127
|
+
'Identity',
|
|
128
|
+
'Attribute',
|
|
129
|
+
'Belief',
|
|
130
|
+
'CommunicationStyle',
|
|
131
|
+
'Language',
|
|
132
|
+
],
|
|
133
|
+
invalid_at: CURRENT_FACTS_ONLY,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
oracle_dids,
|
|
138
|
+
query:
|
|
139
|
+
'What the user works on — their job and role, current and past projects, the tools and technologies they use, code or systems they build, technical problems they solve (migrations, refactors, bug fixes, integrations), and the people they collaborate with at work.',
|
|
140
|
+
strategy: 'balanced',
|
|
141
|
+
max_facts: 15,
|
|
142
|
+
max_entities: 10,
|
|
143
|
+
max_episodes: 5,
|
|
144
|
+
max_communities: 2,
|
|
145
|
+
knowledge_level: 'both',
|
|
146
|
+
search_filters: { invalid_at: CURRENT_FACTS_ONLY },
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
oracle_dids,
|
|
150
|
+
query:
|
|
151
|
+
'What the user is trying to achieve or improve — goals and aspirations they have stated, things they are learning, habits and routines they are building, milestones they are working toward, and causes they care about.',
|
|
152
|
+
strategy: 'balanced',
|
|
153
|
+
max_facts: 10,
|
|
154
|
+
max_entities: 6,
|
|
155
|
+
max_episodes: 3,
|
|
156
|
+
max_communities: 1,
|
|
157
|
+
knowledge_level: 'both',
|
|
158
|
+
search_filters: { invalid_at: CURRENT_FACTS_ONLY },
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
oracle_dids,
|
|
162
|
+
query:
|
|
163
|
+
'What the user enjoys, cares about, or finds interesting — hobbies, favorite topics, preferences, products and content they engage with, things they like and dislike, and areas they have built expertise in outside of work.',
|
|
164
|
+
strategy: 'balanced',
|
|
165
|
+
max_facts: 10,
|
|
166
|
+
max_entities: 6,
|
|
167
|
+
max_episodes: 3,
|
|
168
|
+
max_communities: 1,
|
|
169
|
+
knowledge_level: 'both',
|
|
170
|
+
search_filters: { invalid_at: CURRENT_FACTS_ONLY },
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
oracle_dids,
|
|
174
|
+
query:
|
|
175
|
+
'People the user interacts with or has mentioned — colleagues they work with, collaborators on projects, friends and family members, and any named individuals or groups they have brought up in conversation.',
|
|
176
|
+
strategy: 'balanced',
|
|
177
|
+
max_facts: 8,
|
|
178
|
+
max_entities: 10,
|
|
179
|
+
max_episodes: 3,
|
|
180
|
+
max_communities: 1,
|
|
181
|
+
knowledge_level: 'both',
|
|
182
|
+
search_filters: { invalid_at: CURRENT_FACTS_ONLY },
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
oracle_dids,
|
|
186
|
+
query:
|
|
187
|
+
'What the user has been doing, focusing on, or talking about recently — current activities, decisions they have made, ongoing problems they are working through, and topics that have come up in recent conversations.',
|
|
188
|
+
strategy: 'recent_memory',
|
|
189
|
+
max_facts: 10,
|
|
190
|
+
max_entities: 6,
|
|
191
|
+
max_episodes: 8,
|
|
192
|
+
max_communities: 2,
|
|
193
|
+
knowledge_level: 'both',
|
|
194
|
+
// No node_labels: recent activity can be of any type.
|
|
195
|
+
search_filters: {
|
|
196
|
+
created_at: [
|
|
197
|
+
[
|
|
198
|
+
{
|
|
199
|
+
date: recentSince.toISOString(),
|
|
200
|
+
comparison_operator: '>=',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Trimming ─────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export interface UserContextTotals {
|
|
212
|
+
facts: number;
|
|
213
|
+
entities: number;
|
|
214
|
+
episodes: number;
|
|
215
|
+
communities: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A `/search-enhanced` answer as far as trimming needs it. Elements are
|
|
220
|
+
* `unknown` because they come straight from JSON; `trimUserContextSlot`
|
|
221
|
+
* keeps only well-formed ones. `@ixo/common`'s `SearchEnhancedResponse` is
|
|
222
|
+
* assignable to it.
|
|
223
|
+
*/
|
|
224
|
+
export interface SearchEnhancedResponseLike {
|
|
225
|
+
total_results?: Partial<UserContextTotals>;
|
|
226
|
+
facts?: readonly unknown[];
|
|
227
|
+
entities?: readonly unknown[];
|
|
228
|
+
episodes?: readonly unknown[];
|
|
229
|
+
communities?: readonly unknown[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** One stored slot: what the composer renders, plus the engine's totals. */
|
|
233
|
+
export type TrimmedUserContextSlot = MemoryContextSection & {
|
|
234
|
+
total_results?: UserContextTotals;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
238
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function optionalText(value: unknown): string | undefined {
|
|
242
|
+
return typeof value === 'string' ? value : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Longest stored fact, summary or episode text (characters, ellipsis included). */
|
|
246
|
+
export const USER_CONTEXT_MAX_TEXT_CHARS = 400;
|
|
247
|
+
|
|
248
|
+
/** Budget of the whole stored context, measured as its JSON length. */
|
|
249
|
+
export const USER_CONTEXT_MAX_JSON_CHARS = 16_384;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Cap a stored string at `max` characters: cut at the last word boundary
|
|
253
|
+
* inside the limit (or hard, when that would drop more than half) and end
|
|
254
|
+
* with `…`.
|
|
255
|
+
*/
|
|
256
|
+
export function capUserContextText(
|
|
257
|
+
text: string,
|
|
258
|
+
max: number = USER_CONTEXT_MAX_TEXT_CHARS,
|
|
259
|
+
): string {
|
|
260
|
+
if (text.length <= max) return text;
|
|
261
|
+
const head = text.slice(0, max - 1);
|
|
262
|
+
const lastSpace = head.search(/\s\S*$/);
|
|
263
|
+
const cut = lastSpace >= max / 2 ? head.slice(0, lastSpace) : head;
|
|
264
|
+
return `${cut.trimEnd()}…`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readTotals(value: unknown): UserContextTotals | undefined {
|
|
268
|
+
if (!isRecord(value)) return undefined;
|
|
269
|
+
const { facts, entities, episodes, communities } = value;
|
|
270
|
+
if (
|
|
271
|
+
typeof facts !== 'number' ||
|
|
272
|
+
typeof entities !== 'number' ||
|
|
273
|
+
typeof episodes !== 'number' ||
|
|
274
|
+
typeof communities !== 'number'
|
|
275
|
+
)
|
|
276
|
+
return undefined;
|
|
277
|
+
return { facts, entities, episodes, communities };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Keep the fields the prompt composer reads (`MemoryContextSection`):
|
|
282
|
+
* entities `{name, labels, summary}`, facts `{fact}`, episodes
|
|
283
|
+
* `{content, created_at}`, communities `{name, summary}`. Uuids, group ids
|
|
284
|
+
* and timestamps the prompt never shows are dropped; entries without the
|
|
285
|
+
* text the composer renders are dropped too. Facts, summaries and episode
|
|
286
|
+
* contents are capped at `USER_CONTEXT_MAX_TEXT_CHARS`.
|
|
287
|
+
*/
|
|
288
|
+
export function trimUserContextSlot(
|
|
289
|
+
slot: SearchEnhancedResponseLike,
|
|
290
|
+
): TrimmedUserContextSlot {
|
|
291
|
+
const entities: NonNullable<MemoryContextSection['entities']> = [];
|
|
292
|
+
for (const raw of slot.entities ?? []) {
|
|
293
|
+
if (!isRecord(raw) || typeof raw.name !== 'string' || !raw.name.trim())
|
|
294
|
+
continue;
|
|
295
|
+
const labels = Array.isArray(raw.labels)
|
|
296
|
+
? raw.labels.filter((l): l is string => typeof l === 'string')
|
|
297
|
+
: undefined;
|
|
298
|
+
const summary = capped(raw.summary);
|
|
299
|
+
entities.push({
|
|
300
|
+
name: raw.name,
|
|
301
|
+
...(labels && labels.length > 0 ? { labels } : {}),
|
|
302
|
+
...(summary ? { summary } : {}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const facts: NonNullable<MemoryContextSection['facts']> = [];
|
|
307
|
+
for (const raw of slot.facts ?? []) {
|
|
308
|
+
if (!isRecord(raw)) continue;
|
|
309
|
+
const fact = capped(raw.fact);
|
|
310
|
+
if (fact) facts.push({ fact });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const episodes: NonNullable<MemoryContextSection['episodes']> = [];
|
|
314
|
+
for (const raw of slot.episodes ?? []) {
|
|
315
|
+
if (!isRecord(raw)) continue;
|
|
316
|
+
const content = capped(raw.content);
|
|
317
|
+
if (!content) continue;
|
|
318
|
+
const createdAt = optionalText(raw.created_at);
|
|
319
|
+
episodes.push({
|
|
320
|
+
content,
|
|
321
|
+
...(createdAt ? { created_at: createdAt } : {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const communities: NonNullable<MemoryContextSection['communities']> = [];
|
|
326
|
+
for (const raw of slot.communities ?? []) {
|
|
327
|
+
if (!isRecord(raw) || typeof raw.name !== 'string' || !raw.name.trim())
|
|
328
|
+
continue;
|
|
329
|
+
const summary = capped(raw.summary);
|
|
330
|
+
communities.push({ name: raw.name, ...(summary ? { summary } : {}) });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const totals = readTotals(slot.total_results);
|
|
334
|
+
return {
|
|
335
|
+
...(entities.length > 0 ? { entities } : {}),
|
|
336
|
+
...(facts.length > 0 ? { facts } : {}),
|
|
337
|
+
...(episodes.length > 0 ? { episodes } : {}),
|
|
338
|
+
...(communities.length > 0 ? { communities } : {}),
|
|
339
|
+
...(totals ? { total_results: totals } : {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A non-blank string, capped; undefined otherwise. */
|
|
344
|
+
function capped(value: unknown): string | undefined {
|
|
345
|
+
const text = optionalText(value);
|
|
346
|
+
return text && text.trim() ? capUserContextText(text) : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hasContent(slot: TrimmedUserContextSlot): boolean {
|
|
350
|
+
return Boolean(
|
|
351
|
+
slot.entities?.length ||
|
|
352
|
+
slot.facts?.length ||
|
|
353
|
+
slot.episodes?.length ||
|
|
354
|
+
slot.communities?.length,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** `f12/e8/p3/c1`: the rendered counts of a slot, for logs and status. */
|
|
359
|
+
function slotSummary(slot: TrimmedUserContextSlot): string {
|
|
360
|
+
return `f${slot.facts?.length ?? 0}/e${slot.entities?.length ?? 0}/p${slot.episodes?.length ?? 0}/c${slot.communities?.length ?? 0}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export type TrimmedUserContext = Partial<
|
|
364
|
+
Record<UserContextSlot, TrimmedUserContextSlot>
|
|
365
|
+
>;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Hold a trimmed context under `maxChars` of JSON: drop episodes from the
|
|
369
|
+
* end of each slot, one slot at a time in query order, until it fits; then
|
|
370
|
+
* entity summaries the same way. A slot left with nothing to render is
|
|
371
|
+
* omitted. Facts, entity names and communities are never dropped, so a
|
|
372
|
+
* context made only of those can stay above the budget (each fact is
|
|
373
|
+
* already capped at `USER_CONTEXT_MAX_TEXT_CHARS`, and the engine returns
|
|
374
|
+
* at most 65 across the six queries). The input is not modified.
|
|
375
|
+
*/
|
|
376
|
+
export function fitUserContextBudget(
|
|
377
|
+
context: TrimmedUserContext,
|
|
378
|
+
maxChars: number = USER_CONTEXT_MAX_JSON_CHARS,
|
|
379
|
+
): {
|
|
380
|
+
context: TrimmedUserContext;
|
|
381
|
+
droppedEpisodes: number;
|
|
382
|
+
droppedSummaries: number;
|
|
383
|
+
} {
|
|
384
|
+
const work: TrimmedUserContext = {};
|
|
385
|
+
for (const label of USER_CONTEXT_SLOTS) {
|
|
386
|
+
const slot = context[label];
|
|
387
|
+
if (!slot) continue;
|
|
388
|
+
work[label] = {
|
|
389
|
+
...slot,
|
|
390
|
+
...(slot.entities
|
|
391
|
+
? { entities: slot.entities.map((e) => ({ ...e })) }
|
|
392
|
+
: {}),
|
|
393
|
+
...(slot.episodes ? { episodes: [...slot.episodes] } : {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const over = (): boolean => JSON.stringify(work).length > maxChars;
|
|
397
|
+
/** One drop per slot per round, until it fits or nothing is left to drop. */
|
|
398
|
+
const roundRobin = (
|
|
399
|
+
dropOne: (slot: TrimmedUserContextSlot) => boolean,
|
|
400
|
+
): number => {
|
|
401
|
+
let dropped = 0;
|
|
402
|
+
let progressed = true;
|
|
403
|
+
while (progressed && over()) {
|
|
404
|
+
progressed = false;
|
|
405
|
+
for (const label of USER_CONTEXT_SLOTS) {
|
|
406
|
+
const slot = work[label];
|
|
407
|
+
if (!slot || !dropOne(slot)) continue;
|
|
408
|
+
dropped += 1;
|
|
409
|
+
progressed = true;
|
|
410
|
+
if (!over()) return dropped;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return dropped;
|
|
414
|
+
};
|
|
415
|
+
const droppedEpisodes = roundRobin((slot) => {
|
|
416
|
+
if (!slot.episodes?.length) return false;
|
|
417
|
+
slot.episodes.pop();
|
|
418
|
+
if (slot.episodes.length === 0) delete slot.episodes;
|
|
419
|
+
return true;
|
|
420
|
+
});
|
|
421
|
+
const droppedSummaries = roundRobin((slot) => {
|
|
422
|
+
const entities = slot.entities ?? [];
|
|
423
|
+
for (let i = entities.length - 1; i >= 0; i -= 1) {
|
|
424
|
+
const entity = entities[i];
|
|
425
|
+
if (entity?.summary === undefined) continue;
|
|
426
|
+
const { summary: _dropped, ...rest } = entity;
|
|
427
|
+
entities[i] = rest;
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
});
|
|
432
|
+
const out: TrimmedUserContext = {};
|
|
433
|
+
for (const label of USER_CONTEXT_SLOTS) {
|
|
434
|
+
const slot = work[label];
|
|
435
|
+
if (slot && hasContent(slot)) out[label] = slot;
|
|
436
|
+
}
|
|
437
|
+
return { context: out, droppedEpisodes, droppedSummaries };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function asResponseLike(
|
|
441
|
+
value: Record<string, unknown>,
|
|
442
|
+
): SearchEnhancedResponseLike {
|
|
443
|
+
const arrayOrUndefined = (v: unknown): readonly unknown[] | undefined =>
|
|
444
|
+
Array.isArray(v) ? v : undefined;
|
|
445
|
+
const totals = readTotals(value.total_results);
|
|
446
|
+
return {
|
|
447
|
+
...(totals ? { total_results: totals } : {}),
|
|
448
|
+
facts: arrayOrUndefined(value.facts),
|
|
449
|
+
entities: arrayOrUndefined(value.entities),
|
|
450
|
+
episodes: arrayOrUndefined(value.episodes),
|
|
451
|
+
communities: arrayOrUndefined(value.communities),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Evaluate `fn` for each slot, in query order. */
|
|
456
|
+
function perSlot<T>(
|
|
457
|
+
fn: (label: UserContextSlot, index: number) => T,
|
|
458
|
+
): Record<UserContextSlot, T> {
|
|
459
|
+
return {
|
|
460
|
+
identity: fn('identity', 0),
|
|
461
|
+
work: fn('work', 1),
|
|
462
|
+
goals: fn('goals', 2),
|
|
463
|
+
interests: fn('interests', 3),
|
|
464
|
+
relationships: fn('relationships', 4),
|
|
465
|
+
recent: fn('recent', 5),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ─── Fetcher ──────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
export interface UserContextFetcherDeps {
|
|
472
|
+
/** `MEMORY_ENGINE_URL`; undefined → every call is `skipped`, no request. */
|
|
473
|
+
memoryEngineUrl: string | undefined;
|
|
474
|
+
/** `ORACLE_DID` — the knowledge space the queries search (`oracle_dids`). */
|
|
475
|
+
oracleDid: string;
|
|
476
|
+
/** The user's UCAN surface, bound to the user's DID by the caller. */
|
|
477
|
+
ucan: {
|
|
478
|
+
hasSigningKey(): boolean;
|
|
479
|
+
resolveServiceDid(serviceUrl: string): Promise<string | null>;
|
|
480
|
+
mintInvocation(
|
|
481
|
+
target: { did: string; capability: string },
|
|
482
|
+
opts?: { can?: string },
|
|
483
|
+
): Promise<string>;
|
|
484
|
+
};
|
|
485
|
+
logger: Logger;
|
|
486
|
+
fetchImpl?: typeof fetch;
|
|
487
|
+
/** Clock for cache ages and timings (default `Date.now`). */
|
|
488
|
+
now?: () => number;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface UserContextFetcherOptions {
|
|
492
|
+
/** How long `fetch` waits for the gather (default 3 s; 0 = never wait). */
|
|
493
|
+
blockingCapMs?: number;
|
|
494
|
+
/** Positive cache lifetime per room (default 5 min). */
|
|
495
|
+
cacheTtlMs?: number;
|
|
496
|
+
/**
|
|
497
|
+
* Cache lifetime of an answer with nothing to render (default 30 s, never
|
|
498
|
+
* longer than `cacheTtlMs`).
|
|
499
|
+
*/
|
|
500
|
+
emptyCacheTtlMs?: number;
|
|
501
|
+
/** Negative cache lifetime per room after a transport failure (default 60 s). */
|
|
502
|
+
failureTtlMs?: number;
|
|
503
|
+
/** Hard timeout of the batch request itself (default 30 s). */
|
|
504
|
+
requestTimeoutMs?: number;
|
|
505
|
+
/** Rooms kept in the cache, least recently used evicted first (default 64). */
|
|
506
|
+
maxCachedRooms?: number;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const USER_CONTEXT_BLOCKING_CAP_MS_DEFAULT = 3_000;
|
|
510
|
+
export const USER_CONTEXT_CACHE_TTL_MS_DEFAULT = 300_000;
|
|
511
|
+
const DEFAULT_EMPTY_CACHE_TTL_MS = 30_000;
|
|
512
|
+
const DEFAULT_FAILURE_TTL_MS = 60_000;
|
|
513
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
514
|
+
const DEFAULT_MAX_CACHED_ROOMS = 64;
|
|
515
|
+
|
|
516
|
+
export type UserContextSkipReason =
|
|
517
|
+
| 'no-engine-url'
|
|
518
|
+
| 'no-signing-key'
|
|
519
|
+
| 'no-invocation'
|
|
520
|
+
| 'recently-failed'
|
|
521
|
+
| 'no-room';
|
|
522
|
+
|
|
523
|
+
export type UserContextFetchOutcome =
|
|
524
|
+
| { kind: 'cached'; context: UserContextData; ageMs: number }
|
|
525
|
+
| {
|
|
526
|
+
kind: 'fresh';
|
|
527
|
+
context: UserContextData;
|
|
528
|
+
ms: number;
|
|
529
|
+
/** Per slot `f12/e8/p3/c1` (rendered counts) or `missing`. */
|
|
530
|
+
slots: Record<UserContextSlot, string>;
|
|
531
|
+
}
|
|
532
|
+
/** The gather outlived the cap; it keeps running and warms the cache. */
|
|
533
|
+
| { kind: 'timeout'; capMs: number }
|
|
534
|
+
/** Transport failure or every slot failed; negative-cached for `failureTtlMs`. */
|
|
535
|
+
| { kind: 'failed'; reason: string }
|
|
536
|
+
| { kind: 'skipped'; reason: UserContextSkipReason };
|
|
537
|
+
|
|
538
|
+
export interface UserContextFetchParams {
|
|
539
|
+
roomId: string;
|
|
540
|
+
userDid: string;
|
|
541
|
+
sessionId: string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export interface UserContextStatus {
|
|
545
|
+
cached: boolean;
|
|
546
|
+
ageMs?: number;
|
|
547
|
+
inFlight: boolean;
|
|
548
|
+
lastOutcome?: UserContextFetchOutcome;
|
|
549
|
+
lastAt?: number;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** What a gather settles with (it never rejects). */
|
|
553
|
+
type GatherResult = Extract<
|
|
554
|
+
UserContextFetchOutcome,
|
|
555
|
+
{ kind: 'fresh' } | { kind: 'failed' } | { kind: 'skipped' }
|
|
556
|
+
>;
|
|
557
|
+
|
|
558
|
+
interface InFlightGather {
|
|
559
|
+
promise: Promise<GatherResult>;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function describe(err: unknown): string {
|
|
563
|
+
return err instanceof Error ? err.message : String(err);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Insert as most recent; evict the least recent entries beyond `max`. */
|
|
567
|
+
function lruSet<V>(map: Map<string, V>, key: string, value: V, max: number) {
|
|
568
|
+
map.delete(key);
|
|
569
|
+
map.set(key, value);
|
|
570
|
+
while (map.size > max) {
|
|
571
|
+
const oldest = map.keys().next();
|
|
572
|
+
if (oldest.done) break;
|
|
573
|
+
map.delete(oldest.value);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export class UserContextFetcher {
|
|
578
|
+
private readonly cache = new Map<
|
|
579
|
+
string,
|
|
580
|
+
{ context: UserContextData; at: number; ttlMs: number }
|
|
581
|
+
>();
|
|
582
|
+
|
|
583
|
+
/** Room → time the negative-cache entry expires. */
|
|
584
|
+
private readonly failures = new Map<string, number>();
|
|
585
|
+
|
|
586
|
+
private readonly inFlight = new Map<string, InFlightGather>();
|
|
587
|
+
|
|
588
|
+
private readonly lastOutcomes = new Map<
|
|
589
|
+
string,
|
|
590
|
+
{ outcome: UserContextFetchOutcome; at: number }
|
|
591
|
+
>();
|
|
592
|
+
|
|
593
|
+
private readonly fetchImpl: typeof fetch;
|
|
594
|
+
|
|
595
|
+
private readonly now: () => number;
|
|
596
|
+
|
|
597
|
+
private readonly blockingCapMs: number;
|
|
598
|
+
|
|
599
|
+
private readonly cacheTtlMs: number;
|
|
600
|
+
|
|
601
|
+
private readonly emptyCacheTtlMs: number;
|
|
602
|
+
|
|
603
|
+
private readonly failureTtlMs: number;
|
|
604
|
+
|
|
605
|
+
private readonly requestTimeoutMs: number;
|
|
606
|
+
|
|
607
|
+
private readonly maxCachedRooms: number;
|
|
608
|
+
|
|
609
|
+
constructor(
|
|
610
|
+
private readonly deps: UserContextFetcherDeps,
|
|
611
|
+
options: UserContextFetcherOptions = {},
|
|
612
|
+
) {
|
|
613
|
+
this.fetchImpl = deps.fetchImpl ?? fetch.bind(globalThis);
|
|
614
|
+
this.now = deps.now ?? Date.now;
|
|
615
|
+
this.blockingCapMs =
|
|
616
|
+
options.blockingCapMs ?? USER_CONTEXT_BLOCKING_CAP_MS_DEFAULT;
|
|
617
|
+
this.cacheTtlMs = options.cacheTtlMs ?? USER_CONTEXT_CACHE_TTL_MS_DEFAULT;
|
|
618
|
+
this.emptyCacheTtlMs = Math.min(
|
|
619
|
+
options.emptyCacheTtlMs ?? DEFAULT_EMPTY_CACHE_TTL_MS,
|
|
620
|
+
this.cacheTtlMs,
|
|
621
|
+
);
|
|
622
|
+
this.failureTtlMs = options.failureTtlMs ?? DEFAULT_FAILURE_TTL_MS;
|
|
623
|
+
this.requestTimeoutMs =
|
|
624
|
+
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
625
|
+
this.maxCachedRooms = Math.max(
|
|
626
|
+
1,
|
|
627
|
+
options.maxCachedRooms ?? DEFAULT_MAX_CACHED_ROOMS,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Blocking path for a first turn: resolves within `blockingCapMs` and
|
|
633
|
+
* never throws.
|
|
634
|
+
*/
|
|
635
|
+
async fetch(
|
|
636
|
+
params: UserContextFetchParams,
|
|
637
|
+
): Promise<UserContextFetchOutcome> {
|
|
638
|
+
const { roomId, sessionId } = params;
|
|
639
|
+
const { logger } = this.deps;
|
|
640
|
+
|
|
641
|
+
const cached = this.readCache(roomId);
|
|
642
|
+
if (cached) {
|
|
643
|
+
logger.log(
|
|
644
|
+
`[user-context] cache hit room=${roomId} age=${cached.ageMs}ms keys=${Object.keys(cached.context).length}`,
|
|
645
|
+
);
|
|
646
|
+
return this.record(roomId, cached);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let gather: Promise<GatherResult>;
|
|
650
|
+
try {
|
|
651
|
+
const started = this.startOrJoin(params);
|
|
652
|
+
if (!('promise' in started)) return this.record(roomId, started);
|
|
653
|
+
gather = started.promise;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return this.record(roomId, { kind: 'failed', reason: describe(err) });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const capMs = this.blockingCapMs;
|
|
659
|
+
if (capMs <= 0) {
|
|
660
|
+
logger.log(
|
|
661
|
+
`[user-context] not waiting (cap 0) for room=${roomId} session=${sessionId}; gathering in the background`,
|
|
662
|
+
);
|
|
663
|
+
return this.record(roomId, { kind: 'timeout', capMs: 0 });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
667
|
+
const cap = new Promise<'timeout'>((resolve) => {
|
|
668
|
+
timer = setTimeout(() => resolve('timeout'), capMs);
|
|
669
|
+
});
|
|
670
|
+
const winner = await Promise.race([gather, cap]);
|
|
671
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
672
|
+
|
|
673
|
+
if (winner === 'timeout') {
|
|
674
|
+
logger.warn(
|
|
675
|
+
`[user-context] exceeded ${capMs}ms cap for room=${roomId}; continuing in the background`,
|
|
676
|
+
);
|
|
677
|
+
return this.record(roomId, { kind: 'timeout', capMs });
|
|
678
|
+
}
|
|
679
|
+
return winner;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Non-blocking refresh for later turns: starts (or joins) the gather
|
|
684
|
+
* unless the cache is fresh or the room recently failed. Never rejects;
|
|
685
|
+
* the returned promise settles when the gather does, so the caller can
|
|
686
|
+
* hand it to `ctx.waitUntil`.
|
|
687
|
+
*/
|
|
688
|
+
refresh(params: UserContextFetchParams): Promise<void> {
|
|
689
|
+
try {
|
|
690
|
+
if (this.readCache(params.roomId)) return Promise.resolve();
|
|
691
|
+
const started = this.startOrJoin(params);
|
|
692
|
+
if (!('promise' in started)) return Promise.resolve();
|
|
693
|
+
return started.promise.then(
|
|
694
|
+
() => undefined,
|
|
695
|
+
() => undefined,
|
|
696
|
+
);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
this.deps.logger.warn(`[user-context] refresh failed: ${describe(err)}`);
|
|
699
|
+
return Promise.resolve();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Cache / in-flight state of one room, for `/debug/context` and tests. */
|
|
704
|
+
status(roomId: string): UserContextStatus {
|
|
705
|
+
const cached = this.readCache(roomId, false);
|
|
706
|
+
const last = this.lastOutcomes.get(roomId);
|
|
707
|
+
return {
|
|
708
|
+
cached: Boolean(cached),
|
|
709
|
+
...(cached ? { ageMs: cached.ageMs } : {}),
|
|
710
|
+
inFlight: this.inFlight.has(roomId),
|
|
711
|
+
...(last ? { lastOutcome: last.outcome, lastAt: last.at } : {}),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Forget a room's cached answer and failure marker. A gather already in
|
|
717
|
+
* flight is detached: it still completes but no longer writes the cache,
|
|
718
|
+
* and the next call starts a new one.
|
|
719
|
+
*/
|
|
720
|
+
invalidate(roomId: string): void {
|
|
721
|
+
this.cache.delete(roomId);
|
|
722
|
+
this.failures.delete(roomId);
|
|
723
|
+
this.inFlight.delete(roomId);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private readCache(
|
|
727
|
+
roomId: string,
|
|
728
|
+
touch = true,
|
|
729
|
+
): { kind: 'cached'; context: UserContextData; ageMs: number } | undefined {
|
|
730
|
+
const entry = this.cache.get(roomId);
|
|
731
|
+
if (!entry) return undefined;
|
|
732
|
+
const ageMs = this.now() - entry.at;
|
|
733
|
+
if (ageMs >= entry.ttlMs) {
|
|
734
|
+
this.cache.delete(roomId);
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
if (touch) lruSet(this.cache, roomId, entry, this.maxCachedRooms);
|
|
738
|
+
return { kind: 'cached', context: entry.context, ageMs };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private recentlyFailed(roomId: string): boolean {
|
|
742
|
+
const until = this.failures.get(roomId);
|
|
743
|
+
if (until === undefined) return false;
|
|
744
|
+
if (this.now() < until) return true;
|
|
745
|
+
this.failures.delete(roomId);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Join the room's gather, or start one when the prerequisites hold.
|
|
751
|
+
* Synchronous up to registering the gather, so a `refresh` called right
|
|
752
|
+
* after `fetch` joins instead of duplicating the request.
|
|
753
|
+
*/
|
|
754
|
+
private startOrJoin(
|
|
755
|
+
params: UserContextFetchParams,
|
|
756
|
+
): InFlightGather | Extract<UserContextFetchOutcome, { kind: 'skipped' }> {
|
|
757
|
+
const { roomId } = params;
|
|
758
|
+
const { logger } = this.deps;
|
|
759
|
+
const engineUrl = this.deps.memoryEngineUrl?.replace(/\/+$/, '');
|
|
760
|
+
if (!engineUrl) {
|
|
761
|
+
logger.debug?.(
|
|
762
|
+
`[user-context] skipped: MEMORY_ENGINE_URL not configured`,
|
|
763
|
+
);
|
|
764
|
+
return { kind: 'skipped', reason: 'no-engine-url' };
|
|
765
|
+
}
|
|
766
|
+
if (!roomId) {
|
|
767
|
+
logger.warn(`[user-context] skipped: no oracle room for this turn`);
|
|
768
|
+
return { kind: 'skipped', reason: 'no-room' };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const existing = this.inFlight.get(roomId);
|
|
772
|
+
if (existing) return existing;
|
|
773
|
+
|
|
774
|
+
if (this.recentlyFailed(roomId)) {
|
|
775
|
+
logger.log(
|
|
776
|
+
`[user-context] skipped: recent failure for room=${roomId} (retry within ${Math.round(this.failureTtlMs / 1000)}s)`,
|
|
777
|
+
);
|
|
778
|
+
return { kind: 'skipped', reason: 'recently-failed' };
|
|
779
|
+
}
|
|
780
|
+
if (!this.deps.ucan.hasSigningKey()) {
|
|
781
|
+
logger.warn(
|
|
782
|
+
`[user-context] skipped: no UCAN signing key; no memory engine invocation for room=${roomId}`,
|
|
783
|
+
);
|
|
784
|
+
return { kind: 'skipped', reason: 'no-signing-key' };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const entry: InFlightGather = {
|
|
788
|
+
promise: this.gather(engineUrl, roomId).then(
|
|
789
|
+
(result) => this.settle(roomId, entry, result),
|
|
790
|
+
(err: unknown) =>
|
|
791
|
+
this.settle(roomId, entry, {
|
|
792
|
+
kind: 'failed',
|
|
793
|
+
reason: describe(err),
|
|
794
|
+
}),
|
|
795
|
+
),
|
|
796
|
+
};
|
|
797
|
+
this.inFlight.set(roomId, entry);
|
|
798
|
+
return entry;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Book a finished gather: cache, failure marker, last outcome. */
|
|
802
|
+
private settle(
|
|
803
|
+
roomId: string,
|
|
804
|
+
entry: InFlightGather,
|
|
805
|
+
result: GatherResult,
|
|
806
|
+
): GatherResult {
|
|
807
|
+
// An invalidated (detached) gather must not repopulate the room.
|
|
808
|
+
if (this.inFlight.get(roomId) !== entry) return result;
|
|
809
|
+
this.inFlight.delete(roomId);
|
|
810
|
+
if (result.kind === 'fresh') {
|
|
811
|
+
this.failures.delete(roomId);
|
|
812
|
+
const empty = Object.keys(result.context).length === 0;
|
|
813
|
+
lruSet(
|
|
814
|
+
this.cache,
|
|
815
|
+
roomId,
|
|
816
|
+
{
|
|
817
|
+
context: result.context,
|
|
818
|
+
at: this.now(),
|
|
819
|
+
ttlMs: empty ? this.emptyCacheTtlMs : this.cacheTtlMs,
|
|
820
|
+
},
|
|
821
|
+
this.maxCachedRooms,
|
|
822
|
+
);
|
|
823
|
+
} else if (result.kind === 'failed') {
|
|
824
|
+
lruSet(
|
|
825
|
+
this.failures,
|
|
826
|
+
roomId,
|
|
827
|
+
this.now() + this.failureTtlMs,
|
|
828
|
+
this.maxCachedRooms,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
this.record(roomId, result);
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private record<T extends UserContextFetchOutcome>(
|
|
836
|
+
roomId: string,
|
|
837
|
+
outcome: T,
|
|
838
|
+
): T {
|
|
839
|
+
lruSet(
|
|
840
|
+
this.lastOutcomes,
|
|
841
|
+
roomId,
|
|
842
|
+
{ outcome, at: this.now() },
|
|
843
|
+
this.maxCachedRooms,
|
|
844
|
+
);
|
|
845
|
+
return outcome;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Mint the invocation, run the batch, map the slots. Never rejects. */
|
|
849
|
+
private async gather(
|
|
850
|
+
engineUrl: string,
|
|
851
|
+
roomId: string,
|
|
852
|
+
): Promise<GatherResult> {
|
|
853
|
+
const { logger, ucan } = this.deps;
|
|
854
|
+
const start = this.now();
|
|
855
|
+
|
|
856
|
+
let invocation: string | null = null;
|
|
857
|
+
try {
|
|
858
|
+
const memoryDid = await ucan.resolveServiceDid(engineUrl);
|
|
859
|
+
if (memoryDid) {
|
|
860
|
+
invocation = await ucan.mintInvocation(
|
|
861
|
+
{ did: memoryDid, capability: 'ixo:memory' },
|
|
862
|
+
// Claim the ability the user's delegation actually grants. A `'*'`
|
|
863
|
+
// claim is satisfiable only by a `'*'` grant.
|
|
864
|
+
{ can: 'memory/*' },
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
} catch (err) {
|
|
868
|
+
logger.warn(
|
|
869
|
+
`[user-context] could not mint a memory engine invocation: ${describe(err)}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
if (!invocation) {
|
|
873
|
+
logger.warn(
|
|
874
|
+
`[user-context] skipped: no memory engine invocation for room=${roomId}`,
|
|
875
|
+
);
|
|
876
|
+
return { kind: 'skipped', reason: 'no-invocation' };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let body: unknown;
|
|
880
|
+
try {
|
|
881
|
+
const res = await this.fetchImpl(`${engineUrl}/search-enhanced-batch`, {
|
|
882
|
+
method: 'POST',
|
|
883
|
+
headers: {
|
|
884
|
+
Authorization: `Bearer ${invocation}`,
|
|
885
|
+
'X-Auth-Type': 'ucan',
|
|
886
|
+
'x-room-id': roomId,
|
|
887
|
+
'Content-Type': 'application/json',
|
|
888
|
+
},
|
|
889
|
+
body: JSON.stringify({
|
|
890
|
+
queries: buildUserContextQueries(
|
|
891
|
+
this.deps.oracleDid,
|
|
892
|
+
new Date(this.now()),
|
|
893
|
+
),
|
|
894
|
+
}),
|
|
895
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
896
|
+
});
|
|
897
|
+
if (!res.ok) {
|
|
898
|
+
// The body is never read: an engine error page can echo the user's
|
|
899
|
+
// queries, and the reason reaches logs and `/debug/context`.
|
|
900
|
+
await res.body?.cancel().catch(() => undefined);
|
|
901
|
+
return this.failed(roomId, `memory engine answered ${res.status}`);
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
body = await res.json();
|
|
905
|
+
} catch {
|
|
906
|
+
// A JSON parser's message quotes the body; keep it out of the reason.
|
|
907
|
+
return this.failed(roomId, 'malformed response (invalid JSON)');
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
return this.failed(roomId, describe(err));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const results = isRecord(body) ? body.results : undefined;
|
|
914
|
+
if (!Array.isArray(results)) {
|
|
915
|
+
return this.failed(roomId, 'malformed response (no results array)');
|
|
916
|
+
}
|
|
917
|
+
if (results.length !== USER_CONTEXT_SLOTS.length) {
|
|
918
|
+
logger.warn(
|
|
919
|
+
`[user-context] batch length mismatch for room=${roomId}: expected ${USER_CONTEXT_SLOTS.length}, got ${results.length}`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const trimmed: TrimmedUserContext = {};
|
|
924
|
+
const answered = perSlot((label, index): boolean => {
|
|
925
|
+
const raw: unknown = results[index];
|
|
926
|
+
if (!isRecord(raw)) return false;
|
|
927
|
+
if (isRecord(raw.error)) {
|
|
928
|
+
logger.warn(
|
|
929
|
+
`[user-context] slot ${label} failed (${String(raw.error.status_code)})`,
|
|
930
|
+
);
|
|
931
|
+
// The detail can echo the query; only a debug logger sees it.
|
|
932
|
+
const detail = optionalText(raw.error.detail);
|
|
933
|
+
if (detail) {
|
|
934
|
+
logger.debug?.(
|
|
935
|
+
`[user-context] slot ${label} failure detail: ${detail.slice(0, 200)}`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const slot = trimUserContextSlot(asResponseLike(raw));
|
|
941
|
+
if (hasContent(slot)) trimmed[label] = slot;
|
|
942
|
+
return true;
|
|
943
|
+
});
|
|
944
|
+
if (USER_CONTEXT_SLOTS.every((label) => !answered[label])) {
|
|
945
|
+
return this.failed(roomId, 'every slot failed');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const fitted = fitUserContextBudget(trimmed);
|
|
949
|
+
if (fitted.droppedEpisodes > 0 || fitted.droppedSummaries > 0) {
|
|
950
|
+
logger.log(
|
|
951
|
+
`[user-context] context over ${USER_CONTEXT_MAX_JSON_CHARS} chars for room=${roomId}; dropped ${fitted.droppedEpisodes} episodes and ${fitted.droppedSummaries} entity summaries`,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
const context: UserContextData = fitted.context;
|
|
955
|
+
const slots = perSlot((label): string => {
|
|
956
|
+
if (!answered[label]) return 'missing';
|
|
957
|
+
const slot = fitted.context[label];
|
|
958
|
+
return slot ? slotSummary(slot) : 'f0/e0/p0/c0';
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const ms = this.now() - start;
|
|
962
|
+
logger.log(
|
|
963
|
+
`[user-context] fetched in ${ms} ms for room=${roomId} (${USER_CONTEXT_SLOTS.map((l) => `${l}=${slots[l]}`).join(', ')})`,
|
|
964
|
+
);
|
|
965
|
+
return { kind: 'fresh', context, ms, slots };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private failed(
|
|
969
|
+
roomId: string,
|
|
970
|
+
reason: string,
|
|
971
|
+
): Extract<GatherResult, { kind: 'failed' }> {
|
|
972
|
+
this.deps.logger.warn(
|
|
973
|
+
`[user-context] failed for room=${roomId}: ${reason} (not retried for ${Math.round(this.failureTtlMs / 1000)}s)`,
|
|
974
|
+
);
|
|
975
|
+
return { kind: 'failed', reason };
|
|
976
|
+
}
|
|
977
|
+
}
|