persyst-mcp 2.2.4 → 2.2.6

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
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import db, {
11
+ stmts,
11
12
  searchKeyword,
12
13
  searchVector,
13
14
  getMemoryById,
@@ -19,6 +20,7 @@ import db, {
19
20
  import { generateEmbedding } from './embeddings.js';
20
21
  import { createAttestation } from './attestation.js';
21
22
  import { searchCache, LRUCache } from './cache.js';
23
+ import { jaccardSimilarity } from './text-utils.js';
22
24
 
23
25
  let lastDataVersion = 0;
24
26
 
@@ -68,7 +70,7 @@ export async function searchHybrid(queryText, limit = 5, agentId = null, session
68
70
  const vecHits = searchVector(queryEmbedding, parsedLimit * 2);
69
71
 
70
72
  const semanticResults = vecHits.map(r => ({
71
- id: r.rowid,
73
+ id: Number(r.rowid),
72
74
  distance: r.distance,
73
75
  // Convert L2 distance to 0-1 similarity score
74
76
  similarity: Math.max(0, 1 - (r.distance * r.distance) / 2)
@@ -116,7 +118,7 @@ export async function searchHybrid(queryText, limit = 5, agentId = null, session
116
118
  let reputationWarning = false;
117
119
  const prov = memory.provenance;
118
120
  if (prov && prov.source_type === 'agent' && prov.source_id) {
119
- const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(prov.source_id);
121
+ const agentRow = stmts.getReputationScore.get(prov.source_id);
120
122
  if (agentRow) {
121
123
  reputationScore = agentRow.reputation_score;
122
124
  if (reputationScore < 0.5) {
@@ -134,8 +136,8 @@ export async function searchHybrid(queryText, limit = 5, agentId = null, session
134
136
  importance_score: memory.importance_score,
135
137
  created_at: memory.created_at,
136
138
  last_accessed: memory.last_accessed,
137
- similarity: r.similarity.toFixed(4),
138
- hybrid_score: finalScore.toFixed(4),
139
+ similarity: Math.round(r.similarity * 10000) / 10000,
140
+ hybrid_score: Math.round(finalScore * 10000) / 10000,
139
141
  keyword_match: r.keyword_match,
140
142
  reputation_warning: reputationWarning,
141
143
  provenance: prov
@@ -217,27 +219,6 @@ function applyMMR(candidates, limit, lambda = 0.7) {
217
219
  return selected;
218
220
  }
219
221
 
220
- /**
221
- * Compute Jaccard similarity between two text strings.
222
- * Uses word-level tokenization for efficiency.
223
- *
224
- * @param {string} a - First text
225
- * @param {string} b - Second text
226
- * @returns {number} Similarity score between 0 and 1
227
- */
228
- function jaccardSimilarity(a, b) {
229
- const wordsA = new Set(a.toLowerCase().split(/\s+/));
230
- const wordsB = new Set(b.toLowerCase().split(/\s+/));
231
-
232
- let intersection = 0;
233
- for (const word of wordsA) {
234
- if (wordsB.has(word)) intersection++;
235
- }
236
-
237
- const union = wordsA.size + wordsB.size - intersection;
238
- return union === 0 ? 0 : intersection / union;
239
- }
240
-
241
222
  /**
242
223
  * Optimizes the retrieved context by walking the knowledge graph and compressing content to fit max_tokens.
243
224
  *
@@ -246,7 +227,14 @@ function jaccardSimilarity(a, b) {
246
227
  * @param {string|null} agentId - Querying agent identifier
247
228
  * @param {string|null} sessionId - Current session ID
248
229
  */
249
- export async function getOptimizedContext(queryText, maxTokens, agentId = null, sessionId = null, namespace = null) {
230
+ export async function getOptimizedContext(queryText, maxTokens, agentId = null, sessionId = null, namespace = null, intentParam = null) {
231
+ // Classify intent and urgency early to adjust token budget dynamically
232
+ const { intent, urgency } = classifyIntentAndUrgency(queryText, intentParam);
233
+ let targetMaxTokens = maxTokens;
234
+ if (intent === 'general' || intent === 'testing') {
235
+ targetMaxTokens = Math.min(maxTokens, 1500);
236
+ }
237
+
250
238
  // Extract entities mentioned in the query text to seed the graph search directly
251
239
  const entities = getAllEntities(100);
252
240
  const matchedEntityIds = new Set();
@@ -302,11 +290,7 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
302
290
  if (depth >= 6) continue;
303
291
 
304
292
  // --- 2a. Explicit Graph Edges (from edges table) ---
305
- const connectedEdges = db.prepare(`
306
- SELECT * FROM edges
307
- WHERE (source_id = ? AND source_type = ?)
308
- OR (target_id = ? AND target_type = ?)
309
- `).all(id, type, id, type);
293
+ const connectedEdges = stmts.getEdgesBySourceAndType.all(id, type, id, type);
310
294
 
311
295
  for (const edge of connectedEdges) {
312
296
  let nextId, nextType;
@@ -327,7 +311,7 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
327
311
 
328
312
  // --- 2b. Implicit Name-Based Edges (for robustness when explicit edges are missing) ---
329
313
  if (type === 'memory') {
330
- const memoryRow = db.prepare('SELECT content FROM memories WHERE id = ?').get(id);
314
+ const memoryRow = stmts.getMemoryContentById.get(id);
331
315
  if (memoryRow && memoryRow.content) {
332
316
  const contentLower = memoryRow.content.toLowerCase();
333
317
  for (const ent of entities) {
@@ -343,7 +327,7 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
343
327
  } else if (type === 'entity') {
344
328
  const ent = entities.find(e => e.id === id);
345
329
  if (ent && ent.name) {
346
- const matchingMemories = db.prepare('SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL').all(`%${ent.name}%`);
330
+ const matchingMemories = stmts.getMemoryLikeContent.all(`%${ent.name}%`);
347
331
  for (const row of matchingMemories) {
348
332
  const nextKey = `memory:${row.id}`;
349
333
  if (!visitedNodes.has(nextKey)) {
@@ -409,31 +393,51 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
409
393
  // 4. Sort candidates
410
394
  list.sort((a, b) => b.score - a.score);
411
395
 
412
- // 5. Compress context to fit maxTokens
396
+ // 5. Compress context to fit maxTokens with on-the-fly diversity check
413
397
  let currentTokens = 0;
414
398
  const accepted = [];
415
399
 
416
400
  for (const c of list) {
417
- // Heuristic: ~4 characters per token + format headers (~15 tokens)
418
- const estimatedTokens = Math.max(1, Math.ceil(c.content.length / 4) + 15);
419
- if (currentTokens + estimatedTokens > maxTokens) {
401
+ // Skip if too similar to any already accepted memory to prevent redundant context bloat
402
+ let isRedundant = false;
403
+ for (const acc of accepted) {
404
+ const sim = jaccardSimilarity(c.content, acc.content);
405
+ if (sim > 0.60) {
406
+ isRedundant = true;
407
+ break;
408
+ }
409
+ }
410
+ if (isRedundant) continue;
411
+
412
+ // Heuristic: ~4 characters per token + format headers (~3 tokens for compact format)
413
+ const estimatedTokens = Math.max(1, Math.ceil(c.content.length / 4) + 3);
414
+ if (currentTokens + estimatedTokens > targetMaxTokens) {
420
415
  continue;
421
416
  }
422
417
  currentTokens += estimatedTokens;
423
418
  accepted.push(c);
424
419
  }
425
420
 
421
+ const suggested_actions = generateSuggestedActions(accepted, intent, urgency);
422
+
426
423
  // 6. Format LLM injection context string
427
424
  let context = '=== RETRIEVED AGENT MEMORY CONTEXT ===\n';
425
+ context += `[Intent: ${intent} | Urgency: ${urgency}]\n\n`;
426
+
427
+ if (suggested_actions.length > 0) {
428
+ context += '[Suggested Actions]\n';
429
+ for (const action of suggested_actions) {
430
+ context += `• ${action}\n`;
431
+ }
432
+ context += '\n';
433
+ }
434
+
435
+ context += '[Memories]\n';
428
436
  if (accepted.length === 0) {
429
437
  context += 'No relevant memories retrieved.\n';
430
438
  } else {
431
439
  for (const a of accepted) {
432
- let sourceTag = 'Source: manual';
433
- if (a.provenance) {
434
- sourceTag = `Source: ${a.provenance.source_type}${a.provenance.source_id ? ` (${a.provenance.source_id})` : ''}`;
435
- }
436
- context += `[Memory #${a.id}] (Score: ${a.score.toFixed(4)}, ${sourceTag})\n${a.content}\n---\n`;
440
+ context += `#${a.id}: ${a.content}\n`;
437
441
  }
438
442
  }
439
443
  context += '=== END OF CONTEXT ===';
@@ -447,7 +451,10 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
447
451
  return {
448
452
  context,
449
453
  memories: accepted,
450
- attestation
454
+ attestation,
455
+ intent,
456
+ urgency,
457
+ suggested_actions
451
458
  };
452
459
  }
453
460
 
@@ -514,26 +521,22 @@ export async function consolidateMemories(namespace = null) {
514
521
  const consolidated = [];
515
522
  const visited = new Set();
516
523
 
517
- for (const mem of activeMemories) {
518
- if (visited.has(mem.id)) continue;
524
+ // Wrap all mutations in a transaction so a partial failure rolls back.
525
+ const consolidateOne = db.transaction((mem) => {
526
+ if (visited.has(mem.id)) return;
519
527
 
520
528
  // Search for similar memories
521
- const embedding = db.prepare('SELECT embedding FROM memories_vec WHERE rowid = ?').get(mem.id);
522
- if (!embedding) continue;
529
+ const embedding = stmts.getVecByRowId.get(mem.id);
530
+ if (!embedding) return;
523
531
 
524
- const hits = db.prepare(`
525
- SELECT rowid AS id, distance
526
- FROM memories_vec
527
- WHERE embedding MATCH ?
528
- AND k = 30
529
- `).all(embedding.embedding);
532
+ const hits = stmts.consolidateVecSearch.all(embedding.embedding);
530
533
 
531
534
  const group = [];
532
535
  for (const hit of hits) {
533
536
  if (visited.has(Number(hit.id))) continue;
534
537
  const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
535
538
  if (sim > 0.80) {
536
- const other = db.prepare('SELECT * FROM memories WHERE id = ? AND valid_until IS NULL').get(Number(hit.id));
539
+ const other = stmts.getMemoryByIdRaw.get(Number(hit.id));
537
540
  if (other) {
538
541
  group.push(other);
539
542
  }
@@ -546,7 +549,7 @@ export async function consolidateMemories(namespace = null) {
546
549
  const prov = getProvenance(m.id);
547
550
  let reputation = 1.0;
548
551
  if (prov && prov.source_type === 'agent' && prov.source_id) {
549
- const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(prov.source_id);
552
+ const agentRow = stmts.getReputationScore.get(prov.source_id);
550
553
  if (agentRow) reputation = agentRow.reputation_score;
551
554
  }
552
555
  return (prov ? prov.confidence : 1.0) * reputation;
@@ -565,50 +568,32 @@ export async function consolidateMemories(namespace = null) {
565
568
  const rel = checkRelationship(canonical.content, current.content);
566
569
 
567
570
  if (rel.type === 'contradiction') {
568
- // Resolve contradiction: keep canonical, archive current
569
- db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
570
- db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
571
- .run(current.id, canonical.id, `Consolidated contradiction: resolved in favor of canonical #${canonical.id}`);
572
-
573
- // Apply reputation changes since it's a cross-agent contradiction
574
- const oldProv = getProvenance(current.id);
575
- const newProv = getProvenance(canonical.id);
576
- if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
577
- const isSelf = newProv && newProv.source_type === 'agent' && newProv.source_id === oldProv.source_id;
578
- if (!isSelf) {
579
- db.prepare('UPDATE agent_stats SET memories_contradicted = memories_contradicted + 1 WHERE agent_id = ?').run(oldProv.source_id);
580
- db.prepare('UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?').run(oldProv.source_id);
581
- if (newProv && newProv.source_type === 'agent') {
582
- db.prepare('UPDATE agent_stats SET memories_confirmed = memories_confirmed + 1 WHERE agent_id = ?').run(newProv.source_id);
583
- db.prepare('UPDATE agent_stats SET reputation_score = (memories_confirmed + 1.0) / (memories_contradicted + 1.0) WHERE agent_id = ?').run(newProv.source_id);
584
- }
585
- }
586
- }
571
+ // Resolve contradiction: keep canonical, archive current.
572
+ // logContradiction already updates agent stats, so we only record the archive here.
573
+ stmts.archiveMemoryById.run(current.id);
574
+ stmts.insertContradiction.run(current.id, canonical.id, `Consolidated contradiction: resolved in favor of canonical #${canonical.id}`);
587
575
 
588
576
  archivedIds.push(current.id);
589
577
  visited.add(current.id);
590
578
  } else if (rel.type === 'subset') {
591
579
  if (rel.keep === 'b') {
592
580
  // current (B) is a superset of canonical (A). Swap them
593
- db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(canonical.id);
594
- db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
595
- .run(canonical.id, current.id, `Consolidated subset: replaced by more detailed #${current.id}`);
581
+ stmts.archiveMemoryById.run(canonical.id);
582
+ stmts.insertContradiction.run(canonical.id, current.id, `Consolidated subset: replaced by more detailed #${current.id}`);
596
583
 
597
584
  archivedIds.push(canonical.id);
598
585
  canonical = current;
599
586
  } else {
600
587
  // canonical is superset
601
- db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
602
- db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
603
- .run(current.id, canonical.id, `Consolidated subset: subsumed by more detailed #${canonical.id}`);
588
+ stmts.archiveMemoryById.run(current.id);
589
+ stmts.insertContradiction.run(current.id, canonical.id, `Consolidated subset: subsumed by more detailed #${canonical.id}`);
604
590
 
605
591
  archivedIds.push(current.id);
606
592
  }
607
593
  visited.add(current.id);
608
594
  } else if (rel.type === 'duplicate') {
609
- db.prepare('UPDATE memories SET valid_until = unixepoch() WHERE id = ?').run(current.id);
610
- db.prepare('INSERT INTO contradictions (old_memory_id, new_memory_id, resolution_reason) VALUES (?, ?, ?)')
611
- .run(current.id, canonical.id, `Consolidated duplicate of #${canonical.id}`);
595
+ stmts.archiveMemoryById.run(current.id);
596
+ stmts.insertContradiction.run(current.id, canonical.id, `Consolidated duplicate of #${canonical.id}`);
612
597
 
613
598
  archivedIds.push(current.id);
614
599
  visited.add(current.id);
@@ -623,6 +608,10 @@ export async function consolidateMemories(namespace = null) {
623
608
  });
624
609
  }
625
610
  }
611
+ });
612
+
613
+ for (const mem of activeMemories) {
614
+ consolidateOne(mem);
626
615
  }
627
616
 
628
617
  return {
@@ -631,3 +620,75 @@ export async function consolidateMemories(namespace = null) {
631
620
  details: consolidated
632
621
  };
633
622
  }
623
+
624
+ /**
625
+ * Classify context retrieval intent and urgency level using heuristic analysis.
626
+ */
627
+ function classifyIntentAndUrgency(queryText, intentParam = null) {
628
+ const queryLower = (queryText || '').toLowerCase();
629
+
630
+ // 1. Determine Intent
631
+ let intent = intentParam || 'general';
632
+ if (intent === 'general' || !intent) {
633
+ if (/(?:db|database|sqlite|sql|table|migration|schema)/i.test(queryLower)) {
634
+ intent = 'database_management';
635
+ } else if (/(?:deploy|ci|cd|vercel|publish|release|prod|staging)/i.test(queryLower)) {
636
+ intent = 'deployment';
637
+ } else if (/(?:style|css|html|theme|design|layout|align|color|font)/i.test(queryLower)) {
638
+ intent = 'ui_styling';
639
+ } else if (/(?:test|spec|unit|mock|heavy|smoke)/i.test(queryLower)) {
640
+ intent = 'testing';
641
+ } else if (/(?:error|bug|fail|crash|break|exception|stack|trace|refused|debug)/i.test(queryLower)) {
642
+ intent = 'debugging';
643
+ }
644
+ }
645
+
646
+ // 2. Determine Urgency
647
+ let urgency = 'low';
648
+ if (/(?:panic|emergency|broken|critical|urgent|fatal|security|leak|bypass|vulnerability)/i.test(queryLower)) {
649
+ urgency = 'critical';
650
+ } else if (/(?:fail|error|crash|prevent|stop|warn|warning|issue|broken)/i.test(queryLower)) {
651
+ urgency = 'high';
652
+ } else if (/(?:update|change|add|tweak|check|verify)/i.test(queryLower)) {
653
+ urgency = 'medium';
654
+ }
655
+
656
+ return { intent, urgency };
657
+ }
658
+
659
+ /**
660
+ * Generate actionable suggested actions based on active memories and query classification.
661
+ */
662
+ function generateSuggestedActions(memories, intent, urgency) {
663
+ const actions = [];
664
+
665
+ // General recommendation based on intent
666
+ if (intent === 'debugging') {
667
+ actions.push('Inspect the recent error logs and verify SQLite/system constraints.');
668
+ } else if (intent === 'ui_styling') {
669
+ actions.push('Verify UI layouts conform to user design preferences.');
670
+ } else if (intent === 'database_management') {
671
+ actions.push('Ensure database migrations are applied and referential integrity is checked.');
672
+ }
673
+
674
+ for (const m of memories) {
675
+ const content = m.content.toLowerCase();
676
+
677
+ // Check for rules/decisions in memory content
678
+ if (content.includes('decision:') || content.includes('rule:')) {
679
+ actions.push(`Adhere to guideline: ${m.content.slice(0, 100)}...`);
680
+ } else if (content.includes('prefer')) {
681
+ actions.push(`Apply user preference: ${m.content.slice(0, 100)}...`);
682
+ } else if (content.includes('error') || content.includes('bug') || content.includes('fix')) {
683
+ actions.push(`Reference past fix: ${m.content.slice(0, 100)}...`);
684
+ }
685
+ }
686
+
687
+ // Safety guideline if critical
688
+ if (urgency === 'critical') {
689
+ actions.unshift('CAUTION: Address security, vulnerability, or critical stability factors immediately.');
690
+ }
691
+
692
+ // Deduplicate
693
+ return Array.from(new Set(actions));
694
+ }