@ixo/common 1.1.41 → 1.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -4
  2. package/dist/ai/semantic-router-factory/create-semantic-router.js.map +1 -1
  3. package/dist/ai/utils/load-file.d.ts.map +1 -1
  4. package/dist/ai/utils/load-file.js +42 -3
  5. package/dist/ai/utils/load-file.js.map +1 -1
  6. package/dist/services/env/env.service.js.map +1 -1
  7. package/dist/services/memory-engine/memory-engine.service.d.ts +5 -4
  8. package/dist/services/memory-engine/memory-engine.service.d.ts.map +1 -1
  9. package/dist/services/memory-engine/memory-engine.service.js +75 -98
  10. package/dist/services/memory-engine/memory-engine.service.js.map +1 -1
  11. package/dist/services/session-manager/dto.d.ts +0 -5
  12. package/dist/services/session-manager/dto.d.ts.map +1 -1
  13. package/dist/services/session-manager/dto.js +0 -30
  14. package/dist/services/session-manager/dto.js.map +1 -1
  15. package/dist/services/session-manager/session-manager.service.d.ts +1 -3
  16. package/dist/services/session-manager/session-manager.service.d.ts.map +1 -1
  17. package/dist/services/session-manager/session-manager.service.js +11 -26
  18. package/dist/services/session-manager/session-manager.service.js.map +1 -1
  19. package/package.json +21 -17
  20. package/src/ai/semantic-router-factory/create-semantic-router.ts +1 -1
  21. package/src/ai/utils/load-file.ts +52 -3
  22. package/src/services/env/env.service.ts +3 -3
  23. package/src/services/memory-engine/memory-engine.service.ts +146 -114
  24. package/src/services/session-manager/dto.ts +0 -20
  25. package/src/services/session-manager/session-manager.service.ts +25 -30
  26. package/tsconfig.tsbuildinfo +1 -1
@@ -18,9 +18,14 @@ interface MemoryEngineAuthHeaders {
18
18
  }
19
19
 
