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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persyst-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Local-first MCP memory server with hybrid keyword + semantic search for coding agents",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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(m.hybrid_score || 0)
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
- const result = stmts.insertMemory.run(content, importance, namespace || 'shared');
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
- const source_id = provenanceInfo?.source_id || null;
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
- newProvenance.source_type === 'agent' &&
768
- newProvenance.source_id === oldProvenance.source_id;
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
- return stmts.getProvenance.get(memoryId) || null;
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
- stmts.upsertAgent.run(agentId);
814
+ const normalizedAgentId = agentId.toLowerCase();
815
+ stmts.upsertAgent.run(normalizedAgentId);
793
816
  if (action === 'created') {
794
- stmts.incrementCreated.run(agentId);
817
+ stmts.incrementCreated.run(normalizedAgentId);
795
818
  } else if (action === 'confirmed') {
796
- stmts.incrementConfirmed.run(agentId);
819
+ stmts.incrementConfirmed.run(normalizedAgentId);
797
820
  } else if (action === 'contradicted') {
798
- stmts.incrementContradicted.run(agentId);
821
+ stmts.incrementContradicted.run(normalizedAgentId);
799
822
  }
800
- stmts.recalculateReputation.run(agentId);
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 ancestors (replaced by current) using prepared statement
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) using prepared statement
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
- return rows;
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
- 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;
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
- // 1. Run hybrid search to fetch top 20 memories (namespace-aware)
243
- const searchHits = await searchHybrid(queryText, 20, agentId, sessionId, namespace);
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
- // 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);
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
- // 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
- }
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
- // 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
- });
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.85) {
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 || b.id - a.id);
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 (50,000 characters) */
56
- const MAX_MEMORY_CONTENT_LENGTH = 50000;
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(content, oldMemory.importance_score, {
309
- source_type: agent_id ? 'agent' : 'manual',
310
- source_id: agent_id || null,
311
- confidence: 1.0
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) {