persyst-mcp 2.1.3 → 2.2.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/src/search.js CHANGED
@@ -1,456 +1,561 @@
1
- /**
2
- * search.js — Hybrid Search & Context Optimization Engine
3
- *
4
- * Combines keyword and semantic searches, integrates temporal decay,
5
- * applies agent reputation scores, generates cryptographic search attestations,
6
- * builds graph-hopped optimized LLM context prompts, and applies MMR
7
- * for diverse result retrieval.
8
- */
9
-
10
- import db, {
11
- searchKeyword,
12
- searchVector,
13
- getMemoryById,
14
- boostMemory,
15
- getProvenance,
16
- getMemoriesByEntity
17
- } from './database.js';
18
- import { generateEmbedding } from './embeddings.js';
19
- import { createAttestation } from './attestation.js';
20
- import { searchCache, LRUCache } from './cache.js';
21
-
22
- let lastDataVersion = 0;
23
-
24
- /**
25
- * Search memories using both keyword and semantic strategies.
26
- * Results are cached in the LRU cache for repeated queries.
27
- *
28
- * @param {string} queryText - What to search for
29
- * @param {number} limit - Max results to return (default: 5)
30
- * @param {string|null} agentId - Identifying string for the querying agent
31
- * @param {string|null} sessionId - Session identifier
32
- * @returns {Promise<Array>} Ranked search results (with .attestation property attached)
33
- */
34
- export async function searchHybrid(queryText, limit = 5, agentId = null, sessionId = null, namespace = null) {
35
- // Sync in-memory cache with external DB changes using sqlite data_version
36
- try {
37
- const currentDataVersion = db.pragma('data_version', { simple: true });
38
- if (currentDataVersion !== lastDataVersion) {
39
- searchCache.invalidate();
40
- lastDataVersion = currentDataVersion;
41
- }
42
- } catch (_) {
43
- // Fallback if pragma fails
44
- }
45
-
46
- // --- Check LRU cache first (Feature 1) ---
47
- // Include namespace in cache key to prevent cross-namespace cache hits
48
- const cacheKey = LRUCache.key(`${namespace || 'all'}:${queryText}`, limit);
49
- const cached = searchCache.get(cacheKey);
50
- if (cached) {
51
- console.error(`[persyst-cache] Cache HIT for query: "${queryText.slice(0, 50)}..."`);
52
- return cached;
53
- }
54
-
55
- // --- Step 1: Keyword search (fast, exact matches) ---
56
- const keywordHits = searchKeyword(queryText, limit * 2);
57
- const keywordIds = new Set(keywordHits.map(r => r.id));
58
-
59
- // --- Step 2: Semantic search (meaning-based) ---
60
- const queryEmbedding = await generateEmbedding(queryText);
61
- const vecHits = searchVector(queryEmbedding, limit * 2);
62
-
63
- const semanticResults = vecHits.map(r => ({
64
- id: r.rowid,
65
- distance: r.distance,
66
- // Convert L2 distance to 0-1 similarity score
67
- similarity: Math.max(0, 1 - (r.distance * r.distance) / 2)
68
- }));
69
-
70
- // --- Step 3: Merge results with keyword boost ---
71
- const combined = semanticResults
72
- .map(r => {
73
- const isKeywordMatch = keywordIds.has(r.id);
74
- return {
75
- id: r.id,
76
- similarity: r.similarity,
77
- hybrid_score: r.similarity + (isKeywordMatch ? 0.2 : 0),
78
- keyword_match: isKeywordMatch
79
- };
80
- })
81
- // Filter out low similarity semantic matches if they have no keyword match (threshold 0.30)
82
- .filter(r => r.keyword_match || r.similarity >= 0.30);
83
-
84
- // Add keyword-only hits that semantic search missed
85
- const semanticIds = new Set(semanticResults.map(r => r.id));
86
- for (const id of keywordIds) {
87
- if (!semanticIds.has(id)) {
88
- combined.push({
89
- id,
90
- similarity: 0,
91
- hybrid_score: 0.2, // Keyword-only base score
92
- keyword_match: true
93
- });
94
- }
95
- }
96
-
97
- // --- Step 4: Fetch full details, apply namespace filter, reputation adjust, sort and return top N ---
98
- const finalResults = combined
99
- .map(r => {
100
- // Use namespace-aware getMemoryById to filter by agent namespace
101
- const memory = getMemoryById(r.id, namespace);
102
- if (!memory) return null; // Memory was archived, deleted, or not in namespace
103
-
104
- // Boost memory access metrics
105
- boostMemory(r.id);
106
-
107
- // Fetch reputation stats for weighting
108
- let reputationScore = 1.0;
109
- let reputationWarning = false;
110
- const prov = memory.provenance;
111
- if (prov && prov.source_type === 'agent' && prov.source_id) {
112
- const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(prov.source_id);
113
- if (agentRow) {
114
- reputationScore = agentRow.reputation_score;
115
- if (reputationScore < 0.5) {
116
- reputationWarning = true;
117
- }
118
- }
119
- }
120
-
121
- // Final score formula: base_score * agent_reputation
122
- const finalScore = r.hybrid_score * reputationScore;
123
-
124
- return {
125
- id: memory.id,
126
- content: memory.content,
127
- importance_score: memory.importance_score,
128
- created_at: memory.created_at,
129
- last_accessed: memory.last_accessed,
130
- similarity: r.similarity.toFixed(4),
131
- hybrid_score: finalScore.toFixed(4),
132
- keyword_match: r.keyword_match,
133
- reputation_warning: reputationWarning,
134
- provenance: prov
135
- };
136
- })
137
- .filter(Boolean);
138
-
139
- // Sort by final score descending
140
- finalResults.sort((a, b) => parseFloat(b.hybrid_score) - parseFloat(a.hybrid_score));
141
-
142
- // --- Step 5: Apply MMR for diverse retrieval (Feature 3) ---
143
- const mmrResults = applyMMR(finalResults, limit);
144
-
145
- // Generate cryptographic attestation for audit trails
146
- const attestation = createAttestation(queryText, mmrResults, agentId, sessionId);
147
-
148
- // Attach attestation object directly to the array to preserve compatibility with existing tests
149
- mmrResults.attestation = attestation;
150
-
151
- // --- Store in LRU cache (Feature 1) ---
152
- searchCache.set(cacheKey, mmrResults);
153
-
154
- return mmrResults;
155
- }
156
-
157
- /**
158
- * Apply Maximal Marginal Relevance (MMR) re-ranking for diverse results.
159
- *
160
- * MMR balances relevance with diversity by penalizing candidates that
161
- * are too similar to already-selected results.
162
- *
163
- * @param {Array} candidates - Scored search results
164
- * @param {number} limit - Max results to return
165
- * @param {number} lambda - Trade-off parameter (0.7 = 70% relevance, 30% diversity)
166
- * @returns {Array} MMR-reranked results
167
- */
168
- function applyMMR(candidates, limit, lambda = 0.7) {
169
- if (candidates.length <= limit) return candidates;
170
-
171
- const selected = [];
172
- const remaining = [...candidates];
173
-
174
- // Always pick the top-scored result first
175
- selected.push(remaining.shift());
176
-
177
- while (selected.length < limit && remaining.length > 0) {
178
- let bestIdx = -1;
179
- let bestMMRScore = -Infinity;
180
-
181
- for (let i = 0; i < remaining.length; i++) {
182
- const candidate = remaining[i];
183
- const relevance = parseFloat(candidate.hybrid_score);
184
-
185
- // Calculate max similarity to any already-selected result
186
- // Using content-based Jaccard similarity as a proxy
187
- let maxSimToSelected = 0;
188
- for (const sel of selected) {
189
- const sim = jaccardSimilarity(candidate.content, sel.content);
190
- if (sim > maxSimToSelected) maxSimToSelected = sim;
191
- }
192
-
193
- // MMR score = λ * relevance - (1 - λ) * max_similarity_to_selected
194
- const mmrScore = lambda * relevance - (1 - lambda) * maxSimToSelected;
195
-
196
- if (mmrScore > bestMMRScore) {
197
- bestMMRScore = mmrScore;
198
- bestIdx = i;
199
- }
200
- }
201
-
202
- if (bestIdx >= 0) {
203
- selected.push(remaining.splice(bestIdx, 1)[0]);
204
- } else {
205
- break;
206
- }
207
- }
208
-
209
- return selected;
210
- }
211
-
212
- /**
213
- * Compute Jaccard similarity between two text strings.
214
- * Uses word-level tokenization for efficiency.
215
- *
216
- * @param {string} a - First text
217
- * @param {string} b - Second text
218
- * @returns {number} Similarity score between 0 and 1
219
- */
220
- function jaccardSimilarity(a, b) {
221
- const wordsA = new Set(a.toLowerCase().split(/\s+/));
222
- const wordsB = new Set(b.toLowerCase().split(/\s+/));
223
-
224
- let intersection = 0;
225
- for (const word of wordsA) {
226
- if (wordsB.has(word)) intersection++;
227
- }
228
-
229
- const union = wordsA.size + wordsB.size - intersection;
230
- return union === 0 ? 0 : intersection / union;
231
- }
232
-
233
- /**
234
- * Optimizes the retrieved context by walking the knowledge graph and compressing content to fit max_tokens.
235
- *
236
- * @param {string} queryText - User's query
237
- * @param {number} maxTokens - Hard limit of tokens for context prompt
238
- * @param {string|null} agentId - Querying agent identifier
239
- * @param {string|null} sessionId - Current session ID
240
- */
241
- export async function getOptimizedContext(queryText, maxTokens, agentId = null, sessionId = null, namespace = null) {
242
- // 1. Run hybrid search to fetch top 20 memories (namespace-aware)
243
- const searchHits = await searchHybrid(queryText, 20, agentId, sessionId, namespace);
244
- const candidates = new Map();
245
-
246
- for (const hit of searchHits) {
247
- candidates.set(hit.id, {
248
- id: hit.id,
249
- content: hit.content,
250
- importance_score: hit.importance_score,
251
- created_at: hit.created_at,
252
- last_accessed: hit.last_accessed,
253
- score: parseFloat(hit.hybrid_score),
254
- provenance: hit.provenance,
255
- source: 'search'
256
- });
257
-
258
- // 2. Perform Graph Hop
259
- const edges = db.prepare(`
260
- SELECT * FROM edges
261
- WHERE (source_id = ? AND source_type = 'memory')
262
- OR (target_id = ? AND target_type = 'memory')
263
- `).all(hit.id, hit.id);
264
-
265
- const entityIds = [];
266
- for (const edge of edges) {
267
- if (edge.source_type === 'entity') entityIds.push(edge.source_id);
268
- if (edge.target_type === 'entity') entityIds.push(edge.target_id);
269
- }
270
-
271
- for (const entId of entityIds) {
272
- const otherMemories = getMemoriesByEntity(entId);
273
- for (const other of otherMemories) {
274
- if (other.id === hit.id) continue;
275
- if (candidates.has(other.id)) continue;
276
-
277
- const otherProv = getProvenance(other.id);
278
- candidates.set(other.id, {
279
- id: other.id,
280
- content: other.content,
281
- importance_score: other.importance_score,
282
- created_at: other.created_at,
283
- last_accessed: other.last_accessed,
284
- score: parseFloat(hit.hybrid_score) * 0.5, // 50% graph-hop penalty
285
- provenance: otherProv,
286
- source: 'hop'
287
- });
288
- }
289
- }
290
- }
291
-
292
- // 3. Apply Scoring Adjustments
293
- const now = Math.floor(Date.now() / 1000);
294
- const list = Array.from(candidates.values());
295
-
296
- for (const c of list) {
297
- // 3a. Temporal decay: score *= exp(-0.01 * hours_since_accessed)
298
- const hours = Math.max(0, (now - c.last_accessed) / 3600);
299
- c.score *= Math.exp(-0.01 * hours);
300
-
301
- // 3b. Agent reputation weighting
302
- let reputationScore = 1.0;
303
- if (c.provenance && c.provenance.source_type === 'agent' && c.provenance.source_id) {
304
- const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(c.provenance.source_id);
305
- if (agentRow) {
306
- reputationScore = agentRow.reputation_score;
307
- }
308
- }
309
- c.score *= reputationScore;
310
- }
311
-
312
- // 4. Sort candidates
313
- list.sort((a, b) => b.score - a.score);
314
-
315
- // 5. Compress context to fit maxTokens
316
- let currentTokens = 0;
317
- const accepted = [];
318
-
319
- for (const c of list) {
320
- // Heuristic: ~4 characters per token + format headers (~15 tokens)
321
- const estimatedTokens = Math.max(1, Math.ceil(c.content.length / 4) + 15);
322
- if (currentTokens + estimatedTokens > maxTokens) {
323
- continue;
324
- }
325
- currentTokens += estimatedTokens;
326
- accepted.push(c);
327
- }
328
-
329
- // 6. Format LLM injection context string
330
- let context = '=== RETRIEVED AGENT MEMORY CONTEXT ===\n';
331
- if (accepted.length === 0) {
332
- context += 'No relevant memories retrieved.\n';
333
- } else {
334
- for (const a of accepted) {
335
- let sourceTag = 'Source: manual';
336
- if (a.provenance) {
337
- sourceTag = `Source: ${a.provenance.source_type}${a.provenance.source_id ? ` (${a.provenance.source_id})` : ''}`;
338
- }
339
- context += `[Memory #${a.id}] (Score: ${a.score.toFixed(4)}, ${sourceTag})\n${a.content}\n---\n`;
340
- }
341
- }
342
- context += '=== END OF CONTEXT ===';
343
-
344
- // Bug 8 fix: Skip attestation when no results to avoid audit noise
345
- let attestation = null;
346
- if (accepted.length > 0) {
347
- attestation = createAttestation(queryText, accepted, agentId, sessionId);
348
- }
349
-
350
- return {
351
- context,
352
- memories: accepted,
353
- attestation
354
- };
355
- }
356
-
357
- /**
358
- * Performs memory consolidation by merging highly similar memories.
359
- * Bug 6 fix: DB mutations are wrapped in a transaction for atomicity.
360
- */
361
- export async function consolidateMemories(namespace = null) {
362
- // Only consolidate within namespace boundaries to prevent cross-agent merging
363
- const query = namespace
364
- ? "SELECT * FROM memories WHERE valid_until IS NULL AND (namespace = ? OR namespace = 'shared')"
365
- : 'SELECT * FROM memories WHERE valid_until IS NULL';
366
- const activeMemories = namespace
367
- ? db.prepare(query).all(namespace)
368
- : db.prepare(query).all();
369
- const consolidated = [];
370
- const visited = new Set();
371
-
372
- // Pre-compile the transaction for atomic DB operations (Bug 6 fix)
373
- const archiveAndMerge = db.transaction((canonicalId, mergedContent, dupIds) => {
374
- // Update canonical memory with merged content
375
- db.prepare('UPDATE memories SET content = ?, last_accessed = unixepoch() WHERE id = ?').run(mergedContent, canonicalId);
376
-
377
- // Archive duplicates
378
- for (const dupId of dupIds) {
379
- db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(dupId);
380
- db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
381
- .run(dupId, canonicalId, `Consolidated into canonical memory #${canonicalId}`);
382
- }
383
- });
384
-
385
- for (const mem of activeMemories) {
386
- if (visited.has(mem.id)) continue;
387
-
388
- // Search for similar memories
389
- const embedding = db.prepare('SELECT embedding FROM memories_vec WHERE rowid = ?').get(mem.id);
390
- if (!embedding) continue;
391
-
392
- // sqlite-vec similarity search
393
- const hits = db.prepare(`
394
- SELECT rowid AS id, distance
395
- FROM memories_vec
396
- WHERE embedding MATCH ?
397
- AND k = 10
398
- `).all(embedding.embedding);
399
-
400
- const duplicates = [];
401
- for (const hit of hits) {
402
- if (Number(hit.id) === mem.id) continue;
403
- if (visited.has(Number(hit.id))) continue;
404
-
405
- const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
406
- if (sim > 0.85) {
407
- const dupMemory = db.prepare('SELECT * FROM memories WHERE id = ? AND valid_until IS NULL').get(Number(hit.id));
408
- if (dupMemory) {
409
- duplicates.push(dupMemory);
410
- }
411
- }
412
- }
413
-
414
- if (duplicates.length > 0) {
415
- // Group found! Merge them.
416
- const allMemoriesInGroup = [mem, ...duplicates];
417
-
418
- // Sort by importance to pick canonical
419
- allMemoriesInGroup.sort((a, b) => b.importance_score - a.importance_score);
420
- const canonical = allMemoriesInGroup[0];
421
- const dupesToArchive = allMemoriesInGroup.slice(1);
422
-
423
- // Merge contents (unique sentences or concatenated text)
424
- const contents = allMemoriesInGroup.map(m => m.content.trim());
425
- const uniqueContents = Array.from(new Set(contents));
426
- const mergedContent = uniqueContents.join('. ').replace(/\.\./g, '.');
427
-
428
- // Generate new embedding OUTSIDE the transaction (async operation)
429
- const newEmbedding = await generateEmbedding(mergedContent);
430
-
431
- // Run atomic DB transaction for all mutations (Bug 6 fix)
432
- archiveAndMerge(canonical.id, mergedContent, dupesToArchive.map(d => d.id));
433
-
434
- // Update vector embedding (also outside transaction since vec0 tables have their own handling)
435
- db.prepare('DELETE FROM memories_vec WHERE rowid = ?').run(canonical.id);
436
- db.prepare('INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)').run(BigInt(canonical.id), Buffer.from(newEmbedding.buffer));
437
-
438
- for (const dup of dupesToArchive) {
439
- visited.add(dup.id);
440
- }
441
-
442
- visited.add(canonical.id);
443
- consolidated.push({
444
- canonical_id: canonical.id,
445
- merged_content: mergedContent,
446
- archived_ids: dupesToArchive.map(d => d.id)
447
- });
448
- }
449
- }
450
-
451
- return {
452
- success: true,
453
- consolidated_groups: consolidated.length,
454
- details: consolidated
455
- };
456
- }
1
+ /**
2
+ * search.js — Hybrid Search & Context Optimization Engine
3
+ *
4
+ * Combines keyword and semantic searches, integrates temporal decay,
5
+ * applies agent reputation scores, generates cryptographic search attestations,
6
+ * builds graph-hopped optimized LLM context prompts, and applies MMR
7
+ * for diverse result retrieval.
8
+ */
9
+
10
+ import db, {
11
+ searchKeyword,
12
+ searchVector,
13
+ getMemoryById,
14
+ boostMemory,
15
+ getProvenance,
16
+ getMemoriesByEntity
17
+ } from './database.js';
18
+ import { generateEmbedding } from './embeddings.js';
19
+ import { createAttestation } from './attestation.js';
20
+ import { searchCache, LRUCache } from './cache.js';
21
+
22
+ let lastDataVersion = 0;
23
+
24
+ /**
25
+ * Search memories using both keyword and semantic strategies.
26
+ * Results are cached in the LRU cache for repeated queries.
27
+ *
28
+ * @param {string} queryText - What to search for
29
+ * @param {number} limit - Max results to return (default: 5)
30
+ * @param {string|null} agentId - Identifying string for the querying agent
31
+ * @param {string|null} sessionId - Session identifier
32
+ * @returns {Promise<Array>} Ranked search results (with .attestation property attached)
33
+ */
34
+ export async function searchHybrid(queryText, limit = 5, agentId = null, sessionId = null, namespace = null) {
35
+ // Sync in-memory cache with external DB changes using sqlite data_version
36
+ try {
37
+ const currentDataVersion = db.pragma('data_version', { simple: true });
38
+ if (currentDataVersion !== lastDataVersion) {
39
+ searchCache.invalidate();
40
+ lastDataVersion = currentDataVersion;
41
+ }
42
+ } catch (_) {
43
+ // Fallback if pragma fails
44
+ }
45
+
46
+ // --- Check LRU cache first (Feature 1) ---
47
+ // Include namespace in cache key to prevent cross-namespace cache hits
48
+ const cacheKey = LRUCache.key(`${namespace || 'all'}:${queryText}`, limit);
49
+ const cached = searchCache.get(cacheKey);
50
+ if (cached) {
51
+ console.error(`[persyst-cache] Cache HIT for query: "${queryText.slice(0, 50)}..."`);
52
+ return cached;
53
+ }
54
+
55
+ // --- Step 1: Keyword search (fast, exact matches) ---
56
+ const keywordHits = searchKeyword(queryText, limit * 2);
57
+ const keywordIds = new Set(keywordHits.map(r => r.id));
58
+
59
+ // --- Step 2: Semantic search (meaning-based) ---
60
+ const queryEmbedding = await generateEmbedding(queryText);
61
+ const vecHits = searchVector(queryEmbedding, limit * 2);
62
+
63
+ const semanticResults = vecHits.map(r => ({
64
+ id: r.rowid,
65
+ distance: r.distance,
66
+ // Convert L2 distance to 0-1 similarity score
67
+ similarity: Math.max(0, 1 - (r.distance * r.distance) / 2)
68
+ }));
69
+
70
+ // --- Step 3: Merge results with keyword boost ---
71
+ const combined = semanticResults
72
+ .map(r => {
73
+ const isKeywordMatch = keywordIds.has(r.id);
74
+ return {
75
+ id: r.id,
76
+ similarity: r.similarity,
77
+ hybrid_score: r.similarity + (isKeywordMatch ? 0.2 : 0),
78
+ keyword_match: isKeywordMatch
79
+ };
80
+ })
81
+ // Filter out low similarity semantic matches if they have no keyword match (threshold 0.30)
82
+ .filter(r => r.keyword_match || r.similarity >= 0.30);
83
+
84
+ // Add keyword-only hits that semantic search missed
85
+ const semanticIds = new Set(semanticResults.map(r => r.id));
86
+ for (const id of keywordIds) {
87
+ if (!semanticIds.has(id)) {
88
+ combined.push({
89
+ id,
90
+ similarity: 0,
91
+ hybrid_score: 0.2, // Keyword-only base score
92
+ keyword_match: true
93
+ });
94
+ }
95
+ }
96
+
97
+ // --- Step 4: Fetch full details, apply namespace filter, reputation adjust, sort and return top N ---
98
+ const finalResults = combined
99
+ .map(r => {
100
+ // Use namespace-aware getMemoryById to filter by agent namespace
101
+ const memory = getMemoryById(r.id, namespace);
102
+ if (!memory) return null; // Memory was archived, deleted, or not in namespace
103
+
104
+ // Boost memory access metrics
105
+ boostMemory(r.id);
106
+
107
+ // Fetch reputation stats for weighting
108
+ let reputationScore = 1.0;
109
+ let reputationWarning = false;
110
+ const prov = memory.provenance;
111
+ if (prov && prov.source_type === 'agent' && prov.source_id) {
112
+ const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(prov.source_id);
113
+ if (agentRow) {
114
+ reputationScore = agentRow.reputation_score;
115
+ if (reputationScore < 0.5) {
116
+ reputationWarning = true;
117
+ }
118
+ }
119
+ }
120
+
121
+ // Final score formula: base_score * agent_reputation
122
+ const finalScore = r.hybrid_score * reputationScore;
123
+
124
+ return {
125
+ id: memory.id,
126
+ content: memory.content,
127
+ importance_score: memory.importance_score,
128
+ created_at: memory.created_at,
129
+ last_accessed: memory.last_accessed,
130
+ similarity: r.similarity.toFixed(4),
131
+ hybrid_score: finalScore.toFixed(4),
132
+ keyword_match: r.keyword_match,
133
+ reputation_warning: reputationWarning,
134
+ provenance: prov
135
+ };
136
+ })
137
+ .filter(Boolean);
138
+
139
+ // Sort by final score descending
140
+ finalResults.sort((a, b) => parseFloat(b.hybrid_score) - parseFloat(a.hybrid_score));
141
+
142
+ // --- Step 5: Apply MMR for diverse retrieval (Feature 3) ---
143
+ const mmrResults = applyMMR(finalResults, limit);
144
+
145
+ // Generate cryptographic attestation for audit trails
146
+ const attestation = createAttestation(queryText, mmrResults, agentId, sessionId);
147
+
148
+ // Attach attestation object directly to the array to preserve compatibility with existing tests
149
+ mmrResults.attestation = attestation;
150
+
151
+ // --- Store in LRU cache (Feature 1) ---
152
+ searchCache.set(cacheKey, mmrResults);
153
+
154
+ return mmrResults;
155
+ }
156
+
157
+ /**
158
+ * Apply Maximal Marginal Relevance (MMR) re-ranking for diverse results.
159
+ *
160
+ * MMR balances relevance with diversity by penalizing candidates that
161
+ * are too similar to already-selected results.
162
+ *
163
+ * @param {Array} candidates - Scored search results
164
+ * @param {number} limit - Max results to return
165
+ * @param {number} lambda - Trade-off parameter (0.7 = 70% relevance, 30% diversity)
166
+ * @returns {Array} MMR-reranked results
167
+ */
168
+ function applyMMR(candidates, limit, lambda = 0.7) {
169
+ if (candidates.length <= limit) return candidates;
170
+
171
+ const selected = [];
172
+ const remaining = [...candidates];
173
+
174
+ // Always pick the top-scored result first
175
+ selected.push(remaining.shift());
176
+
177
+ while (selected.length < limit && remaining.length > 0) {
178
+ let bestIdx = -1;
179
+ let bestMMRScore = -Infinity;
180
+
181
+ for (let i = 0; i < remaining.length; i++) {
182
+ const candidate = remaining[i];
183
+ const relevance = parseFloat(candidate.hybrid_score);
184
+
185
+ // Calculate max similarity to any already-selected result
186
+ // Using content-based Jaccard similarity as a proxy
187
+ let maxSimToSelected = 0;
188
+ for (const sel of selected) {
189
+ const sim = jaccardSimilarity(candidate.content, sel.content);
190
+ if (sim > maxSimToSelected) maxSimToSelected = sim;
191
+ }
192
+
193
+ // MMR score = λ * relevance - (1 - λ) * max_similarity_to_selected
194
+ const mmrScore = lambda * relevance - (1 - lambda) * maxSimToSelected;
195
+
196
+ if (mmrScore > bestMMRScore) {
197
+ bestMMRScore = mmrScore;
198
+ bestIdx = i;
199
+ }
200
+ }
201
+
202
+ if (bestIdx >= 0) {
203
+ selected.push(remaining.splice(bestIdx, 1)[0]);
204
+ } else {
205
+ break;
206
+ }
207
+ }
208
+
209
+ return selected;
210
+ }
211
+
212
+ /**
213
+ * Compute Jaccard similarity between two text strings.
214
+ * Uses word-level tokenization for efficiency.
215
+ *
216
+ * @param {string} a - First text
217
+ * @param {string} b - Second text
218
+ * @returns {number} Similarity score between 0 and 1
219
+ */
220
+ function jaccardSimilarity(a, b) {
221
+ const wordsA = new Set(a.toLowerCase().split(/\s+/));
222
+ const wordsB = new Set(b.toLowerCase().split(/\s+/));
223
+
224
+ let intersection = 0;
225
+ for (const word of wordsA) {
226
+ if (wordsB.has(word)) intersection++;
227
+ }
228
+
229
+ const union = wordsA.size + wordsB.size - intersection;
230
+ return union === 0 ? 0 : intersection / union;
231
+ }
232
+
233
+ /**
234
+ * Optimizes the retrieved context by walking the knowledge graph and compressing content to fit max_tokens.
235
+ *
236
+ * @param {string} queryText - User's query
237
+ * @param {number} maxTokens - Hard limit of tokens for context prompt
238
+ * @param {string|null} agentId - Querying agent identifier
239
+ * @param {string|null} sessionId - Current session ID
240
+ */
241
+ export async function getOptimizedContext(queryText, maxTokens, agentId = null, sessionId = null, namespace = null) {
242
+ // 1. Run hybrid search to fetch top 20 memories (namespace-aware)
243
+ const searchHits = await searchHybrid(queryText, 20, agentId, sessionId, namespace);
244
+ const candidates = new Map();
245
+
246
+ for (const hit of searchHits) {
247
+ candidates.set(hit.id, {
248
+ id: hit.id,
249
+ content: hit.content,
250
+ importance_score: hit.importance_score,
251
+ created_at: hit.created_at,
252
+ last_accessed: hit.last_accessed,
253
+ score: parseFloat(hit.hybrid_score),
254
+ provenance: hit.provenance,
255
+ source: 'search'
256
+ });
257
+
258
+ // 2. Perform Graph Hop (multi-hop traversal)
259
+ // Find all entities directly connected to this search hit memory
260
+ const hitEdges = db.prepare(`
261
+ SELECT * FROM edges
262
+ WHERE (source_id = ? AND source_type = 'memory' AND target_type = 'entity')
263
+ OR (target_id = ? AND target_type = 'memory' AND source_type = 'entity')
264
+ `).all(hit.id, hit.id);
265
+
266
+ const startEntityIds = new Set();
267
+ for (const edge of hitEdges) {
268
+ if (edge.source_type === 'entity') startEntityIds.add(edge.source_id);
269
+ if (edge.target_type === 'entity') startEntityIds.add(edge.target_id);
270
+ }
271
+
272
+ // BFS to find connected entities up to depth 2 (entity -> entity -> entity)
273
+ const visitedEntities = new Set(startEntityIds);
274
+ const queue = Array.from(startEntityIds).map(id => ({ id, depth: 0 }));
275
+
276
+ while (queue.length > 0) {
277
+ const { id, depth } = queue.shift();
278
+ if (depth >= 2) continue;
279
+
280
+ const connectedEdges = db.prepare(`
281
+ SELECT * FROM edges
282
+ WHERE (source_id = ? AND source_type = 'entity' AND target_type = 'entity')
283
+ OR (target_id = ? AND target_type = 'entity' AND source_type = 'entity')
284
+ `).all(id, id);
285
+
286
+ for (const edge of connectedEdges) {
287
+ const nextId = edge.source_id === id ? edge.target_id : edge.source_id;
288
+ if (!visitedEntities.has(nextId)) {
289
+ visitedEntities.add(nextId);
290
+ queue.push({ id: nextId, depth: depth + 1 });
291
+ }
292
+ }
293
+ }
294
+
295
+ // Now collect all memories connected to any of the traversed entities
296
+ for (const entId of visitedEntities) {
297
+ const otherMemories = getMemoriesByEntity(entId);
298
+ for (const other of otherMemories) {
299
+ if (other.id === hit.id) continue;
300
+ if (candidates.has(other.id)) continue;
301
+
302
+ const otherProv = getProvenance(other.id);
303
+ candidates.set(other.id, {
304
+ id: other.id,
305
+ content: other.content,
306
+ importance_score: other.importance_score,
307
+ created_at: other.created_at,
308
+ last_accessed: other.last_accessed,
309
+ score: parseFloat(hit.hybrid_score) * 0.5, // 50% graph-hop penalty
310
+ provenance: otherProv,
311
+ source: 'hop'
312
+ });
313
+ }
314
+ }
315
+ }
316
+
317
+ // 3. Apply Scoring Adjustments
318
+ const now = Math.floor(Date.now() / 1000);
319
+ const list = Array.from(candidates.values());
320
+
321
+ for (const c of list) {
322
+ // 3a. Temporal decay: score *= exp(-0.01 * hours_since_accessed)
323
+ const hours = Math.max(0, (now - c.last_accessed) / 3600);
324
+ c.score *= Math.exp(-0.01 * hours);
325
+
326
+ // 3b. Agent reputation weighting
327
+ let reputationScore = 1.0;
328
+ if (c.provenance && c.provenance.source_type === 'agent' && c.provenance.source_id) {
329
+ const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(c.provenance.source_id);
330
+ if (agentRow) {
331
+ reputationScore = agentRow.reputation_score;
332
+ }
333
+ }
334
+ c.score *= reputationScore;
335
+ }
336
+
337
+ // 4. Sort candidates
338
+ list.sort((a, b) => b.score - a.score);
339
+
340
+ // 5. Compress context to fit maxTokens
341
+ let currentTokens = 0;
342
+ const accepted = [];
343
+
344
+ for (const c of list) {
345
+ // Heuristic: ~4 characters per token + format headers (~15 tokens)
346
+ const estimatedTokens = Math.max(1, Math.ceil(c.content.length / 4) + 15);
347
+ if (currentTokens + estimatedTokens > maxTokens) {
348
+ continue;
349
+ }
350
+ currentTokens += estimatedTokens;
351
+ accepted.push(c);
352
+ }
353
+
354
+ // 6. Format LLM injection context string
355
+ let context = '=== RETRIEVED AGENT MEMORY CONTEXT ===\n';
356
+ if (accepted.length === 0) {
357
+ context += 'No relevant memories retrieved.\n';
358
+ } else {
359
+ for (const a of accepted) {
360
+ let sourceTag = 'Source: manual';
361
+ if (a.provenance) {
362
+ sourceTag = `Source: ${a.provenance.source_type}${a.provenance.source_id ? ` (${a.provenance.source_id})` : ''}`;
363
+ }
364
+ context += `[Memory #${a.id}] (Score: ${a.score.toFixed(4)}, ${sourceTag})\n${a.content}\n---\n`;
365
+ }
366
+ }
367
+ context += '=== END OF CONTEXT ===';
368
+
369
+ // Bug 8 fix: Skip attestation when no results to avoid audit noise
370
+ let attestation = null;
371
+ if (accepted.length > 0) {
372
+ attestation = createAttestation(queryText, accepted, agentId, sessionId);
373
+ }
374
+
375
+ return {
376
+ context,
377
+ memories: accepted,
378
+ attestation
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Analyze relationship between two similar memories based on token sets.
384
+ * @param {string} a - Content of memory A
385
+ * @param {string} b - Content of memory B
386
+ * @returns {{ type: 'duplicate'|'subset'|'contradiction'|'different', keep?: 'a'|'b'|'canonical' }}
387
+ */
388
+ function checkRelationship(a, b) {
389
+ const getWords = (text) => new Set(text.toLowerCase().split(/\s+/).map(w => w.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "")).filter(Boolean));
390
+ const wordsA = getWords(a);
391
+ const wordsB = getWords(b);
392
+
393
+ if (wordsA.size === 0 || wordsB.size === 0) return { type: 'duplicate', keep: 'a' };
394
+
395
+ let intersection = 0;
396
+ for (const w of wordsA) {
397
+ if (wordsB.has(w)) intersection++;
398
+ }
399
+
400
+ const overlapA = intersection / wordsA.size;
401
+ const overlapB = intersection / wordsB.size;
402
+
403
+ const union = wordsA.size + wordsB.size - intersection;
404
+ const jaccard = 1 - (intersection / union);
405
+
406
+ if (jaccard === 0) {
407
+ return { type: 'duplicate', keep: 'a' };
408
+ }
409
+
410
+ // Contradiction: similar topic, differing key terms
411
+ if (jaccard > 0.15 && jaccard < 0.5) {
412
+ return { type: 'contradiction' };
413
+ }
414
+
415
+ // Subset check
416
+ if (overlapA > 0.85 && wordsB.size > wordsA.size) {
417
+ return { type: 'subset', keep: 'b' };
418
+ }
419
+ if (overlapB > 0.85 && wordsA.size > wordsB.size) {
420
+ return { type: 'subset', keep: 'a' };
421
+ }
422
+
423
+ // Duplicate
424
+ if (jaccard < 0.25) {
425
+ return { type: 'duplicate', keep: 'canonical' };
426
+ }
427
+
428
+ return { type: 'different' };
429
+ }
430
+
431
+ /**
432
+ * Performs memory consolidation by merging highly similar memories.
433
+ * Bug 6 fix: DB mutations are wrapped in a transaction for atomicity.
434
+ */
435
+ export async function consolidateMemories(namespace = null) {
436
+ const query = namespace
437
+ ? "SELECT * FROM memories WHERE valid_until IS NULL AND (namespace = ? OR namespace = 'shared')"
438
+ : 'SELECT * FROM memories WHERE valid_until IS NULL';
439
+ const activeMemories = namespace
440
+ ? db.prepare(query).all(namespace)
441
+ : db.prepare(query).all();
442
+ const consolidated = [];
443
+ const visited = new Set();
444
+
445
+ for (const mem of activeMemories) {
446
+ if (visited.has(mem.id)) continue;
447
+
448
+ // Search for similar memories
449
+ const embedding = db.prepare('SELECT embedding FROM memories_vec WHERE rowid = ?').get(mem.id);
450
+ if (!embedding) continue;
451
+
452
+ const hits = db.prepare(`
453
+ SELECT rowid AS id, distance
454
+ FROM memories_vec
455
+ WHERE embedding MATCH ?
456
+ AND k = 10
457
+ `).all(embedding.embedding);
458
+
459
+ const group = [];
460
+ for (const hit of hits) {
461
+ if (visited.has(Number(hit.id))) continue;
462
+ const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
463
+ if (sim > 0.85) {
464
+ const other = db.prepare('SELECT * FROM memories WHERE id = ? AND valid_until IS NULL').get(Number(hit.id));
465
+ if (other) {
466
+ group.push(other);
467
+ }
468
+ }
469
+ }
470
+
471
+ if (group.length > 1) {
472
+ // Sort group by trust score (confidence * reputation) desc, then importance_score desc, then id desc
473
+ const getTrust = (m) => {
474
+ const prov = getProvenance(m.id);
475
+ let reputation = 1.0;
476
+ if (prov && prov.source_type === 'agent' && prov.source_id) {
477
+ const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(prov.source_id);
478
+ if (agentRow) reputation = agentRow.reputation_score;
479
+ }
480
+ return (prov ? prov.confidence : 1.0) * reputation;
481
+ };
482
+
483
+ const groupWithTrust = group.map(m => ({ ...m, trust: getTrust(m) }));
484
+ groupWithTrust.sort((a, b) => b.trust - a.trust || b.importance_score - a.importance_score || b.id - a.id);
485
+
486
+ // Resolve the group sequentially
487
+ let canonical = groupWithTrust[0];
488
+ const archivedIds = [];
489
+ visited.add(canonical.id);
490
+
491
+ for (let i = 1; i < groupWithTrust.length; i++) {
492
+ const current = groupWithTrust[i];
493
+ const rel = checkRelationship(canonical.content, current.content);
494
+
495
+ if (rel.type === 'contradiction') {
496
+ // Resolve contradiction: keep canonical, archive current
497
+ db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
498
+ db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
499
+ .run(current.id, canonical.id, `Consolidated contradiction: resolved in favor of canonical #${canonical.id}`);
500
+
501
+ // Apply reputation changes since it's a cross-agent contradiction
502
+ const oldProv = getProvenance(current.id);
503
+ const newProv = getProvenance(canonical.id);
504
+ if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
505
+ const isSelf = newProv && newProv.source_type === 'agent' && newProv.source_id === oldProv.source_id;
506
+ if (!isSelf) {
507
+ db.prepare('UPDATE agent_stats SET memories_contradicted = memories_contradicted + 1 WHERE agent_id = ?').run(oldProv.source_id);
508
+ db.prepare('UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?').run(oldProv.source_id);
509
+ if (newProv && newProv.source_type === 'agent') {
510
+ db.prepare('UPDATE agent_stats SET memories_confirmed = memories_confirmed + 1 WHERE agent_id = ?').run(newProv.source_id);
511
+ db.prepare('UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?').run(newProv.source_id);
512
+ }
513
+ }
514
+ }
515
+
516
+ archivedIds.push(current.id);
517
+ visited.add(current.id);
518
+ } else if (rel.type === 'subset') {
519
+ if (rel.keep === 'b') {
520
+ // current (B) is a superset of canonical (A). Swap them
521
+ db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(canonical.id);
522
+ db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
523
+ .run(canonical.id, current.id, `Consolidated subset: replaced by more detailed #${current.id}`);
524
+
525
+ archivedIds.push(canonical.id);
526
+ canonical = current;
527
+ } else {
528
+ // canonical is superset
529
+ db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
530
+ db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
531
+ .run(current.id, canonical.id, `Consolidated subset: subsumed by more detailed #${canonical.id}`);
532
+
533
+ archivedIds.push(current.id);
534
+ }
535
+ visited.add(current.id);
536
+ } else if (rel.type === 'duplicate') {
537
+ db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
538
+ db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
539
+ .run(current.id, canonical.id, `Consolidated duplicate of #${canonical.id}`);
540
+
541
+ archivedIds.push(current.id);
542
+ visited.add(current.id);
543
+ }
544
+ }
545
+
546
+ if (archivedIds.length > 0) {
547
+ consolidated.push({
548
+ canonical_id: canonical.id,
549
+ merged_content: canonical.content,
550
+ archived_ids: archivedIds
551
+ });
552
+ }
553
+ }
554
+ }
555
+
556
+ return {
557
+ success: true,
558
+ consolidated_groups: consolidated.length,
559
+ details: consolidated
560
+ };
561
+ }