20
20
  export class MemoryEngineService {
21
- // Batch covers 6 queries running in parallel server-side. Bound by the
22
- // slowest query, not but we leave headroom for cold caches.
23
- private readonly BATCH_TIMEOUT_MS = 15000;
21
+ // Soft deadline: how long the caller waits before falling back to empty
22
+ // context. The underlying fetch keeps running in the background up to
23
+ // BATCH_HARD_TIMEOUT_MS so the server-side query can still finish (and warm
24
+ // caches) even after the user's turn has moved on.
25
+ private readonly BATCH_TIMEOUT_MS = 30000;
26
+ // Hard cap on the underlying fetch — prevents leaking sockets if the server
27
+ // never responds. Anything slower than this is treated as a real failure.
28
+ private readonly BATCH_HARD_TIMEOUT_MS = 60000;
24
29
 
25
30
  constructor(private readonly memoryEngineUrl: string) {}
26
31
 
@@ -105,6 +110,7 @@ export class MemoryEngineService {
105
110
 
106
111
  const gatherStart = Date.now();
107
112
  const batch = await this.executeBatch(requests, roomId, authHeaders);
113
+
108
114
  const gatherElapsed = Date.now() - gatherStart;
109
115
 
110
116
  if (!batch) {
@@ -179,41 +185,91 @@ export class MemoryEngineService {
179
185
 
180
186
  const body: SearchEnhancedBatchRequest = { queries };
181
187
 
182
- const controller = new AbortController();
183
- const timer = setTimeout(() => controller.abort(), this.BATCH_TIMEOUT_MS);
188
+ // Hard cap aborts the fetch so a hung server can't leak sockets. The soft
189
+ // deadline below is what the caller actually awaits.
190
+ const hardController = new AbortController();
191
+ const hardTimer = setTimeout(
192
+ () => hardController.abort(),
193
+ this.BATCH_HARD_TIMEOUT_MS,
194
+ );
184
195
 
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
- },
196
+ const start = Date.now();
197
+
198
+ const requestPromise: Promise<SearchEnhancedBatchResponse | undefined> =
199
+ (async () => {
200
+ try {
201
+ const response = await fetch(
202
+ `${this.memoryEngineUrl}/search-enhanced-batch`,
203
+ {
204
+ method: 'POST',
205
+ headers: this.buildHeaders(auth, roomId),
206
+ body: JSON.stringify(body),
207
+ signal: hardController.signal,
208
+ },
209
+ );
210
+
211
+ if (!response.ok) {
212
+ const errorText = await response.text();
213
+ Logger.warn(
214
+ `[MemoryEngineService] Batch search failed (${response.status}): ${errorText}`,
215
+ );
216
+ return undefined;
217
+ }
218
+
219
+ return (await response.json()) as SearchEnhancedBatchResponse;
220
+ } catch (error) {
221
+ if ((error as Error).name === 'AbortError') {
222
+ Logger.warn(
223
+ `[MemoryEngineService] Batch search hit hard cap after ${this.BATCH_HARD_TIMEOUT_MS}ms — aborted`,
224
+ );
225
+ } else {
226
+ Logger.error(`[MemoryEngineService] Batch search threw:`, error);
227
+ }
228
+ return undefined;
229
+ } finally {
230
+ clearTimeout(hardTimer);
231
+ }
232
+ })();
233
+
234
+ type Outcome =
235
+ | { kind: 'result'; value: SearchEnhancedBatchResponse | undefined }
236
+ | { kind: 'timeout' };
237
+
238
+ let softTimer: ReturnType<typeof setTimeout> | undefined;
239
+ const softDeadline = new Promise<Outcome>((resolve) => {
240
+ softTimer = setTimeout(
241
+ () => resolve({ kind: 'timeout' }),
242
+ this.BATCH_TIMEOUT_MS,
194
243
  );
244
+ });
195
245
 
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
- }
246
+ const outcome = await Promise.race<Outcome>([
247
+ requestPromise.then((value): Outcome => ({ kind: 'result', value })),
248
+ softDeadline,
249
+ ]);
203
250
 
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
- }
251
+ if (softTimer) clearTimeout(softTimer);
252
+
253
+ if (outcome.kind === 'timeout') {
254
+ Logger.warn(
255
+ `[MemoryEngineService] Batch search exceeded ${this.BATCH_TIMEOUT_MS}ms soft deadline — returning empty context, request continues in background (hard cap ${this.BATCH_HARD_TIMEOUT_MS}ms)`,
256
+ );
257
+ void requestPromise.then((result) => {
258
+ const elapsed = Date.now() - start;
259
+ if (result) {
260
+ Logger.info(
261
+ `[MemoryEngineService] Background batch search finished after ${elapsed}ms (caller gave up at ${this.BATCH_TIMEOUT_MS}ms)`,
262
+ );
263
+ } else {
264
+ Logger.warn(
265
+ `[MemoryEngineService] Background batch search returned no data after ${elapsed}ms`,
266
+ );
267
+ }
268
+ });
213
269
  return undefined;
214
- } finally {
215
- clearTimeout(timer);
216
270
  }
271
+
272
+ return outcome.value;
217
273
  }
218
274
 
219
275
  // ── Per-query request builders ────────────────────────────────────────────
