persyst-mcp 2.2.0 → 2.2.1
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/package.json +1 -1
- package/src/attestation.js +7 -1
- package/src/database.js +63 -16
- package/src/search.js +97 -61
- package/src/tools.js +33 -7
package/package.json
CHANGED
package/src/attestation.js
CHANGED
|
@@ -60,10 +60,16 @@ export function createAttestation(query, memories, agentId = null, sessionId = n
|
|
|
60
60
|
// Map memories to {id, content_hash, score}
|
|
61
61
|
const memoriesRetrieved = memories.map(m => {
|
|
62
62
|
const contentHash = crypto.createHash('sha256').update(m.content).digest('hex');
|
|
63
|
+
let scoreVal = 0;
|
|
64
|
+
if (m.hybrid_score !== undefined && m.hybrid_score !== null) {
|
|
65
|
+
scoreVal = m.hybrid_score;
|
|
66
|
+
} else if (m.score !== undefined && m.score !== null) {
|
|
67
|
+
scoreVal = m.score;
|
|
68
|
+
}
|
|
63
69
|
return {
|
|
64
70
|
id: m.id,
|
|
65
71
|
content_hash: contentHash,
|
|
66
|
-
score: parseFloat(
|
|
72
|
+
score: parseFloat(scoreVal)
|
|
67
73
|
};
|
|
68
74
|
});
|
|
69
75
|
|
package/src/database.js
CHANGED
|
@@ -77,6 +77,11 @@ try {
|
|
|
77
77
|
db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
|
|
78
78
|
} catch (e) { /* Column already exists */ }
|
|
79
79
|
|
|
80
|
+
// --- Migration: add parent_id column for history tracing ---
|
|
81
|
+
try {
|
|
82
|
+
db.exec('ALTER TABLE memories ADD COLUMN parent_id INTEGER DEFAULT NULL');
|
|
83
|
+
} catch (e) { /* Column already exists */ }
|
|
84
|
+
|
|
80
85
|
// --- Index on namespace for fast filtered queries ---
|
|
81
86
|
try {
|
|
82
87
|
db.exec('CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories (namespace)');
|
|
@@ -226,7 +231,7 @@ console.error('[persyst] Schema initialized ✓');
|
|
|
226
231
|
const stmts = {
|
|
227
232
|
// -- Insert --
|
|
228
233
|
insertMemory: db.prepare(
|
|
229
|
-
'INSERT INTO memories (content, importance_score, namespace) VALUES (?, ?, ?)'
|
|
234
|
+
'INSERT INTO memories (content, importance_score, namespace, parent_id) VALUES (?, ?, ?, ?)'
|
|
230
235
|
),
|
|
231
236
|
insertVec: db.prepare(
|
|
232
237
|
'INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)'
|
|
@@ -434,13 +439,19 @@ const stmts = {
|
|
|
434
439
|
* @param {string} namespace - Namespace for agent isolation (default: 'shared')
|
|
435
440
|
* @returns {number} The new memory's ID
|
|
436
441
|
*/
|
|
437
|
-
export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared') {
|
|
438
|
-
|
|
442
|
+
export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared', parentId = null) {
|
|
443
|
+
if (content && content.length > 10000) {
|
|
444
|
+
throw new Error('Memory content exceeds maximum length of 10000 characters.');
|
|
445
|
+
}
|
|
446
|
+
const result = stmts.insertMemory.run(content, importance, namespace || 'shared', parentId);
|
|
439
447
|
const id = Number(result.lastInsertRowid);
|
|
440
448
|
|
|
441
449
|
// Provenance Info handling
|
|
442
450
|
const source_type = provenanceInfo?.source_type || 'manual';
|
|
443
|
-
|
|
451
|
+
let source_id = provenanceInfo?.source_id || null;
|
|
452
|
+
if (source_type === 'agent' && source_id) {
|
|
453
|
+
source_id = source_id.toLowerCase();
|
|
454
|
+
}
|
|
444
455
|
const confidence = provenanceInfo?.confidence !== undefined ? provenanceInfo.confidence : 1.0;
|
|
445
456
|
|
|
446
457
|
stmts.insertProvenance.run(id, source_type, source_id, confidence);
|
|
@@ -758,14 +769,21 @@ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
|
|
|
758
769
|
stmts.archiveMemory.run(oldMemoryId);
|
|
759
770
|
stmts.insertContradiction.run(oldMemoryId, newMemoryId, reason);
|
|
760
771
|
|
|
772
|
+
// Set parent_id to link memories for bidirectional history tracing
|
|
773
|
+
try {
|
|
774
|
+
db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(oldMemoryId, newMemoryId);
|
|
775
|
+
} catch (e) {
|
|
776
|
+
console.error(`[persyst] Failed to set parent_id on contradiction: ${e.message}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
761
779
|
// Retrieve provenance of both versions for game-theoretic reputation calculation
|
|
762
780
|
const oldProvenance = getProvenance(oldMemoryId);
|
|
763
781
|
const newProvenance = getProvenance(newMemoryId);
|
|
764
782
|
|
|
765
783
|
if (oldProvenance && oldProvenance.source_type === 'agent' && oldProvenance.source_id) {
|
|
766
|
-
const isSelfCorrection = newProvenance &&
|
|
767
|
-
|
|
768
|
-
|
|
784
|
+
const isSelfCorrection = (newProvenance && newProvenance.source_id &&
|
|
785
|
+
newProvenance.source_id.toLowerCase() === oldProvenance.source_id.toLowerCase()) ||
|
|
786
|
+
reason.includes('update_memory');
|
|
769
787
|
if (!isSelfCorrection) {
|
|
770
788
|
// Different agent/manual source contradicts the old memory
|
|
771
789
|
incrementAgentStat(oldProvenance.source_id, 'contradicted');
|
|
@@ -782,22 +800,27 @@ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
|
|
|
782
800
|
* Get provenance for a memory.
|
|
783
801
|
*/
|
|
784
802
|
export function getProvenance(memoryId) {
|
|
785
|
-
|
|
803
|
+
const prov = stmts.getProvenance.get(memoryId) || null;
|
|
804
|
+
if (prov && prov.source_type === 'agent' && prov.source_id) {
|
|
805
|
+
prov.source_id = prov.source_id.toLowerCase();
|
|
806
|
+
}
|
|
807
|
+
return prov;
|
|
786
808
|
}
|
|
787
809
|
|
|
788
810
|
/**
|
|
789
811
|
* Update agent reputation counters.
|
|
790
812
|
*/
|
|
791
813
|
export function incrementAgentStat(agentId, action) {
|
|
792
|
-
|
|
814
|
+
const normalizedAgentId = agentId.toLowerCase();
|
|
815
|
+
stmts.upsertAgent.run(normalizedAgentId);
|
|
793
816
|
if (action === 'created') {
|
|
794
|
-
stmts.incrementCreated.run(
|
|
817
|
+
stmts.incrementCreated.run(normalizedAgentId);
|
|
795
818
|
} else if (action === 'confirmed') {
|
|
796
|
-
stmts.incrementConfirmed.run(
|
|
819
|
+
stmts.incrementConfirmed.run(normalizedAgentId);
|
|
797
820
|
} else if (action === 'contradicted') {
|
|
798
|
-
stmts.incrementContradicted.run(
|
|
821
|
+
stmts.incrementContradicted.run(normalizedAgentId);
|
|
799
822
|
}
|
|
800
|
-
stmts.recalculateReputation.run(
|
|
823
|
+
stmts.recalculateReputation.run(normalizedAgentId);
|
|
801
824
|
}
|
|
802
825
|
|
|
803
826
|
/**
|
|
@@ -857,13 +880,25 @@ export function getMemoryHistoryChain(memoryId) {
|
|
|
857
880
|
if (versions.has(currentId)) continue;
|
|
858
881
|
versions.add(currentId);
|
|
859
882
|
|
|
860
|
-
// Find
|
|
883
|
+
// 1. Find parent (ancestor) from memories table
|
|
884
|
+
const row = db.prepare('SELECT parent_id FROM memories WHERE id = ?').get(currentId);
|
|
885
|
+
if (row && row.parent_id !== null) {
|
|
886
|
+
if (!versions.has(row.parent_id)) queue.push(row.parent_id);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// 2. Find children (descendants) from memories table
|
|
890
|
+
const children = db.prepare('SELECT id FROM memories WHERE parent_id = ?').all(currentId);
|
|
891
|
+
for (const child of children) {
|
|
892
|
+
if (!versions.has(child.id)) queue.push(child.id);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// 3. Fallback: Find ancestors (replaced by current) from contradictions table
|
|
861
896
|
const ancestors = stmts.getContradictionAncestors.all(currentId);
|
|
862
897
|
ancestors.forEach(a => {
|
|
863
898
|
if (!versions.has(a.old_memory_id)) queue.push(a.old_memory_id);
|
|
864
899
|
});
|
|
865
900
|
|
|
866
|
-
// Find descendants (replaces current)
|
|
901
|
+
// 4. Fallback: Find descendants (replaces current) from contradictions table
|
|
867
902
|
const descendants = stmts.getContradictionDescendants.all(currentId);
|
|
868
903
|
descendants.forEach(d => {
|
|
869
904
|
if (!versions.has(d.new_memory_id)) queue.push(d.new_memory_id);
|
|
@@ -882,7 +917,19 @@ export function getMemoryHistoryChain(memoryId) {
|
|
|
882
917
|
ORDER BY m.created_at ASC
|
|
883
918
|
`).all(...ids);
|
|
884
919
|
|
|
885
|
-
|
|
920
|
+
const uniqueRows = [];
|
|
921
|
+
const seenIds = new Set();
|
|
922
|
+
for (const row of rows) {
|
|
923
|
+
if (row && !seenIds.has(row.id)) {
|
|
924
|
+
seenIds.add(row.id);
|
|
925
|
+
if (row.source_type === 'agent' && row.source_id) {
|
|
926
|
+
row.source_id = row.source_id.toLowerCase();
|
|
927
|
+
}
|
|
928
|
+
uniqueRows.push(row);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return uniqueRows;
|
|
886
933
|
}
|
|
887
934
|
|
|
888
935
|
/**
|
package/src/search.js
CHANGED
|
@@ -13,7 +13,8 @@ import db, {
|
|
|
13
13
|
getMemoryById,
|
|
14
14
|
boostMemory,
|
|
15
15
|
getProvenance,
|
|
16
|
-
getMemoriesByEntity
|
|
16
|
+
getMemoriesByEntity,
|
|
17
|
+
getAllEntities
|
|
17
18
|
} from './database.js';
|
|
18
19
|
import { generateEmbedding } from './embeddings.js';
|
|
19
20
|
import { createAttestation } from './attestation.js';
|
|
@@ -31,7 +32,7 @@ let lastDataVersion = 0;
|
|
|
31
32
|
* @param {string|null} sessionId - Session identifier
|
|
32
33
|
* @returns {Promise<Array>} Ranked search results (with .attestation property attached)
|
|
33
34
|
*/
|
|
34
|
-
export async function searchHybrid(queryText, limit = 5, agentId = null, sessionId = null, namespace = null) {
|
|
35
|
+
export async function searchHybrid(queryText, limit = 5, agentId = null, sessionId = null, namespace = null, skipAttestation = false) {
|
|
35
36
|
// Sync in-memory cache with external DB changes using sqlite data_version
|
|
36
37
|
try {
|
|
37
38
|
const currentDataVersion = db.pragma('data_version', { simple: true });
|
|
@@ -142,11 +143,12 @@ export async function searchHybrid(queryText, limit = 5, agentId = null, session
|
|
|
142
143
|
// --- Step 5: Apply MMR for diverse retrieval (Feature 3) ---
|
|
143
144
|
const mmrResults = applyMMR(finalResults, limit);
|
|
144
145
|
|
|
145
|
-
// Generate cryptographic attestation for audit trails
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
// Generate cryptographic attestation for audit trails (skip if called internally)
|
|
147
|
+
let attestation = null;
|
|
148
|
+
if (!skipAttestation) {
|
|
149
|
+
attestation = createAttestation(queryText, mmrResults, agentId, sessionId);
|
|
150
|
+
mmrResults.attestation = attestation;
|
|
151
|
+
}
|
|
150
152
|
|
|
151
153
|
// --- Store in LRU cache (Feature 1) ---
|
|
152
154
|
searchCache.set(cacheKey, mmrResults);
|
|
@@ -239,8 +241,18 @@ function jaccardSimilarity(a, b) {
|
|
|
239
241
|
* @param {string|null} sessionId - Current session ID
|
|
240
242
|
*/
|
|
241
243
|
export async function getOptimizedContext(queryText, maxTokens, agentId = null, sessionId = null, namespace = null) {
|
|
242
|
-
//
|
|
243
|
-
const
|
|
244
|
+
// Extract entities mentioned in the query text to seed the graph search directly
|
|
245
|
+
const entities = getAllEntities(100);
|
|
246
|
+
const matchedEntityIds = new Set();
|
|
247
|
+
for (const ent of entities) {
|
|
248
|
+
const entNameLower = ent.name.toLowerCase();
|
|
249
|
+
if (queryText.toLowerCase().includes(entNameLower)) {
|
|
250
|
+
matchedEntityIds.add(ent.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 1. Run hybrid search to fetch top 5 memories as seeds (skip attestation to avoid double-write)
|
|
255
|
+
const searchHits = await searchHybrid(queryText, 5, agentId, sessionId, namespace, true);
|
|
244
256
|
const candidates = new Map();
|
|
245
257
|
|
|
246
258
|
for (const hit of searchHits) {
|
|
@@ -254,63 +266,87 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
|
|
|
254
266
|
provenance: hit.provenance,
|
|
255
267
|
source: 'search'
|
|
256
268
|
});
|
|
269
|
+
}
|
|
257
270
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
`
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (edge.source_type === 'entity') startEntityIds.add(edge.source_id);
|
|
269
|
-
if (edge.target_type === 'entity') startEntityIds.add(edge.target_id);
|
|
271
|
+
// 2. Perform Graph Hop (multi-hop traversal) globally
|
|
272
|
+
const hopQueue = [];
|
|
273
|
+
const visitedNodes = new Set(); // Stores "type:id" keys
|
|
274
|
+
|
|
275
|
+
// Seed with matched entities from query text
|
|
276
|
+
for (const entId of matchedEntityIds) {
|
|
277
|
+
const key = `entity:${entId}`;
|
|
278
|
+
if (!visitedNodes.has(key)) {
|
|
279
|
+
visitedNodes.add(key);
|
|
280
|
+
hopQueue.push({ id: entId, type: 'entity', depth: 0 });
|
|
270
281
|
}
|
|
282
|
+
}
|
|
271
283
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
284
|
+
// Seed with search hit memories
|
|
285
|
+
for (const hit of searchHits) {
|
|
286
|
+
const key = `memory:${hit.id}`;
|
|
287
|
+
if (!visitedNodes.has(key)) {
|
|
288
|
+
visitedNodes.add(key);
|
|
289
|
+
hopQueue.push({ id: hit.id, type: 'memory', depth: 0 });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// BFS to traverse memories and entities uniformly up to depth 4
|
|
294
|
+
while (hopQueue.length > 0) {
|
|
295
|
+
const { id, type, depth } = hopQueue.shift();
|
|
296
|
+
if (depth >= 4) continue;
|
|
297
|
+
|
|
298
|
+
const connectedEdges = db.prepare(`
|
|
299
|
+
SELECT * FROM edges
|
|
300
|
+
WHERE (source_id = ? AND source_type = ?)
|
|
301
|
+
OR (target_id = ? AND target_type = ?)
|
|
302
|
+
`).all(id, type, id, type);
|
|
303
|
+
|
|
304
|
+
for (const edge of connectedEdges) {
|
|
305
|
+
let nextId, nextType;
|
|
306
|
+
if (edge.source_id === id && edge.source_type === type) {
|
|
307
|
+
nextId = edge.target_id;
|
|
308
|
+
nextType = edge.target_type;
|
|
309
|
+
} else {
|
|
310
|
+
nextId = edge.source_id;
|
|
311
|
+
nextType = edge.source_type;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const key = `${nextType}:${nextId}`;
|
|
315
|
+
if (!visitedNodes.has(key)) {
|
|
316
|
+
visitedNodes.add(key);
|
|
317
|
+
hopQueue.push({ id: nextId, type: nextType, depth: depth + 1 });
|
|
292
318
|
}
|
|
293
319
|
}
|
|
320
|
+
}
|
|
294
321
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
provenance: otherProv,
|
|
311
|
-
source: 'hop'
|
|
312
|
-
});
|
|
322
|
+
// Now collect all hopped memories from the visited nodes
|
|
323
|
+
for (const key of visitedNodes) {
|
|
324
|
+
const [type, idStr] = key.split(':');
|
|
325
|
+
if (type === 'memory') {
|
|
326
|
+
const memId = Number(idStr);
|
|
327
|
+
if (candidates.has(memId)) continue; // Keep search hit info
|
|
328
|
+
|
|
329
|
+
// Check namespace filter if present
|
|
330
|
+
const other = getMemoryById(memId, namespace);
|
|
331
|
+
if (!other) continue;
|
|
332
|
+
|
|
333
|
+
let baseScore = 0.4;
|
|
334
|
+
if (searchHits.length > 0) {
|
|
335
|
+
const maxSearchScore = Math.max(...searchHits.map(h => parseFloat(h.hybrid_score)));
|
|
336
|
+
baseScore = maxSearchScore * 0.5;
|
|
313
337
|
}
|
|
338
|
+
|
|
339
|
+
const otherProv = getProvenance(memId);
|
|
340
|
+
candidates.set(memId, {
|
|
341
|
+
id: other.id,
|
|
342
|
+
content: other.content,
|
|
343
|
+
importance_score: other.importance_score,
|
|
344
|
+
created_at: other.created_at,
|
|
345
|
+
last_accessed: other.last_accessed,
|
|
346
|
+
score: baseScore,
|
|
347
|
+
provenance: otherProv,
|
|
348
|
+
source: 'hop'
|
|
349
|
+
});
|
|
314
350
|
}
|
|
315
351
|
}
|
|
316
352
|
|
|
@@ -460,7 +496,7 @@ export async function consolidateMemories(namespace = null) {
|
|
|
460
496
|
for (const hit of hits) {
|
|
461
497
|
if (visited.has(Number(hit.id))) continue;
|
|
462
498
|
const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
|
|
463
|
-
if (sim > 0.
|
|
499
|
+
if (sim > 0.80) {
|
|
464
500
|
const other = db.prepare('SELECT * FROM memories WHERE id = ? AND valid_until IS NULL').get(Number(hit.id));
|
|
465
501
|
if (other) {
|
|
466
502
|
group.push(other);
|
|
@@ -481,7 +517,7 @@ export async function consolidateMemories(namespace = null) {
|
|
|
481
517
|
};
|
|
482
518
|
|
|
483
519
|
const groupWithTrust = group.map(m => ({ ...m, trust: getTrust(m) }));
|
|
484
|
-
groupWithTrust.sort((a, b) => b.trust - a.trust || b.importance_score - a.importance_score ||
|
|
520
|
+
groupWithTrust.sort((a, b) => b.trust - a.trust || b.importance_score - a.importance_score || a.id - b.id);
|
|
485
521
|
|
|
486
522
|
// Resolve the group sequentially
|
|
487
523
|
let canonical = groupWithTrust[0];
|
package/src/tools.js
CHANGED
|
@@ -52,8 +52,8 @@ import { searchCache } from './cache.js';
|
|
|
52
52
|
// CONSTANTS
|
|
53
53
|
// ============================================================
|
|
54
54
|
|
|
55
|
-
/** Maximum allowed memory content length (
|
|
56
|
-
const MAX_MEMORY_CONTENT_LENGTH =
|
|
55
|
+
/** Maximum allowed memory content length (10,000 characters) */
|
|
56
|
+
const MAX_MEMORY_CONTENT_LENGTH = 10000;
|
|
57
57
|
|
|
58
58
|
/** Minimum content length (must have actual content) */
|
|
59
59
|
const MIN_MEMORY_CONTENT_LENGTH = 1;
|
|
@@ -304,12 +304,22 @@ export function registerTools(server) {
|
|
|
304
304
|
const oldMemory = getMemory(id);
|
|
305
305
|
if (!oldMemory) return text({ error: `Memory #${id} not found` });
|
|
306
306
|
|
|
307
|
+
// Retrieve old agent_id from provenance
|
|
308
|
+
const oldProv = getProvenance(id);
|
|
309
|
+
const resolvedAgentId = agent_id || (oldProv && oldProv.source_type === 'agent' ? oldProv.source_id : null);
|
|
310
|
+
|
|
307
311
|
// Insert new version
|
|
308
|
-
const newId = insertMemory(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
const newId = insertMemory(
|
|
313
|
+
content,
|
|
314
|
+
oldMemory.importance_score,
|
|
315
|
+
{
|
|
316
|
+
source_type: resolvedAgentId ? 'agent' : 'manual',
|
|
317
|
+
source_id: resolvedAgentId,
|
|
318
|
+
confidence: 1.0
|
|
319
|
+
},
|
|
320
|
+
oldMemory.namespace || 'shared',
|
|
321
|
+
id
|
|
322
|
+
);
|
|
313
323
|
|
|
314
324
|
const embedding = await generateEmbedding(content);
|
|
315
325
|
insertVector(newId, embedding);
|
|
@@ -545,13 +555,29 @@ export function registerTools(server) {
|
|
|
545
555
|
hits = searchAllMemoriesFts(query, 5);
|
|
546
556
|
}
|
|
547
557
|
|
|
558
|
+
// Fallback to LIKE query on memories content if FTS is empty or fails
|
|
559
|
+
if (hits.length === 0) {
|
|
560
|
+
try {
|
|
561
|
+
const likeRows = db.prepare("SELECT id FROM memories WHERE content LIKE ? LIMIT 5").all(`%${query}%`);
|
|
562
|
+
hits = likeRows;
|
|
563
|
+
} catch (_) {}
|
|
564
|
+
}
|
|
565
|
+
|
|
548
566
|
if (hits.length === 0) {
|
|
549
567
|
return text({ message: 'No memories matching query found.' });
|
|
550
568
|
}
|
|
551
569
|
|
|
552
570
|
const histories = {};
|
|
571
|
+
const seenChainKeys = new Set();
|
|
553
572
|
for (const hit of hits) {
|
|
554
573
|
const chain = getMemoryHistoryChain(hit.id);
|
|
574
|
+
if (chain.length === 0) continue;
|
|
575
|
+
|
|
576
|
+
// Deduplicate chains to prevent duplicate entries in history response
|
|
577
|
+
const chainKey = chain.map(c => c.id).sort((a, b) => a - b).join(',');
|
|
578
|
+
if (seenChainKeys.has(chainKey)) continue;
|
|
579
|
+
seenChainKeys.add(chainKey);
|
|
580
|
+
|
|
555
581
|
// Decorate chain versions with semantic diffs from the previous version
|
|
556
582
|
for (let idx = 0; idx < chain.length; idx++) {
|
|
557
583
|
if (idx > 0) {
|