opcode-pg-memory 2.2.7 → 2.3.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.
Files changed (76) hide show
  1. package/dist/cli.js +21 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +30 -20981
  5. package/dist/index.js.map +1 -0
  6. package/dist/mcp-server.js +26 -6
  7. package/dist/mcp-server.js.map +1 -0
  8. package/dist/src/cache/semantic-cache.js +399 -0
  9. package/dist/src/cache/semantic-cache.js.map +1 -0
  10. package/dist/src/cli.js +404 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/config.d.ts +5 -0
  13. package/dist/src/config.d.ts.map +1 -1
  14. package/dist/src/config.js +89 -0
  15. package/dist/src/config.js.map +1 -0
  16. package/dist/src/db/init-db.js +545 -0
  17. package/dist/src/db/init-db.js.map +1 -0
  18. package/dist/src/hooks/message-part-updated.js +203 -0
  19. package/dist/src/hooks/message-part-updated.js.map +1 -0
  20. package/dist/src/hooks/message-updated.js +347 -0
  21. package/dist/src/hooks/message-updated.js.map +1 -0
  22. package/dist/src/hooks/session-compacting.js +179 -0
  23. package/dist/src/hooks/session-compacting.js.map +1 -0
  24. package/dist/src/hooks/session-completed.js +337 -0
  25. package/dist/src/hooks/session-completed.js.map +1 -0
  26. package/dist/src/hooks/session-created.js +206 -0
  27. package/dist/src/hooks/session-created.js.map +1 -0
  28. package/dist/src/hooks/tool-execute.js +267 -0
  29. package/dist/src/hooks/tool-execute.js.map +1 -0
  30. package/dist/src/index.d.ts +1 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +643 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/mcp/hindsight-reflect-omo.js +318 -0
  35. package/dist/src/mcp/hindsight-reflect-omo.js.map +1 -0
  36. package/dist/src/mcp/hindsight-reflect.js +838 -0
  37. package/dist/src/mcp/hindsight-reflect.js.map +1 -0
  38. package/dist/src/mcp/recall-memory-omo.js +263 -0
  39. package/dist/src/mcp/recall-memory-omo.js.map +1 -0
  40. package/dist/src/mcp/recall-memory.d.ts +6 -0
  41. package/dist/src/mcp/recall-memory.d.ts.map +1 -1
  42. package/dist/src/mcp/recall-memory.js +900 -0
  43. package/dist/src/mcp/recall-memory.js.map +1 -0
  44. package/dist/src/omo/adapter.js +583 -0
  45. package/dist/src/omo/adapter.js.map +1 -0
  46. package/dist/src/omo/types.js +44 -0
  47. package/dist/src/omo/types.js.map +1 -0
  48. package/dist/src/services/db-polling.d.ts +30 -0
  49. package/dist/src/services/db-polling.d.ts.map +1 -0
  50. package/dist/src/services/db-polling.js +97 -0
  51. package/dist/src/services/db-polling.js.map +1 -0
  52. package/dist/src/services/event-synchronizer.d.ts +15 -0
  53. package/dist/src/services/event-synchronizer.d.ts.map +1 -0
  54. package/dist/src/services/event-synchronizer.js +119 -0
  55. package/dist/src/services/event-synchronizer.js.map +1 -0
  56. package/dist/src/services/keyword.js +29 -0
  57. package/dist/src/services/keyword.js.map +1 -0
  58. package/dist/src/services/logger.js +42 -0
  59. package/dist/src/services/logger.js.map +1 -0
  60. package/dist/src/services/opencode-schema-adapter.d.ts +34 -0
  61. package/dist/src/services/opencode-schema-adapter.d.ts.map +1 -0
  62. package/dist/src/services/opencode-schema-adapter.js +96 -0
  63. package/dist/src/services/opencode-schema-adapter.js.map +1 -0
  64. package/dist/src/services/privacy.js +23 -0
  65. package/dist/src/services/privacy.js.map +1 -0
  66. package/dist/src/topic/segment-manager.js +447 -0
  67. package/dist/src/topic/segment-manager.js.map +1 -0
  68. package/dist/src/types.d.ts +20 -2
  69. package/dist/src/types.d.ts.map +1 -1
  70. package/dist/src/types.js +8 -0
  71. package/dist/src/types.js.map +1 -0
  72. package/dist/src/utils/embedding.js +180 -0
  73. package/dist/src/utils/embedding.js.map +1 -0
  74. package/dist/src/utils/token-budget.js +152 -0
  75. package/dist/src/utils/token-budget.js.map +1 -0
  76. package/package.json +6 -6