@@ -221,14 +277,35 @@ export class MemoryEngineService {
221
277
  // via the batch endpoint. Order matches the labels array in
222
278
  // gatherUserContext — keep the two in sync.
223
279
 
280
+ // Design notes for the query builders:
281
+ //
282
+ // 1. Queries are natural-language sentences, not keyword bags. Embedding
283
+ // search ranks by semantic similarity — a sentence that names both the
284
+ // abstract concept ("what the user works on") and concrete examples
285
+ // ("tools, projects, people they collaborate with") aligns far better
286
+ // with how real memories are phrased than a thesaurus of synonyms.
287
+ //
288
+ // 2. `edge_types` is deliberately not used. It is AND-ed with node_labels
289
+ // before scoring, and the extractor's edge choices vary too much to
290
+ // predict (a work mention may produce WorksWith, Mentions, Discusses,
291
+ // RelatesTo). One filter miss drops the whole fact. Semantic scoring
292
+ // handles discrimination better than guessing edge types.
293
+ //
294
+ // 3. `node_labels` is kept where it's discriminating but broadened to cover
295
+ // the EntityType variants the extractor actually emits (Event/Experience/
296
+ // Procedure/Task all show up for work episodes, for example).
297
+ //
298
+ // 4. `invalid_at IS NULL` filters out facts that have been superseded —
299
+ // kept everywhere except `recent`, which already constrains on created_at.
300
+
224
301
  private buildIdentityRequest(oracleDid: string): SearchEnhancedRequest {
225
302
  return {
226
303
  oracle_dids: [oracleDid],
227
304
  query:
228
- 'username and nickname and age user identity traits values personality characteristics communication style beliefs preferences',
305
+ '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.',
229
306
  strategy: 'balanced',
230
- max_facts: 10,
231
- max_entities: 6,
307
+ max_facts: 12,
308
+ max_entities: 8,
232
309
  max_episodes: 3,
233
310
  max_communities: 2,
234
311
  knowledge_level: 'both',
@@ -239,9 +316,9 @@ export class MemoryEngineService {
239
316
  'Value',
240
317
  'Identity',
241
318
  'Attribute',
242
- 'Emotion',
243
319
  'Belief',
244
320
  'CommunicationStyle',
321
+ 'Language',
245
322
  ],
246
323
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
247
324
  },
@@ -252,31 +329,14 @@ export class MemoryEngineService {
252
329
  return {
253
330
  oracle_dids: [oracleDid],
254
331
  query:
255
- 'work job career projects skills organization employment role responsibilities expertise',
332
+ '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.',
256
333
  strategy: 'balanced',
257
- max_facts: 10,
258
- max_entities: 6,
259
- max_episodes: 3,
334
+ max_facts: 15,
335
+ max_entities: 10,
336
+ max_episodes: 5,
260
337
  max_communities: 2,
261
338
  knowledge_level: 'both',
262
339
  search_filters: {
263
- node_labels: [
264
- 'Job',
265
- 'Project',
266
- 'Organization',
267
- 'Skill',
268
- 'Tool',
269
- 'Expertise',
270
- 'Task',
271
- ],
272
- edge_types: [
273
- 'EmployedAt',
274
- 'WorksOn',
275
- 'Manages',
276
- 'Uses',
277
- 'ExpertiseIn',
278
- 'WorksWith',
279
- ],
280
340
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
281
341
  },
282
342
  };
@@ -286,23 +346,14 @@ export class MemoryEngineService {
286
346
  return {
287
347
  oracle_dids: [oracleDid],
288
348
  query:
289
- 'goals aspirations objectives milestones habits routines patterns achievements progress',
349
+ '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.',
290
350
  strategy: 'balanced',
291
- max_facts: 8,
292
- max_entities: 4,
351
+ max_facts: 10,
352
+ max_entities: 6,
293
353
  max_episodes: 3,
294
354
  max_communities: 1,
295
355
  knowledge_level: 'both',
296
356
  search_filters: {
297
- node_labels: [
298
- 'Goal',
299
- 'Milestone',
300
- 'Habit',
301
- 'Routine',
302
- 'Pattern',
303
- 'LearningGoal',
304
- ],
305
- edge_types: ['Pursuing', 'Achieved', 'Practices', 'Motivates'],
306
357
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
307
358
  },
308
359
  };
@@ -312,30 +363,14 @@ export class MemoryEngineService {
312
363
  return {
313
364
  oracle_dids: [oracleDid],
314
365
  query:
315
- 'interests hobbies passions preferences likes dislikes expertise topics content',
366
+ '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.',
316
367
  strategy: 'balanced',
317
- max_facts: 8,
318
- max_entities: 4,
368
+ max_facts: 10,
369
+ max_entities: 6,
319
370
  max_episodes: 3,
320
371
  max_communities: 1,
321
372
  knowledge_level: 'both',
322
373
  search_filters: {
323
- node_labels: [
324
- 'Interest',
325
- 'Hobby',
326
- 'Preference',
327
- 'Product',
328
- 'Content',
329
- 'Expertise',
330
- 'Resource',
331
- ],
332
- edge_types: [
333
- 'Prefers',
334
- 'Likes',
335
- 'Dislikes',
336
- 'InterestedIn',
337
- 'ExpertiseIn',
338
- ],
339
374
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
340
375
  },
341
376
  };
@@ -345,23 +380,14 @@ export class MemoryEngineService {
345
380
  return {
346
381
  oracle_dids: [oracleDid],
347
382
  query:
348
- 'relationships people connections social network colleagues friends family contacts',
383
+ '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.',
349
384
  strategy: 'balanced',
350
- max_facts: 6,
351
- max_entities: 6,
352
- max_episodes: 2,
385
+ max_facts: 8,
386
+ max_entities: 10,
387
+ max_episodes: 3,
353
388
  max_communities: 1,
354
389
  knowledge_level: 'both',
355
390
  search_filters: {
356
- node_labels: ['Person', 'Group'],
357
- edge_types: [
358
- 'Knows',
359
- 'WorksWith',
360
- 'MemberOf',
361
- 'Influences',
362
- 'Supports',
363
- 'RelatesTo',
364
- ],
365
391
  invalid_at: [[{ date: null, comparison_operator: 'IS NULL' }]],
366
392
  },
367
393
  };
@@ -371,6 +397,8 @@ export class MemoryEngineService {
371
397
  // Server-side `recent_memory` strategy auto-injects a created_at >= now-90d
372
398
  // filter. We still pass it explicitly as defense-in-depth — the server's
373
399
  // merge logic respects an existing lower bound and won't double-apply.
400
+ // No node_labels filter: recent activity can be of any type, and the
401
+ // created_at window is already doing the heavy filtering.
374
402
  const ninetyDaysAgo = new Date();
375
403
  ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
376
404
  const dateString = ninetyDaysAgo.toISOString();
@@ -378,11 +406,11 @@ export class MemoryEngineService {
378
406
  return {
379
407
  oracle_dids: [oracleDid],
380
408
  query:
381
- 'recent conversations messages discussions activities updates interactions',
409
+ '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.',
382
410
  strategy: 'recent_memory',
383
- max_facts: 8,
384
- max_entities: 4,
385
- max_episodes: 6,
411
+ max_facts: 10,
412
+ max_entities: 6,
413
+ max_episodes: 8,
386
414
  max_communities: 2,
387
415
  knowledge_level: 'both',
388
416
  search_filters: {
@@ -397,10 +425,10 @@ export class MemoryEngineService {
397
425
  async processConversationHistory({
398
426
  messages,
399
427
  roomId,
400
- oracleToken,
401
- userToken,
402
- oracleHomeServer,
403
- userHomeServer,
428
+ oracleToken = '',
429
+ userToken = '',
430
+ oracleHomeServer = '',
431
+ userHomeServer = '',
404
432
  ucanInvocation,
405
433
  }: {
406
434
  messages: Array<{
@@ -411,11 +439,15 @@ export class MemoryEngineService {
411
439
  source_description?: string;
412
440
  }>;
413
441
  roomId: string;
414
- oracleToken: string;
415
- userToken: string;
416
- oracleHomeServer: string;
417
- userHomeServer: string;
418
- /** When set, uses UCAN auth instead of Matrix tokens */
442
+ /** Deprecated — Matrix bearer auth. Use `ucanInvocation` instead. */
443
+ oracleToken?: string;
444
+ /** Deprecated — Matrix bearer auth. Use `ucanInvocation` instead. */
445
+ userToken?: string;
446
+ /** Deprecated Matrix bearer auth. Use `ucanInvocation` instead. */
447
+ oracleHomeServer?: string;
448
+ /** Deprecated — Matrix bearer auth. Use `ucanInvocation` instead. */
449
+ userHomeServer?: string;
450
+ /** Required under UCAN-only auth. */
419
451
  ucanInvocation?: string;
420
452
  }): Promise<{ success: boolean }> {
421
453
  if (!roomId) {
@@ -56,26 +56,6 @@ export class CreateChatSessionDto extends UserAuthDto {
56
56
  @IsOptional()
57
57
  slackThreadTs?: string;
58
58
 
59
- @IsString()
60
- @IsOptional()
61
- oracleToken?: string;
62
-
63
- @IsString()
64
- @IsOptional()
65
- userToken?: string;
66
-
67
- @IsString()
68
- @IsOptional()
69
- oracleHomeServer?: string;
70
-
71
- @IsString()
72
- @IsOptional()
73
- userHomeServer?: string;
74
-
75
- @IsString()
76
- @IsOptional()
77
- ucanInvocation?: string;
78
-
79
59
  /** Override the roomId stored on the session (e.g. task-specific room). */
80
60
  @IsString()
81
61
  @IsOptional()
@@ -8,7 +8,6 @@ import {
8
8
  getOpenRouterChatModel,
9
9
  getProviderConfig,
10
10
  } from '../../ai/index.js';
11
- import { type MemoryEngineService } from '../memory-engine/memory-engine.service.js';
12
11
  import { type UserContextData } from '../memory-engine/types.js';
13
12
  import {
14
13
  type ChatSession,
@@ -27,7 +26,6 @@ export class SessionManagerService {
27
26
  constructor(
28
27
  private readonly syncService: IDatabaseSyncService,
29
28
  public readonly matrixManger = MatrixManager.getInstance(),
30
- private readonly memoryEngineService?: MemoryEngineService,
31
29
  ) {}
32
30
 
33
31
  public getSessionsStateKey({
@@ -417,30 +415,11 @@ ___________________________________________________________
417
415
  isOracleAdmin: true,
418
416
  }));
419
417
 
420
- // Gather user context from Memory Engine
421
- let userContext: UserContextData | undefined;
422
- if (
423
- this.memoryEngineService &&
424
- (createSessionDto.ucanInvocation ||
425
- (createSessionDto.oracleToken && createSessionDto.userToken))
426
- ) {
427
- try {
428
- Logger.debug('Gathering user context from Memory Engine');
429
- userContext = await this.memoryEngineService.gatherUserContext({
430
- oracleDid: createSessionDto.oracleDid,
431
- roomId,
432
- oracleToken: createSessionDto.oracleToken ?? '',
433
- userToken: createSessionDto.userToken ?? '',
434
- oracleHomeServer: createSessionDto.oracleHomeServer ?? '',
435
- userHomeServer: createSessionDto.userHomeServer ?? '',
436
- ucanInvocation: createSessionDto.ucanInvocation,
437
- });
438
- } catch (error) {
439
- Logger.error('Failed to gather user context:', error);
440
- throw error;
441
- }
442
- }
443
-
418
+ // `userContext` is no longer fetched at session creation. It is
419
+ // populated per-message by the runtime's `UserContextFetcher` (cached
420
+ // for 3 minutes), which keeps the session-create path off the Memory
421
+ // Engine critical path AND keeps the prompt fresher than a snapshot
422
+ // taken once at session start.
444
423
  const session = await this.syncSessionSet({
445
424
  sessionId: eventId,
446
425
  oracleName: createSessionDto.oracleName,
@@ -449,7 +428,6 @@ ___________________________________________________________
449
428
  oracleDid: createSessionDto.oracleDid,
450
429
  messages: [],
451
430
  roomId,
452
- userContext,
453
431
  slackThreadTs: createSessionDto.slackThreadTs,
454
432
  });
455
433
 
@@ -460,8 +438,25 @@ ___________________________________________________________
460
438
  deleteSessionDto: DeleteChatSessionDto,
461
439
  ): Promise<void> {
462
440
  const db = await this.syncService.getUserDatabase(deleteSessionDto.did);
463
- db.prepare('DELETE FROM sessions WHERE session_id = ?').run(
464
- deleteSessionDto.sessionId,
465
- );
441
+ const { sessionId } = deleteSessionDto;
442
+
443
+ // The session id doubles as the LangGraph thread id, so clear the
444
+ // thread's checkpointer rows too (mirrors `SqliteSaver.deleteThread`).
445
+ // The checkpointer creates its tables on the first graph turn — a user
446
+ // who created a session but never sent a message has none yet, so only
447
+ // the checkpointer tables need an existence check.
448
+ const checkpointerTables = db
449
+ .prepare<
450
+ [],
451
+ { name: string }
452
+ >(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('checkpoints', 'writes', 'messages')`)
453
+ .all();
454
+
455
+ db.transaction(() => {
456
+ db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId);
457
+ for (const { name } of checkpointerTables) {
458
+ db.prepare(`DELETE FROM ${name} WHERE thread_id = ?`).run(sessionId);
459
+ }
460
+ })();
466
461
  }
467
462
  }