opcode-pg-memory 2.2.8 → 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.
- package/dist/cli.js +232 -214
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -21006
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +319 -302
- package/dist/mcp-server.js.map +1 -0
- package/dist/src/cache/semantic-cache.js +399 -0
- package/dist/src/cache/semantic-cache.js.map +1 -0
- package/dist/src/cli.js +404 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +5 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +89 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/db/init-db.js +545 -0
- package/dist/src/db/init-db.js.map +1 -0
- package/dist/src/hooks/message-part-updated.js +203 -0
- package/dist/src/hooks/message-part-updated.js.map +1 -0
- package/dist/src/hooks/message-updated.js +347 -0
- package/dist/src/hooks/message-updated.js.map +1 -0
- package/dist/src/hooks/session-compacting.js +179 -0
- package/dist/src/hooks/session-compacting.js.map +1 -0
- package/dist/src/hooks/session-completed.js +337 -0
- package/dist/src/hooks/session-completed.js.map +1 -0
- package/dist/src/hooks/session-created.js +206 -0
- package/dist/src/hooks/session-created.js.map +1 -0
- package/dist/src/hooks/tool-execute.js +267 -0
- package/dist/src/hooks/tool-execute.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +643 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp/hindsight-reflect-omo.js +318 -0
- package/dist/src/mcp/hindsight-reflect-omo.js.map +1 -0
- package/dist/src/mcp/hindsight-reflect.js +838 -0
- package/dist/src/mcp/hindsight-reflect.js.map +1 -0
- package/dist/src/mcp/recall-memory-omo.js +263 -0
- package/dist/src/mcp/recall-memory-omo.js.map +1 -0
- package/dist/src/mcp/recall-memory.d.ts +6 -0
- package/dist/src/mcp/recall-memory.d.ts.map +1 -1
- package/dist/src/mcp/recall-memory.js +900 -0
- package/dist/src/mcp/recall-memory.js.map +1 -0
- package/dist/src/omo/adapter.js +583 -0
- package/dist/src/omo/adapter.js.map +1 -0
- package/dist/src/omo/types.js +44 -0
- package/dist/src/omo/types.js.map +1 -0
- package/dist/src/services/db-polling.d.ts +30 -0
- package/dist/src/services/db-polling.d.ts.map +1 -0
- package/dist/src/services/db-polling.js +97 -0
- package/dist/src/services/db-polling.js.map +1 -0
- package/dist/src/services/event-synchronizer.d.ts +15 -0
- package/dist/src/services/event-synchronizer.d.ts.map +1 -0
- package/dist/src/services/event-synchronizer.js +119 -0
- package/dist/src/services/event-synchronizer.js.map +1 -0
- package/dist/src/services/keyword.js +29 -0
- package/dist/src/services/keyword.js.map +1 -0
- package/dist/src/services/logger.js +42 -0
- package/dist/src/services/logger.js.map +1 -0
- package/dist/src/services/opencode-schema-adapter.d.ts +34 -0
- package/dist/src/services/opencode-schema-adapter.d.ts.map +1 -0
- package/dist/src/services/opencode-schema-adapter.js +96 -0
- package/dist/src/services/opencode-schema-adapter.js.map +1 -0
- package/dist/src/services/privacy.js +23 -0
- package/dist/src/services/privacy.js.map +1 -0
- package/dist/src/topic/segment-manager.js +447 -0
- package/dist/src/topic/segment-manager.js.map +1 -0
- package/dist/src/types.d.ts +20 -2
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/embedding.js +180 -0
- package/dist/src/utils/embedding.js.map +1 -0
- package/dist/src/utils/token-budget.js +152 -0
- package/dist/src/utils/token-budget.js.map +1 -0
- package/package.json +6 -5
|
@@ -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
|