@ixo/common 1.1.38 → 1.1.39

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.
@@ -1,8 +1,11 @@
1
1
  import { Logger } from '@ixo/logger';
2
- import type {
3
- SearchEnhancedRequest,
4
- SearchEnhancedResponse,
5
- UserContextData,
2
+ import {
3
+ isBatchErrorSlot,
4
+ type SearchEnhancedBatchRequest,
5
+ type SearchEnhancedBatchResponse,
6
+ type SearchEnhancedRequest,
7
+ type SearchEnhancedResponse,
8
+ type UserContextData,
6
9
  } from './types.js';
7
10
 
8
11
  interface MemoryEngineAuthHeaders {
@@ -15,7 +18,9 @@ interface MemoryEngineAuthHeaders {
15
18
  }
16
19
 
17
20
  export class MemoryEngineService {
18
- private readonly QUERY_TIMEOUT_MS = 2500; // 2.5 seconds per query
21
+ // Batch covers 6 queries running in parallel server-side. Bound by the
22
+ // slowest query, not 6× — but we leave headroom for cold caches.
23
+ private readonly BATCH_TIMEOUT_MS = 15000;
19
24
 
20
25
  constructor(private readonly memoryEngineUrl: string) {}
21
26
 
@@ -44,22 +49,6 @@ export class MemoryEngineService {
44
49
  };
45
50
  }
46
51
 
47
- /**
48
- * Wraps a promise with a timeout, returning fallback value if timeout is exceeded
49
- */
50
- private async withTimeout<T>(
51
- promise: Promise<T>,
52
- timeoutMs: number,
53
- fallback: T,
54
- ): Promise<T> {
55
- return Promise.race([
56
- promise,
57
- new Promise<T>((resolve) =>
58
- setTimeout(() => resolve(fallback), timeoutMs),
59
- ),
60
- ]);
61
- }
62
-
63
52
  /**
64
53
  * Gather user context from Memory Engine by executing 6 parallel queries
65
54
  */
@@ -87,100 +76,156 @@ export class MemoryEngineService {
87
76
  `[MemoryEngineService] Gathering user context for oracle: ${oracleDid}, room: ${roomId}`,
88
77
  );
89
78
 
90
- try {
91
- // Execute all 6 queries in parallel with timeouts using Promise.allSettled
92
- const authHeaders: MemoryEngineAuthHeaders = {
93
- oracleToken,
94
- userToken,
95
- oracleHomeServer,
96
- userHomeServer,
97
- ucanInvocation,
98
- };
79
+ const authHeaders: MemoryEngineAuthHeaders = {
80
+ oracleToken,
81
+ userToken,
82
+ oracleHomeServer,
83
+ userHomeServer,
84
+ ucanInvocation,
85
+ };
99
86
 
100
- const results = await Promise.allSettled([
101
- this.withTimeout(
102
- this.queryIdentity(oracleDid, roomId, authHeaders),
103
- this.QUERY_TIMEOUT_MS,
104
- undefined,
105
- ),
106
- this.withTimeout(
107
- this.queryWork(oracleDid, roomId, authHeaders),
108
- this.QUERY_TIMEOUT_MS,
109
- undefined,
110
- ),
111
- this.withTimeout(
112
- this.queryGoals(oracleDid, roomId, authHeaders),
113
- this.QUERY_TIMEOUT_MS,
114
- undefined,
115
- ),
116
- this.withTimeout(
117
- this.queryInterests(oracleDid, roomId, authHeaders),
118
- this.QUERY_TIMEOUT_MS,
119
- undefined,
120
- ),
121
- this.withTimeout(
122
- this.queryRelationships(oracleDid, roomId, authHeaders),
123
- this.QUERY_TIMEOUT_MS,
124
- undefined,
125
- ),
126
- this.withTimeout(
127
- this.queryRecent(oracleDid, roomId, authHeaders),
128
- this.QUERY_TIMEOUT_MS,
129
- undefined,
130
- ),
131
- ]);
132
-
133
- // Extract results from Promise.allSettled outcomes
134
- const identity =
135
- results[0].status === 'fulfilled' ? results[0].value : undefined;
136
- const work =
137
- results[1].status === 'fulfilled' ? results[1].value : undefined;
138
- const goals =
139
- results[2].status === 'fulfilled' ? results[2].value : undefined;
140
- const interests =
141
- results[3].status === 'fulfilled' ? results[3].value : undefined;
142
- const relationships =
143
- results[4].status === 'fulfilled' ? results[4].value : undefined;
144
- const recent =
145
- results[5].status === 'fulfilled' ? results[5].value : undefined;
146
-
147
- // Log any failures
148
- results.forEach((result, index) => {
149
- if (result.status === 'rejected') {
87
+ // The 6 queries that make up userContext. Order matters: it determines
88
+ // how we map batch result slots back to UserContextData fields.
89
+ const labels = [
90
+ 'identity',
91
+ 'work',
92
+ 'goals',
93
+ 'interests',
94
+ 'relationships',
95
+ 'recent',
96
+ ] as const;
97
+ const requests: SearchEnhancedRequest[] = [
98
+ this.buildIdentityRequest(oracleDid),
99
+ this.buildWorkRequest(oracleDid),
100
+ this.buildGoalsRequest(oracleDid),
101
+ this.buildInterestsRequest(oracleDid),
102
+ this.buildRelationshipsRequest(oracleDid),
103
+ this.buildRecentRequest(oracleDid),
104
+ ];
105
+
106
+ const gatherStart = Date.now();
107
+ const batch = await this.executeBatch(requests, roomId, authHeaders);
108
+ const gatherElapsed = Date.now() - gatherStart;
109
+
110
+ if (!batch) {
111
+ Logger.error(
112
+ `[MemoryEngineService] gatherUserContext failed after ${gatherElapsed}ms — returning empty context`,
113
+ );
114
+ return {};
115
+ }
116
+
117
+ // Map each slot back to the labelled field. Error slots become undefined.
118
+ const fields: (SearchEnhancedResponse | undefined)[] = batch.results.map(
119
+ (slot, index) => {
120
+ const label = labels[index];
121
+ if (isBatchErrorSlot(slot)) {
150
122
  Logger.warn(
151
- `[MemoryEngineService] Query ${index} failed:`,
152
- result.reason,
123
+ `[MemoryEngineService] Batch slot "${label}" failed (${slot.error.status_code}): ${slot.error.detail}`,
153
124
  );
125
+ return undefined;
154
126
  }
155
- });
127
+ return slot;
128
+ },
129
+ );
156
130
 
157
- return {
158
- identity,
159
- work,
160
- goals,
161
- interests,
162
- relationships,
163
- recent,
164
- };
165
- } catch (error) {
166
- Logger.error(
167
- '[MemoryEngineService] Failed to gather user context:',
168
- error,
131
+ if (batch.results.length !== labels.length) {
132
+ Logger.warn(
133
+ `[MemoryEngineService] Batch length mismatch: expected ${labels.length}, got ${batch.results.length}`,
169
134
  );
170
- // Return empty context on error
171
- return {};
172
135
  }
136
+
137
+ const summary = labels.map((label, index) => {
138
+ const value = fields[index];
139
+ if (value === undefined) return `${label}=missing`;
140
+ return `${label}=ok(f${value.total_results.facts}/e${value.total_results.entities})`;
141
+ });
142
+ Logger.info(
143
+ `[MemoryEngineService] gatherUserContext completed in ${gatherElapsed}ms (batch) — ${summary.join(', ')}`,
144
+ );
145
+
146
+ return {
147
+ identity: fields[0],
148
+ work: fields[1],
149
+ goals: fields[2],
150
+ interests: fields[3],
151
+ relationships: fields[4],
152
+ recent: fields[5],
153
+ };
173
154
  }
174
155
 
175
156
  /**
176
- * Query 1: User Identity & Attributes
157
+ * POST /search-enhanced-batch single round-trip for N parallel queries.
158
+ * Returns undefined on transport/HTTP failure; a partially-failed batch
159
+ * still resolves with per-slot error markers (handled by caller via
160
+ * `isBatchErrorSlot`).
177
161
  */
178
- private async queryIdentity(
179
- oracleDid: string,
162
+ private async executeBatch(
163
+ queries: SearchEnhancedRequest[],
180
164
  roomId: string,
181
165
  auth: MemoryEngineAuthHeaders,
182
- ): Promise<SearchEnhancedResponse | undefined> {
183
- const request: SearchEnhancedRequest = {
166
+ ): Promise<SearchEnhancedBatchResponse | undefined> {
167
+ if (!roomId) {
168
+ Logger.warn(
169
+ `[MemoryEngineService] No room id provided, skipping batch search`,
170
+ );
171
+ return undefined;
172
+ }
173
+ if (!auth.ucanInvocation && (!auth.oracleToken || !auth.userToken)) {
174
+ Logger.warn(
175
+ `[MemoryEngineService] Missing auth (no UCAN and no Matrix tokens), skipping batch search`,
176
+ );
177
+ return undefined;
178
+ }
179
+
180
+ const body: SearchEnhancedBatchRequest = { queries };
181
+
182
+ const controller = new AbortController();
183
+ const timer = setTimeout(
184
+ () => controller.abort(),
185
+ this.BATCH_TIMEOUT_MS,
186
+ );
187
+
188
+ try {
189
+ const response = await fetch(
190
+ `${this.memoryEngineUrl}/search-enhanced-batch`,
191
+ {
192
+ method: 'POST',
193
+ headers: this.buildHeaders(auth, roomId),
194
+ body: JSON.stringify(body),
195
+ signal: controller.signal,
196
+ },
197
+ );
198
+
199
+ if (!response.ok) {
200
+ const errorText = await response.text();
201
+ Logger.warn(
202
+ `[MemoryEngineService] Batch search failed (${response.status}): ${errorText}`,
203
+ );
204
+ return undefined;
205
+ }
206
+
207
+ return (await response.json()) as SearchEnhancedBatchResponse;
208
+ } catch (error) {
209
+ if ((error as Error).name === 'AbortError') {
210
+ Logger.warn(
211
+ `[MemoryEngineService] Batch search aborted after ${this.BATCH_TIMEOUT_MS}ms`,
212
+ );
213
+ } else {
214
+ Logger.error(`[MemoryEngineService] Batch search threw:`, error);
215
+ }
216
+ return undefined;
217
+ } finally {
218
+ clearTimeout(timer);
219
+ }
220
+ }
221
+
222
+ // ── Per-query request builders ────────────────────────────────────────────
223
+ // These produce SearchEnhancedRequest payloads consumed by gatherUserContext
224
+ // via the batch endpoint. Order matches the labels array in
225
+ // gatherUserContext — keep the two in sync.
226
+
227
+ private buildIdentityRequest(oracleDid: string): SearchEnhancedRequest {
228
+ return {
184
229
  oracle_dids: [oracleDid],
185
230
  query:
186
231
  'username and nickname and age user identity traits values personality characteristics communication style beliefs preferences',
@@ -204,19 +249,10 @@ export class MemoryEngineService {
204
249
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
205
250
  },
206
251
  };
207
-
208
- return this.executeQuery(request, roomId, auth);
209
252
  }
210
253
 
211
- /**
212
- * Query 2: Work Context
213
- */
214
- private async queryWork(
215
- oracleDid: string,
216
- roomId: string,
217
- auth: MemoryEngineAuthHeaders,
218
- ): Promise<SearchEnhancedResponse | undefined> {
219
- const request: SearchEnhancedRequest = {
254
+ private buildWorkRequest(oracleDid: string): SearchEnhancedRequest {
255
+ return {
220
256
  oracle_dids: [oracleDid],
221
257
  query:
222
258
  'work job career projects skills organization employment role responsibilities expertise',
@@ -247,19 +283,10 @@ export class MemoryEngineService {
247
283
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
248
284
  },
249
285
  };
250
-
251
- return this.executeQuery(request, roomId, auth);
252
286
  }
253
287
 
254
- /**
255
- * Query 3: Goals & Habits
256
- */
257
- private async queryGoals(
258
- oracleDid: string,
259
- roomId: string,
260
- auth: MemoryEngineAuthHeaders,
261
- ): Promise<SearchEnhancedResponse | undefined> {
262
- const request: SearchEnhancedRequest = {
288
+ private buildGoalsRequest(oracleDid: string): SearchEnhancedRequest {
289
+ return {
263
290
  oracle_dids: [oracleDid],
264
291
  query:
265
292
  'goals aspirations objectives milestones habits routines patterns achievements progress',
@@ -282,19 +309,10 @@ export class MemoryEngineService {
282
309
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
283
310
  },
284
311
  };
285
-
286
- return this.executeQuery(request, roomId, auth);
287
312
  }
288
313
 
289
- /**
290
- * Query 4: Interests & Preferences
291
- */
292
- private async queryInterests(
293
- oracleDid: string,
294
- roomId: string,
295
- auth: MemoryEngineAuthHeaders,
296
- ): Promise<SearchEnhancedResponse | undefined> {
297
- const request: SearchEnhancedRequest = {
314
+ private buildInterestsRequest(oracleDid: string): SearchEnhancedRequest {
315
+ return {
298
316
  oracle_dids: [oracleDid],
299
317
  query:
300
318
  'interests hobbies passions preferences likes dislikes expertise topics content',
@@ -324,19 +342,10 @@ export class MemoryEngineService {
324
342
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
325
343
  },
326
344
  };
327
-
328
- return this.executeQuery(request, roomId, auth);
329
345
  }
330
346
 
331
- /**
332
- * Query 5: Relationships
333
- */
334
- private async queryRelationships(
335
- oracleDid: string,
336
- roomId: string,
337
- auth: MemoryEngineAuthHeaders,
338
- ): Promise<SearchEnhancedResponse | undefined> {
339
- const request: SearchEnhancedRequest = {
347
+ private buildRelationshipsRequest(oracleDid: string): SearchEnhancedRequest {
348
+ return {
340
349
  oracle_dids: [oracleDid],
341
350
  query:
342
351
  'relationships people connections social network colleagues friends family contacts',
@@ -359,24 +368,17 @@ export class MemoryEngineService {
359
368
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
360
369
  },
361
370
  };
362
-
363
- return this.executeQuery(request, roomId, auth);
364
371
  }
365
372
 
366
- /**
367
- * Query 6: Recent Context
368
- */
369
- private async queryRecent(
370
- oracleDid: string,
371
- roomId: string,
372
- auth: MemoryEngineAuthHeaders,
373
- ): Promise<SearchEnhancedResponse | undefined> {
374
- // Calculate date 90 days ago for recent context
373
+ private buildRecentRequest(oracleDid: string): SearchEnhancedRequest {
374
+ // Server-side `recent_memory` strategy auto-injects a created_at >= now-90d
375
+ // filter. We still pass it explicitly as defense-in-depth — the server's
376
+ // merge logic respects an existing lower bound and won't double-apply.
375
377
  const ninetyDaysAgo = new Date();
376
378
  ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
377
379
  const dateString = ninetyDaysAgo.toISOString();
378
380
 
379
- const request: SearchEnhancedRequest = {
381
+ return {
380
382
  oracle_dids: [oracleDid],
381
383
  query:
382
384
  'recent conversations messages discussions activities updates interactions',
@@ -390,8 +392,6 @@ export class MemoryEngineService {
390
392
  created_at: [[{ date: dateString, comparison_operator: '>=' }]],
391
393
  },
392
394
  };
393
-
394
- return this.executeQuery(request, roomId, auth);
395
395
  }
396
396
 
397
397
  /**
@@ -475,53 +475,4 @@ export class MemoryEngineService {
475
475
  }
476
476
  }
477
477
 
478
- /**
479
- * Execute a search query against the Memory Engine API
480
- */
481
- private async executeQuery(
482
- request: SearchEnhancedRequest,
483
- roomId: string,
484
- auth: MemoryEngineAuthHeaders,
485
- ): Promise<SearchEnhancedResponse | undefined> {
486
- if (!roomId) {
487
- Logger.warn(
488
- `[MemoryEngineService] No room id provided, skipping query "${request.query}"`,
489
- );
490
- return undefined;
491
- }
492
- if (!auth.ucanInvocation && (!auth.oracleToken || !auth.userToken)) {
493
- Logger.warn(
494
- `[MemoryEngineService] Missing auth (no UCAN and no Matrix tokens), skipping query "${request.query}"`,
495
- );
496
- return undefined;
497
- }
498
-
499
- try {
500
- const response = await fetch(`${this.memoryEngineUrl}/search-enhanced`, {
501
- method: 'POST',
502
- headers: this.buildHeaders(auth, roomId),
503
- body: JSON.stringify(request),
504
- });
505
-
506
- if (!response.ok) {
507
- const errorText = await response.text();
508
- Logger.warn(
509
- `[MemoryEngineService] Memory Engine query failed (${response.status}): ${errorText}`,
510
- );
511
- return undefined;
512
- }
513
-
514
- const result = (await response.json()) as SearchEnhancedResponse;
515
- Logger.info(
516
- `[MemoryEngineService] Query "${request.query}" returned ${result.total_results.facts} facts, ${result.total_results.entities} entities`,
517
- );
518
- return result;
519
- } catch (error) {
520
- Logger.error(
521
- `[MemoryEngineService] Failed to execute query "${request.query}":`,
522
- error,
523
- );
524
- return undefined;
525
- }
526
- }
527
478
  }
@@ -206,3 +206,37 @@ export interface UserContextData {
206
206
  relationships?: SearchEnhancedResponse;
207
207
  recent?: SearchEnhancedResponse;
208
208
  }
209
+
210
+ // Batch search types — backend endpoint POST /search-enhanced-batch
211
+ export interface SearchEnhancedBatchRequest {
212
+ queries: SearchEnhancedRequest[];
213
+ }
214
+
215
+ // A failed slot in the batch response. The server returns this in place of
216
+ // SearchEnhancedResponse when a single query fails — the rest of the batch
217
+ // still completes.
218
+ export interface SearchEnhancedBatchErrorSlot {
219
+ error: {
220
+ status_code: number;
221
+ detail: string;
222
+ };
223
+ query: string;
224
+ strategy_used: string;
225
+ }
226
+
227
+ export type SearchEnhancedBatchSlot =
228
+ | SearchEnhancedResponse
229
+ | SearchEnhancedBatchErrorSlot;
230
+
231
+ export interface SearchEnhancedBatchResponse {
232
+ results: SearchEnhancedBatchSlot[];
233
+ }
234
+
235
+ export function isBatchErrorSlot(
236
+ slot: SearchEnhancedBatchSlot,
237
+ ): slot is SearchEnhancedBatchErrorSlot {
238
+ return (
239
+ typeof (slot as SearchEnhancedBatchErrorSlot).error === 'object' &&
240
+ (slot as SearchEnhancedBatchErrorSlot).error !== null
241
+ );
242
+ }