@@ -0,0 +1,900 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recallMemory = recallMemory;
4
+ const logger_1 = require("../services/logger");
5
+ const embedding_1 = require("../utils/embedding");
6
+ const logger = (0, logger_1.createLogger)('recall-memory');
7
+ // ============================================================
8
+ // Constants
9
+ // ============================================================
10
+ const DEFAULT_DECAY_CONFIG = {
11
+ enabled: true,
12
+ factor: 0.99,
13
+ maxAgeDays: 365,
14
+ };
15
+ const DEFAULT_CONFIG = {
16
+ weights: {
17
+ semantic: 0.5,
18
+ recency: 0.3,
19
+ importance: 0.2,
20
+ },
21
+ maxResults: 10,
22
+ rerankEnabled: true,
23
+ decay: { ...DEFAULT_DECAY_CONFIG },
24
+ };
25
+ /** Topic-fusion blend ratio: 70% query + 30% topic */
26
+ const TOPIC_FUSION_RATIO = 0.3;
27
+ /** Max results per strategy before merge */
28
+ const PER_STRATEGY_LIMIT = 20;
29
+ // ============================================================
30
+ // Main function: recallMemory
31
+ // ============================================================
32
+ /**
33
+ * Enhanced recall_memory MCP tool.
34
+ *
35
+ * Innovations:
36
+ * 1. Topic context fusion — blends query embedding with current topic embedding
37
+ * 2. Structured output — every result carries full context metadata
38
+ * 3. Agent-friendly — OmO agents pass caller_context for better retrieval
39
+ * 4. Enhanced filters — min_importance, tier, exclude_topic_segment_ids
40
+ *
41
+ * Backward compatible with simple { query: "..." } calls.
42
+ */
43
+ async function recallMemory(input, pool, config = {}) {
44
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
45
+ const startTime = Date.now();
46
+ logger.info(`recall_memory called: "${input.query.substring(0, 100)}..."`);
47
+ try {
48
+ // ── Step 0: Resolve session ID ──
49
+ const sessionId = await resolveSessionId(input, pool);
50
+ // ── Step 1: Generate query embedding ──
51
+ const embeddingService = (0, embedding_1.getEmbeddingService)();
52
+ if (!embeddingService) {
53
+ throw new Error('Embedding service is not available. Check EMBEDDING_PROVIDER and API keys.');
54
+ }
55
+ const queryEmbedding = await embeddingService.generateEmbedding(input.query);
56
+ // ── Step 2: Topic context fusion ──
57
+ let fusedEmbedding = queryEmbedding;
58
+ let contextUsed;
59
+ const effectiveSessionId = input.caller_context?.current_session_id || input.session_id;
60
+ if (effectiveSessionId) {
61
+ const fusionResult = await topicContextFusion(effectiveSessionId, queryEmbedding, embeddingService, pool);
62
+ if (fusionResult) {
63
+ fusedEmbedding = fusionResult.fusedEmbedding;
64
+ contextUsed = fusionResult.contextUsed;
65
+ logger.info(`Topic fusion applied (topic: ${fusionResult.contextUsed.topic_segment_id.substring(0, 8)}...)`);
66
+ }
67
+ }
68
+ // ── Step 3: Multi-strategy parallel retrieval ──
69
+ const strategies = input.retrieval_strategies || ['semantic', 'bm25', 'graph'];
70
+ const retrievalResults = await parallelRetrieve(input.query, fusedEmbedding, sessionId, strategies, pool, input.filters);
71
+ // ── Step 4: Merge & deduplicate ──
72
+ const mergedResults = mergeAndDeduplicate(retrievalResults);
73
+ // ── Step 5: Multi-dimensional scoring (with time-based decay) ──
74
+ const scoredResults = calculateMultiDimensionalScores(mergedResults, fusedEmbedding, mergedConfig.weights, { ...DEFAULT_DECAY_CONFIG, ...mergedConfig.decay });
75
+ // ── Step 6: Apply filters ──
76
+ let filteredResults = applyFilters(scoredResults, input.filters);
77
+ // ── Step 7: Cross-encoder rerank (backward compat) ──
78
+ if (input.rerank !== false && mergedConfig.rerankEnabled) {
79
+ filteredResults = await crossEncoderRerank(filteredResults, input.query);
80
+ }
81
+ // ── Step 8: Limit results ──
82
+ const maxResults = input.max_results || mergedConfig.maxResults;
83
+ filteredResults = filteredResults.slice(0, maxResults);
84
+ // ── Step 9: Enrich with context ──
85
+ const enrichedResults = await enrichWithContext(filteredResults, sessionId, pool);
86
+ // ── Step 10: Convert to structured MemoryResult ──
87
+ const results = enrichedResults.map((fact) => {
88
+ const id = fact.id || `fallback-${Date.now()}-${Math.random().toString(36).slice(2)}`;
89
+ return {
90
+ id,
91
+ type: mapType(fact.type),
92
+ data: buildDataPayload(fact),
93
+ relevance_score: fact.relevanceScore,
94
+ context: {
95
+ session_id: fact._sessionId || sessionId,
96
+ omo_task_id: fact._omoTaskId,
97
+ topic_segment_id: fact._topicSegmentId || 'unknown',
98
+ topic_summary: fact._topicSummary,
99
+ timestamp: fact._timestamp || new Date().toISOString(),
100
+ },
101
+ // backward-compat flat fields
102
+ content: fact.content,
103
+ metadata: {
104
+ ...fact.metadata,
105
+ id,
106
+ topic_segment_id: fact._topicSegmentId,
107
+ topic_summary: fact._topicSummary,
108
+ session_id: fact._sessionId || sessionId,
109
+ },
110
+ };
111
+ });
112
+ const retrievalTime = Date.now() - startTime;
113
+ logger.info(`recall_memory completed: ${results.length} results in ${retrievalTime}ms`);
114
+ return {
115
+ query: input.query,
116
+ context_used: contextUsed,
117
+ success: true,
118
+ results,
119
+ total_found: mergedResults.length,
120
+ retrieval_time_ms: retrievalTime,
121
+ strategies_used: strategies,
122
+ session_id: sessionId,
123
+ };
124
+ }
125
+ catch (error) {
126
+ logger.error('recall_memory error:', error);
127
+ const retrievalTime = Date.now() - startTime;
128
+ return {
129
+ query: input.query,
130
+ success: false,
131
+ results: [],
132
+ total_found: 0,
133
+ retrieval_time_ms: retrievalTime,
134
+ strategies_used: [],
135
+ session_id: '',
136
+ error: error.message || String(error),
137
+ };
138
+ }
139
+ }
140
+ // ============================================================
141
+ // Step 0: Session ID resolution
142
+ // ============================================================
143
+ async function resolveSessionId(input, pool) {
144
+ if (input.session_id) {
145
+ // Provided session_id — try session_map first, then fall back to sessions
146
+ const id = await resolveExternalSessionId(input.session_id, pool);
147
+ if (id)
148
+ return id;
149
+ // If not found in session_map, try legacy sessions table
150
+ const legacyResult = await pool.query('SELECT id FROM sessions WHERE external_id = $1', [input.session_id]);
151
+ if (legacyResult.rows.length > 0)
152
+ return legacyResult.rows[0].id;
153
+ throw new Error(`Session not found: ${input.session_id}`);
154
+ }
155
+ // Auto-detect: try session_map first, then sessions
156
+ try {
157
+ const recent = await pool.query('SELECT id, opencode_session_id FROM session_map ORDER BY last_active_at DESC LIMIT 1');
158
+ if (recent.rows.length > 0) {
159
+ logger.info(`Auto-detected session: ${recent.rows[0].opencode_session_id}`);
160
+ return recent.rows[0].id;
161
+ }
162
+ }
163
+ catch {
164
+ // session_map table doesn't exist yet
165
+ }
166
+ // Fall back to legacy sessions
167
+ const recentSession = await pool.query("SELECT id, external_id FROM sessions ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1");
168
+ if (recentSession.rows.length > 0) {
169
+ logger.info(`Auto-detected session (legacy): ${recentSession.rows[0].external_id}`);
170
+ return recentSession.rows[0].id;
171
+ }
172
+ throw new Error('No session found. Please start a conversation first or provide session_id explicitly.');
173
+ }
174
+ /**
175
+ * Resolve an external session ID to internal UUID via session_map or sessions table.
176
+ */
177
+ async function resolveExternalSessionId(externalId, pool) {
178
+ // Try session_map
179
+ try {
180
+ const result = await pool.query('SELECT id FROM session_map WHERE opencode_session_id = $1', [externalId]);
181
+ if (result.rows.length > 0)
182
+ return result.rows[0].id;
183
+ }
184
+ catch {
185
+ // table may not exist
186
+ }
187
+ // Try legacy sessions
188
+ const legacy = await pool.query('SELECT id FROM sessions WHERE external_id = $1', [externalId]);
189
+ if (legacy.rows.length > 0)
190
+ return legacy.rows[0].id;
191
+ return null;
192
+ }
193
+ // ============================================================
194
+ // Step 2: Topic context fusion
195
+ // ============================================================
196
+ async function topicContextFusion(externalSessionId, queryEmbedding, embeddingService, pool) {
197
+ try {
198
+ // Look up internal session ID
199
+ const sessionLookup = await pool.query(`SELECT id FROM session_map WHERE opencode_session_id = $1
200
+ UNION ALL
201
+ SELECT id FROM sessions WHERE external_id = $1
202
+ LIMIT 1`, [externalSessionId]);
203
+ if (sessionLookup.rows.length === 0)
204
+ return null;
205
+ const internalSessionId = sessionLookup.rows[0].id;
206
+ // Find most recent active topic segment for this session
207
+ const topicResult = await pool.query(`SELECT id, summary, embedding
208
+ FROM topic_segments
209
+ WHERE session_map_id = $1
210
+ AND closed_at IS NULL
211
+ AND embedding IS NOT NULL
212
+ ORDER BY created_at DESC
213
+ LIMIT 1`, [internalSessionId]);
214
+ if (topicResult.rows.length === 0) {
215
+ // Try finding any topic segment with embedding
216
+ const anyTopic = await pool.query(`SELECT id, summary, embedding
217
+ FROM topic_segments
218
+ WHERE session_map_id = $1
219
+ AND embedding IS NOT NULL
220
+ ORDER BY created_at DESC
221
+ LIMIT 1`, [internalSessionId]);
222
+ if (anyTopic.rows.length === 0)
223
+ return null;
224
+ const topicEmbedding = anyTopic.rows[0].embedding;
225
+ const fusedEmbedding = fuseEmbeddings(queryEmbedding, topicEmbedding, TOPIC_FUSION_RATIO);
226
+ return {
227
+ fusedEmbedding,
228
+ contextUsed: {
229
+ topic_segment_id: anyTopic.rows[0].id,
230
+ topic_summary: anyTopic.rows[0].summary || 'No summary',
231
+ },
232
+ };
233
+ }
234
+ const topicEmbedding = topicResult.rows[0].embedding;
235
+ const fusedEmbedding = fuseEmbeddings(queryEmbedding, topicEmbedding, TOPIC_FUSION_RATIO);
236
+ return {
237
+ fusedEmbedding,
238
+ contextUsed: {
239
+ topic_segment_id: topicResult.rows[0].id,
240
+ topic_summary: topicResult.rows[0].summary || 'No summary',
241
+ },
242
+ };
243
+ }
244
+ catch (err) {
245
+ // topic_segments table may not exist — skip fusion gracefully
246
+ logger.warn('Topic context fusion skipped:', err);
247
+ return null;
248
+ }
249
+ }
250
+ /**
251
+ * Fuse two embeddings: fused = normalize((1-ratio) * a + ratio * b)
252
+ */
253
+ function fuseEmbeddings(queryEmb, topicEmb, topicRatio) {
254
+ const dim = Math.min(queryEmb.length, topicEmb.length);
255
+ const result = new Array(dim);
256
+ const queryWeight = 1 - topicRatio;
257
+ for (let i = 0; i < dim; i++) {
258
+ result[i] = queryWeight * (queryEmb[i] || 0) + topicRatio * (topicEmb[i] || 0);
259
+ }
260
+ return normalizeVector(result);
261
+ }
262
+ /**
263
+ * L2-normalize a vector to unit length.
264
+ */
265
+ function normalizeVector(vec) {
266
+ const magnitude = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
267
+ if (magnitude === 0)
268
+ return vec.slice();
269
+ return vec.map((v) => v / magnitude);
270
+ }
271
+ // ============================================================
272
+ // Step 3: Multi-strategy parallel retrieval
273
+ // ============================================================
274
+ async function parallelRetrieve(query, queryEmbedding, sessionId, strategies, pool, filters) {
275
+ const results = new Map();
276
+ const filterSQL = buildFilterConditions(filters);
277
+ const promises = [];
278
+ if (strategies.includes('semantic')) {
279
+ promises.push((async () => {
280
+ try {
281
+ const r = await semanticSearch(queryEmbedding, sessionId, pool, filterSQL);
282
+ results.set('semantic', r);
283
+ }
284
+ catch (err) {
285
+ logger.warn('Semantic search failed:', err);
286
+ results.set('semantic', []);
287
+ }
288
+ })());
289
+ }
290
+ if (strategies.includes('bm25')) {
291
+ promises.push((async () => {
292
+ try {
293
+ const r = await bm25Search(query, sessionId, pool, filterSQL);
294
+ results.set('bm25', r);
295
+ }
296
+ catch (err) {
297
+ logger.warn('BM25 search failed:', err);
298
+ results.set('bm25', []);
299
+ }
300
+ })());
301
+ }
302
+ if (strategies.includes('graph')) {
303
+ promises.push((async () => {
304
+ try {
305
+ const r = await graphTraversal(query, embeddingServiceFallback(query), sessionId, pool, filterSQL);
306
+ results.set('graph', r);
307
+ }
308
+ catch (err) {
309
+ logger.warn('Graph traversal failed:', err);
310
+ results.set('graph', []);
311
+ }
312
+ })());
313
+ }
314
+ if (strategies.includes('keyword')) {
315
+ promises.push((async () => {
316
+ try {
317
+ const r = await keywordSearch(query, sessionId, pool, filterSQL);
318
+ results.set('keyword', r);
319
+ }
320
+ catch (err) {
321
+ logger.warn('Keyword search failed:', err);
322
+ results.set('keyword', []);
323
+ }
324
+ })());
325
+ }
326
+ await Promise.all(promises);
327
+ return results;
328
+ }
329
+ /** Fallback embedding for non-vector strategies (simple keyword affinity) */
330
+ function embeddingServiceFallback(_query) {
331
+ // Graph traversal uses keyword matching, not vector search
332
+ return [];
333
+ }
334
+ // ============================================================
335
+ // Filter builder
336
+ // ============================================================
337
+ function buildFilterConditions(filters) {
338
+ const conditions = [];
339
+ const params = [];
340
+ let idx = 1;
341
+ if (filters?.min_confidence !== undefined) {
342
+ conditions.push(`(e.confidence >= $${idx} OR o.importance IS NULL)`);
343
+ params.push(filters.min_confidence);
344
+ idx++;
345
+ }
346
+ // Note: min_importance, tier, entity_types, exclude_topic_segment_ids are applied
347
+ // in the post-retrieval applyFilters() step, not in SQL.
348
+ const sql = conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
349
+ return { sql, params };
350
+ }
351
+ // ============================================================
352
+ // Semantic search (vector)
353
+ // ============================================================
354
+ async function semanticSearch(queryEmbedding, sessionId, pool, filters) {
355
+ const facts = [];
356
+ const embeddingStr = formatVectorLiteral(queryEmbedding);
357
+ // ── 1. Entities (via session_map + topic_segments if available, fall back to sessions) ──
358
+ try {
359
+ const entityQuery = `
360
+ SELECT e.id, e.name, e.type, e.tier, e.weight, e.description,
361
+ 1 - (e.embedding <=> $1) as similarity,
362
+ e.first_seen_at as created_at, e.confidence,
363
+ e.session_id, e.session_map_id, e.topic_segment_id
364
+ FROM entities e
365
+ LEFT JOIN session_map sm ON e.session_map_id = sm.id
366
+ LEFT JOIN topic_segments ts ON e.topic_segment_id = ts.id
367
+ WHERE (e.session_map_id = $2 OR e.session_id = $2 OR e.tier = 'permanent')
368
+ AND e.embedding IS NOT NULL
369
+ ${filters.sql}
370
+ ORDER BY e.embedding <=> $1
371
+ LIMIT ${PER_STRATEGY_LIMIT}
372
+ `;
373
+ const entityResult = await pool.query(entityQuery, [
374
+ queryEmbedding,
375
+ sessionId,
376
+ ...filters.params.filter((_, i) => i < filters.params.length),
377
+ ]);
378
+ facts.push(...entityResult.rows.map((row) => ({
379
+ id: row.id,
380
+ type: 'entity',
381
+ content: `[${(row.type || '').toUpperCase()}] ${row.name}: ${row.description || ''}`,
382
+ relevanceScore: row.similarity ?? 0,
383
+ tokens: 0,
384
+ metadata: {
385
+ entityType: row.type,
386
+ weight: row.weight,
387
+ confidence: row.confidence,
388
+ createdAt: row.created_at,
389
+ tier: row.tier,
390
+ source: 'semantic',
391
+ },
392
+ _sessionId: row.session_map_id || row.session_id,
393
+ _topicSegmentId: row.topic_segment_id,
394
+ _timestamp: row.created_at,
395
+ })));
396
+ }
397
+ catch (err) {
398
+ logger.warn('Entity semantic search error:', err);
399
+ }
400
+ // ── 2. Observations ──
401
+ try {
402
+ const obsQuery = `
403
+ SELECT o.id, o.tool_name, o.tool_output_summary as content, o.embedding,
404
+ 1 - (o.embedding <=> $1) as similarity,
405
+ o.created_at, o.importance,
406
+ o.session_id, o.session_map_id, o.topic_segment_id
407
+ FROM observations o
408
+ LEFT JOIN session_map sm ON o.session_map_id = sm.id
409
+ LEFT JOIN topic_segments ts ON o.topic_segment_id = ts.id
410
+ WHERE (o.session_map_id = $2 OR o.session_id = $2)
411
+ AND o.embedding IS NOT NULL
412
+ ORDER BY o.embedding <=> $1
413
+ LIMIT ${PER_STRATEGY_LIMIT}
414
+ `;
415
+ const obsResult = await pool.query(obsQuery, [queryEmbedding, sessionId]);
416
+ facts.push(...obsResult.rows.map((row) => ({
417
+ id: row.id,
418
+ type: 'observation',
419
+ content: `[${row.tool_name || 'Observation'}] ${row.content || ''}`,
420
+ relevanceScore: row.similarity ?? 0,
421
+ tokens: 0,
422
+ metadata: {
423
+ toolName: row.tool_name,
424
+ importance: row.importance,
425
+ createdAt: row.created_at,
426
+ source: 'semantic',
427
+ },
428
+ _sessionId: row.session_map_id || row.session_id,
429
+ _topicSegmentId: row.topic_segment_id,
430
+ _timestamp: row.created_at,
431
+ })));
432
+ }
433
+ catch (err) {
434
+ logger.warn('Observation semantic search error:', err);
435
+ }
436
+ // ── 3. Reflections ──
437
+ try {
438
+ const refQuery = `
439
+ SELECT r.id, r.summary as content, r.pattern_type, r.embedding,
440
+ 1 - (r.embedding <=> $1) as similarity,
441
+ r.created_at, r.confidence,
442
+ r.session_id, r.session_map_id, r.topic_segment_id
443
+ FROM reflections r
444
+ LEFT JOIN session_map sm ON r.session_map_id = sm.id
445
+ LEFT JOIN topic_segments ts ON r.topic_segment_id = ts.id
446
+ WHERE (r.session_map_id = $2 OR r.session_id = $2)
447
+ AND r.embedding IS NOT NULL
448
+ ORDER BY r.embedding <=> $1
449
+ LIMIT ${PER_STRATEGY_LIMIT / 2}
450
+ `;
451
+ const refResult = await pool.query(refQuery, [queryEmbedding, sessionId]);
452
+ facts.push(...refResult.rows.map((row) => ({
453
+ id: row.id,
454
+ type: 'reflection',
455
+ content: `[Reflection${row.pattern_type ? ` - ${row.pattern_type}` : ''}] ${row.content || ''}`,
456
+ relevanceScore: row.similarity ?? 0,
457
+ tokens: 0,
458
+ metadata: {
459
+ patternType: row.pattern_type,
460
+ confidence: row.confidence,
461
+ createdAt: row.created_at,
462
+ source: 'semantic',
463
+ },
464
+ _sessionId: row.session_map_id || row.session_id,
465
+ _topicSegmentId: row.topic_segment_id,
466
+ _timestamp: row.created_at,
467
+ })));
468
+ }
469
+ catch (err) {
470
+ logger.warn('Reflection semantic search error:', err);
471
+ }
472
+ return facts;
473
+ }
474
+ // ============================================================
475
+ // BM25 / trigram text search
476
+ // ============================================================
477
+ async function bm25Search(query, sessionId, pool, filters) {
478
+ const queryTerms = query.split(/\s+/).filter((t) => t.length > 2);
479
+ if (queryTerms.length === 0)
480
+ return [];
481
+ const facts = [];
482
+ try {
483
+ const entityQuery = `
484
+ SELECT e.id, e.name, e.type, e.tier, e.weight, e.description,
485
+ similarity(e.name, $1) as bm25_score,
486
+ e.first_seen_at as created_at, e.confidence,
487
+ e.session_id, e.session_map_id, e.topic_segment_id
488
+ FROM entities e
489
+ WHERE (e.session_map_id = $2 OR e.session_id = $2 OR e.tier = 'permanent')
490
+ AND (e.name % $1 OR e.description % $1)
491
+ ${filters.sql}
492
+ ORDER BY similarity(e.name, $1) DESC
493
+ LIMIT ${PER_STRATEGY_LIMIT}
494
+ `;
495
+ const entityResult = await pool.query(entityQuery, [
496
+ query,
497
+ sessionId,
498
+ ...filters.params,
499
+ ]);
500
+ facts.push(...entityResult.rows.map((row) => ({
501
+ id: row.id,
502
+ type: 'entity',
503
+ content: `[${(row.type || '').toUpperCase()}] ${row.name}: ${row.description || ''}`,
504
+ relevanceScore: row.bm25_score ?? 0,
505
+ tokens: 0,
506
+ metadata: {
507
+ entityType: row.type,
508
+ weight: row.weight,
509
+ confidence: row.confidence,
510
+ createdAt: row.created_at,
511
+ tier: row.tier,
512
+ source: 'bm25',
513
+ },
514
+ _sessionId: row.session_map_id || row.session_id,
515
+ _topicSegmentId: row.topic_segment_id,
516
+ _timestamp: row.created_at,
517
+ })));
518
+ }
519
+ catch (err) {
520
+ logger.warn('BM25 search failed (pg_trgm may not be installed):', err);
521
+ }
522
+ return facts;
523
+ }
524
+ // ============================================================
525
+ // Graph traversal
526
+ // ============================================================
527
+ async function graphTraversal(query, _queryEmb, sessionId, pool, filters) {
528
+ // 1. Find seed entities matching query text
529
+ let seedQuery;
530
+ let seedParams;
531
+ try {
532
+ // Try newer schema (session_map_id)
533
+ seedQuery = `
534
+ SELECT id, name, type
535
+ FROM entities
536
+ WHERE (session_map_id = $1 OR session_id = $1 OR tier = 'permanent')
537
+ AND (name ILIKE $2 OR description ILIKE $2)
538
+ LIMIT 5
539
+ `;
540
+ seedParams = [sessionId, `%${query}%`];
541
+ }
542
+ catch {
543
+ return [];
544
+ }
545
+ const seedResult = await pool.query(seedQuery, seedParams);
546
+ if (seedResult.rows.length === 0)
547
+ return [];
548
+ const seedIds = seedResult.rows.map((row) => row.id);
549
+ // 2. Traverse relations (1-hop neighbors) using the graph query pattern from spec
550
+ const graphQuery = `
551
+ SELECT
552
+ e2.id, e2.name, e2.type, e2.tier, e2.weight, e2.description,
553
+ e2.first_seen_at as created_at, e2.confidence,
554
+ r.relation_type,
555
+ e_seed.name as related_entity_name,
556
+ e2.session_id, e2.session_map_id, e2.topic_segment_id
557
+ FROM entities e_seed
558
+ JOIN relations r ON e_seed.id = r.source_entity_id
559
+ JOIN entities e2 ON r.target_entity_id = e2.id
560
+ WHERE e_seed.id = ANY($1)
561
+ AND r.confidence >= $2
562
+ AND e_seed.id != e2.id
563
+ ORDER BY r.confidence DESC
564
+ LIMIT ${PER_STRATEGY_LIMIT}
565
+ `;
566
+ try {
567
+ const graphResult = await pool.query(graphQuery, [seedIds, 0.5]);
568
+ return graphResult.rows.map((row) => ({
569
+ id: row.id,
570
+ type: 'entity',
571
+ content: `[${(row.type || '').toUpperCase()}] ${row.name}: ${row.description || ''} (related to ${row.related_entity_name} via ${row.relation_type})`,
572
+ relevanceScore: (row.confidence ?? 0) * 0.8,
573
+ tokens: 0,
574
+ metadata: {
575
+ entityType: row.type,
576
+ weight: row.weight,
577
+ confidence: row.confidence,
578
+ createdAt: row.created_at,
579
+ tier: row.tier,
580
+ relationType: row.relation_type,
581
+ relatedEntity: row.related_entity_name,
582
+ source: 'graph',
583
+ },
584
+ _sessionId: row.session_map_id || row.session_id,
585
+ _topicSegmentId: row.topic_segment_id,
586
+ _timestamp: row.created_at,
587
+ }));
588
+ }
589
+ catch (err) {
590
+ logger.warn('Graph traversal error:', err);
591
+ return [];
592
+ }
593
+ }
594
+ // ============================================================
595
+ // Keyword search
596
+ // ============================================================
597
+ async function keywordSearch(query, sessionId, pool, filters) {
598
+ const keywords = query.split(/\s+/).filter((k) => k.length > 2);
599
+ if (keywords.length === 0)
600
+ return [];
601
+ const pattern = keywords.join('|');
602
+ try {
603
+ const entityQuery = `
604
+ SELECT e.id, e.name, e.type, e.tier, e.weight, e.description,
605
+ e.first_seen_at as created_at, e.confidence,
606
+ e.session_id, e.session_map_id, e.topic_segment_id
607
+ FROM entities e
608
+ WHERE (e.session_map_id = $1 OR e.session_id = $1 OR e.tier = 'permanent')
609
+ AND (e.name ~* $2 OR e.description ~* $2)
610
+ ${filters.sql}
611
+ LIMIT ${PER_STRATEGY_LIMIT}
612
+ `;
613
+ const entityResult = await pool.query(entityQuery, [sessionId, pattern, ...filters.params]);
614
+ return entityResult.rows.map((row) => ({
615
+ id: row.id,
616
+ type: 'entity',
617
+ content: `[${(row.type || '').toUpperCase()}] ${row.name}: ${row.description || ''}`,
618
+ relevanceScore: 0.6,
619
+ tokens: 0,
620
+ metadata: {
621
+ entityType: row.type,
622
+ weight: row.weight,
623
+ confidence: row.confidence,
624
+ createdAt: row.created_at,
625
+ tier: row.tier,
626
+ source: 'keyword',
627
+ },
628
+ _sessionId: row.session_map_id || row.session_id,
629
+ _topicSegmentId: row.topic_segment_id,
630
+ _timestamp: row.created_at,
631
+ }));
632
+ }
633
+ catch (err) {
634
+ logger.warn('Keyword search error:', err);
635
+ return [];
636
+ }
637
+ }
638
+ // ============================================================
639
+ // Step 4: Merge & deduplicate
640
+ // ============================================================
641
+ function mergeAndDeduplicate(strategyResults) {
642
+ const seen = new Set();
643
+ const merged = [];
644
+ for (const [, facts] of strategyResults) {
645
+ for (const fact of facts) {
646
+ const key = `${fact.type}:${fact.id || fact.metadata.id || ''}`;
647
+ if (!seen.has(key)) {
648
+ seen.add(key);
649
+ merged.push(fact);
650
+ }
651
+ else {
652
+ // Keep highest score
653
+ const existing = merged.find((f) => `${f.type}:${f.id || f.metadata.id || ''}` === key);
654
+ if (existing && fact.relevanceScore > existing.relevanceScore) {
655
+ existing.relevanceScore = fact.relevanceScore;
656
+ }
657
+ }
658
+ }
659
+ }
660
+ return merged;
661
+ }
662
+ // ============================================================
663
+ // Step 5: Multi-dimensional scoring
664
+ // ============================================================
665
+ function calculateMultiDimensionalScores(facts, _queryEmbedding, weights, decayConfig = DEFAULT_DECAY_CONFIG) {
666
+ const now = new Date();
667
+ let results = facts
668
+ .map((fact) => {
669
+ const semanticScore = fact.relevanceScore;
670
+ // Recency decay
671
+ const createdAt = new Date(fact.metadata.createdAt || fact._timestamp || Date.now());
672
+ const daysAgo = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
673
+ const recencyScore = 1.0 / (1 + daysAgo);
674
+ // Importance (normalize to 0-1)
675
+ const importance = fact.metadata.importance || fact.metadata.weight || 3;
676
+ const importanceScore = Math.min(1, importance / 5.0);
677
+ const finalScore = weights.semantic * semanticScore +
678
+ weights.recency * recencyScore +
679
+ weights.importance * importanceScore;
680
+ let relevanceScore = Math.round(finalScore * 1000) / 1000;
681
+ // ── Apply time-based entity weight decay ──
682
+ if (decayConfig.enabled) {
683
+ const metadata = fact.metadata || {};
684
+ const lastSeen = metadata.createdAt || fact._timestamp || Date.now();
685
+ const daysSinceLastSeen = (Date.now() - new Date(lastSeen).getTime()) / 86400000;
686
+ // Skip decay for permanent tier
687
+ if (metadata.tier !== 'permanent') {
688
+ // Apply exponential decay to relevanceScore
689
+ const decayedWeight = relevanceScore * Math.pow(decayConfig.factor, daysSinceLastSeen);
690
+ relevanceScore = Math.max(0.01, decayedWeight);
691
+ // Mark old, low-value results for filtering
692
+ if (daysSinceLastSeen > decayConfig.maxAgeDays && relevanceScore < 0.1) {
693
+ relevanceScore = 0;
694
+ }
695
+ }
696
+ }
697
+ return {
698
+ ...fact,
699
+ relevanceScore,
700
+ };
701
+ })
702
+ .filter((r) => r.relevanceScore > 0)
703
+ .sort((a, b) => b.relevanceScore - a.relevanceScore);
704
+ return results;
705
+ }
706
+ // ============================================================
707
+ // Step 6: Apply filters (post-retrieval)
708
+ // ============================================================
709
+ function applyFilters(facts, filters) {
710
+ if (!filters)
711
+ return facts;
712
+ // Resolve tier filter: single `tier` takes precedence, fall back to `tier_levels` (first element)
713
+ const effectiveTiers = [];
714
+ if (filters.tier) {
715
+ effectiveTiers.push(filters.tier);
716
+ }
717
+ else if (filters.tier_levels && filters.tier_levels.length > 0) {
718
+ effectiveTiers.push(...filters.tier_levels);
719
+ }
720
+ const now = Date.now();
721
+ return facts.filter((fact) => {
722
+ // min_confidence
723
+ if (filters.min_confidence !== undefined) {
724
+ const conf = fact.metadata.confidence;
725
+ if (conf !== undefined && conf !== null && conf < filters.min_confidence)
726
+ return false;
727
+ }
728
+ // min_importance
729
+ if (filters.min_importance !== undefined) {
730
+ const imp = fact.metadata.importance;
731
+ if (imp !== undefined && imp !== null && imp < filters.min_importance)
732
+ return false;
733
+ }
734
+ // tier filter (single + array backward compat)
735
+ if (effectiveTiers.length > 0) {
736
+ const tier = fact.metadata.tier;
737
+ if (tier && !effectiveTiers.includes(tier))
738
+ return false;
739
+ }
740
+ // entity_types filter
741
+ if (filters.entity_types && filters.entity_types.length > 0) {
742
+ const etype = fact.metadata.entityType;
743
+ if (etype && !filters.entity_types.includes(etype))
744
+ return false;
745
+ }
746
+ // exclude_topic_segment_ids
747
+ if (filters.exclude_topic_segment_ids && filters.exclude_topic_segment_ids.length > 0) {
748
+ if (fact._topicSegmentId &&
749
+ filters.exclude_topic_segment_ids.includes(fact._topicSegmentId)) {
750
+ return false;
751
+ }
752
+ }
753
+ // time_range_days (backward compat)
754
+ if (filters.time_range_days !== undefined) {
755
+ const ts = fact._timestamp || fact.metadata.createdAt;
756
+ if (ts) {
757
+ const ageMs = now - new Date(ts).getTime();
758
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
759
+ if (ageDays > filters.time_range_days)
760
+ return false;
761
+ }
762
+ }
763
+ return true;
764
+ });
765
+ }
766
+ // ============================================================
767
+ // Step 7: Cross-encoder rerank (simplified)
768
+ // ============================================================
769
+ async function crossEncoderRerank(facts, query) {
770
+ const queryLower = query.toLowerCase();
771
+ const reranked = facts.map((fact) => {
772
+ const contentLower = fact.content.toLowerCase();
773
+ const queryWords = queryLower.split(/\s+/);
774
+ const contentWords = contentLower.split(/\s+/);
775
+ let overlap = 0;
776
+ for (const word of queryWords) {
777
+ if (word.length > 2 && contentWords.some((cw) => cw.includes(word))) {
778
+ overlap++;
779
+ }
780
+ }
781
+ const overlapScore = queryWords.length > 0 ? overlap / queryWords.length : 0;
782
+ const adjustedScore = fact.relevanceScore * 0.7 + overlapScore * 0.3;
783
+ return {
784
+ ...fact,
785
+ relevanceScore: Math.round(adjustedScore * 1000) / 1000,
786
+ };
787
+ });
788
+ return reranked.sort((a, b) => b.relevanceScore - a.relevanceScore);
789
+ }
790
+ // ============================================================
791
+ // Step 9: Enrich with context (session_map + topic_segments)
792
+ // ============================================================
793
+ async function enrichWithContext(facts, sessionId, pool) {
794
+ if (facts.length === 0)
795
+ return facts;
796
+ // Collect unique session IDs that need context lookup
797
+ const sessionIds = new Set();
798
+ const topicIds = new Set();
799
+ for (const fact of facts) {
800
+ if (fact._sessionId)
801
+ sessionIds.add(fact._sessionId);
802
+ if (fact._topicSegmentId)
803
+ topicIds.add(fact._topicSegmentId);
804
+ }
805
+ // Batch lookups
806
+ const [sessionMap, topicMap] = await Promise.all([
807
+ lookupSessions([...sessionIds], pool),
808
+ lookupTopics([...topicIds], pool),
809
+ ]);
810
+ // Apply context to each fact
811
+ for (const fact of facts) {
812
+ if (fact._sessionId && sessionMap.has(fact._sessionId)) {
813
+ const s = sessionMap.get(fact._sessionId);
814
+ fact._omoTaskId = fact._omoTaskId || s.omo_task_id;
815
+ if (!fact._sessionId)
816
+ fact._sessionId = s.opencode_session_id || fact._sessionId;
817
+ }
818
+ if (fact._topicSegmentId && topicMap.has(fact._topicSegmentId)) {
819
+ const t = topicMap.get(fact._topicSegmentId);
820
+ fact._topicSummary = t.summary;
821
+ }
822
+ // Fallback timestamp
823
+ if (!fact._timestamp) {
824
+ fact._timestamp = fact.metadata.createdAt || new Date().toISOString();
825
+ }
826
+ }
827
+ return facts;
828
+ }
829
+ async function lookupSessions(ids, pool) {
830
+ const map = new Map();
831
+ if (ids.length === 0)
832
+ return map;
833
+ try {
834
+ const result = await pool.query(`SELECT id, opencode_session_id, omo_task_id FROM session_map WHERE id = ANY($1)`, [ids]);
835
+ for (const row of result.rows) {
836
+ map.set(row.id, {
837
+ opencode_session_id: row.opencode_session_id,
838
+ omo_task_id: row.omo_task_id,
839
+ });
840
+ }
841
+ }
842
+ catch {
843
+ // session_map may not exist, try sessions
844
+ try {
845
+ const result = await pool.query(`SELECT id, external_id FROM sessions WHERE id = ANY($1)`, [ids]);
846
+ for (const row of result.rows) {
847
+ map.set(row.id, { opencode_session_id: row.external_id });
848
+ }
849
+ }
850
+ catch {
851
+ // Both failed — context will be sparse
852
+ }
853
+ }
854
+ return map;
855
+ }
856
+ async function lookupTopics(ids, pool) {
857
+ const map = new Map();
858
+ if (ids.length === 0)
859
+ return map;
860
+ try {
861
+ const result = await pool.query(`SELECT id, summary FROM topic_segments WHERE id = ANY($1)`, [ids]);
862
+ for (const row of result.rows) {
863
+ map.set(row.id, { summary: row.summary });
864
+ }
865
+ }
866
+ catch {
867
+ // topic_segments may not exist
868
+ }
869
+ return map;
870
+ }
871
+ // ============================================================
872
+ // Helpers: type mapping, data payload builder, vector formatting
873
+ // ============================================================
874
+ function mapType(type) {
875
+ if (type === 'message')
876
+ return 'observation'; // legacy messages → observation
877
+ if (type === 'relation')
878
+ return 'relation';
879
+ return type;
880
+ }
881
+ function buildDataPayload(fact) {
882
+ return {
883
+ name: fact.metadata.entityType,
884
+ type: fact.type,
885
+ content: fact.content,
886
+ confidence: fact.metadata.confidence,
887
+ importance: fact.metadata.importance || fact.metadata.weight,
888
+ created_at: fact.metadata.createdAt || fact._timestamp,
889
+ pattern_type: fact.metadata.patternType,
890
+ relation_type: fact.metadata.relationType,
891
+ related_entity: fact.metadata.relatedEntity,
892
+ tool_name: fact.metadata.toolName,
893
+ ...fact.metadata,
894
+ };
895
+ }
896
+ /** Format a number[] as a pgvector literal string: '[0.1,0.2,...]' */
897
+ function formatVectorLiteral(embedding) {
898
+ return `[${embedding.join(',')}]`;
899
+ }
900
+ //# sourceMappingURL=recall-memory.js.map