@ixo/common 1.1.38 → 1.1.40

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,153 @@ 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(() => controller.abort(), this.BATCH_TIMEOUT_MS);
184
+
185
+ try {
186
+ const response = await fetch(
187
+ `${this.memoryEngineUrl}/search-enhanced-batch`,
188
+ {
189
+ method: 'POST',
190
+ headers: this.buildHeaders(auth, roomId),
191
+ body: JSON.stringify(body),
192
+ signal: controller.signal,
193
+ },
194
+ );
195
+
196
+ if (!response.ok) {
197
+ const errorText = await response.text();
198
+ Logger.warn(
199
+ `[MemoryEngineService] Batch search failed (${response.status}): ${errorText}`,
200
+ );
201
+ return undefined;
202
+ }
203
+
204
+ return (await response.json()) as SearchEnhancedBatchResponse;
205
+ } catch (error) {
206
+ if ((error as Error).name === 'AbortError') {
207
+ Logger.warn(
208
+ `[MemoryEngineService] Batch search aborted after ${this.BATCH_TIMEOUT_MS}ms`,
209
+ );
210
+ } else {
211
+ Logger.error(`[MemoryEngineService] Batch search threw:`, error);
212
+ }
213
+ return undefined;
214
+ } finally {
215
+ clearTimeout(timer);
216
+ }
217
+ }
218
+
219
+ // ── Per-query request builders ────────────────────────────────────────────
220
+ // These produce SearchEnhancedRequest payloads consumed by gatherUserContext
221
+ // via the batch endpoint. Order matches the labels array in
222
+ // gatherUserContext — keep the two in sync.
223
+
224
+ private buildIdentityRequest(oracleDid: string): SearchEnhancedRequest {
225
+ return {
184
226
  oracle_dids: [oracleDid],
185
227
  query:
186
228
  'username and nickname and age user identity traits values personality characteristics communication style beliefs preferences',
@@ -204,19 +246,10 @@ export class MemoryEngineService {
204
246
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
205
247
  },
206
248
  };
207
-
208
- return this.executeQuery(request, roomId, auth);
209
249
  }
210
250
 
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 = {
251
+ private buildWorkRequest(oracleDid: string): SearchEnhancedRequest {
252
+ return {
220
253
  oracle_dids: [oracleDid],
221
254
  query:
222
255
  'work job career projects skills organization employment role responsibilities expertise',
@@ -247,19 +280,10 @@ export class MemoryEngineService {
247
280
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
248
281
  },
249
282
  };
250
-
251
- return this.executeQuery(request, roomId, auth);
252
283
  }
253
284
 
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 = {
285
+ private buildGoalsRequest(oracleDid: string): SearchEnhancedRequest {
286
+ return {
263
287
  oracle_dids: [oracleDid],
264
288
  query:
265
289
  'goals aspirations objectives milestones habits routines patterns achievements progress',
@@ -282,19 +306,10 @@ export class MemoryEngineService {
282
306
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
283
307
  },
284
308
  };
285
-
286
- return this.executeQuery(request, roomId, auth);
287
309
  }
288
310
 
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 = {
311
+ private buildInterestsRequest(oracleDid: string): SearchEnhancedRequest {
312
+ return {
298
313
  oracle_dids: [oracleDid],
299
314
  query:
300
315
  'interests hobbies passions preferences likes dislikes expertise topics content',
@@ -324,19 +339,10 @@ export class MemoryEngineService {
324
339
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
325
340
  },
326
341
  };
327
-
328
- return this.executeQuery(request, roomId, auth);
329
342
  }
330
343
 
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 = {
344
+ private buildRelationshipsRequest(oracleDid: string): SearchEnhancedRequest {
345
+ return {
340
346
  oracle_dids: [oracleDid],
341
347
  query:
342
348
  'relationships people connections social network colleagues friends family contacts',
@@ -359,24 +365,17 @@ export class MemoryEngineService {
359
365
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
360
366
  },
361
367
  };
362
-
363
- return this.executeQuery(request, roomId, auth);
364
368
  }
365
369
 
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
370
+ private buildRecentRequest(oracleDid: string): SearchEnhancedRequest {
371
+ // Server-side `recent_memory` strategy auto-injects a created_at >= now-90d
372
+ // filter. We still pass it explicitly as defense-in-depth — the server's
373
+ // merge logic respects an existing lower bound and won't double-apply.
375
374
  const ninetyDaysAgo = new Date();
376
375
  ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
377
376
  const dateString = ninetyDaysAgo.toISOString();
378
377
 
379
- const request: SearchEnhancedRequest = {
378
+ return {
380
379
  oracle_dids: [oracleDid],
381
380
  query:
382
381
  'recent conversations messages discussions activities updates interactions',
@@ -390,8 +389,6 @@ export class MemoryEngineService {
390
389
  created_at: [[{ date: dateString, comparison_operator: '>=' }]],
391
390
  },
392
391
  };
393
-
394
- return this.executeQuery(request, roomId, auth);
395
392
  }
396
393
 
397
394
  /**
@@ -474,54 +471,4 @@ export class MemoryEngineService {
474
471
  return { success: false };
475
472
  }
476
473
  }
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
474
  }
@@ -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
+ }