persyst-mcp 1.1.0 → 2.1.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/tools.js CHANGED
@@ -2,6 +2,13 @@
2
2
  * tools.js — MCP Tool Definitions & Handlers
3
3
  *
4
4
  * Defines all 19 tools that AI agents can call via MCP.
5
+ *
6
+ * v2.0 changes:
7
+ * - Bug 1: Uses memoryExistsByHashPrefix for git dedup
8
+ * - Bug 3: Exports cleanupWatchers for graceful shutdown
9
+ * - Bug 7 + Feature 4: Memory content size validation
10
+ * - Feature 1: Cache invalidation on write operations
11
+ * - Feature 2: Contradiction detection on add_memory
5
12
  */
6
13
 
7
14
  import { z } from 'zod';
@@ -21,6 +28,7 @@ import {
21
28
  getMemoriesByEntity,
22
29
  getAllEntities,
23
30
  memoryExists,
31
+ memoryExistsByHashPrefix,
24
32
  getMemoryByContent,
25
33
  boostMemory,
26
34
  logContradiction,
@@ -28,15 +36,67 @@ import {
28
36
  getAttestationsByDateRange,
29
37
  getMemoryHistoryChain,
30
38
  searchAllMemoriesFts,
31
- getAnyMemoryById
39
+ getAnyMemoryById,
40
+ searchVector,
41
+ getMemoryById,
42
+ getActiveMemoryCount
32
43
  } from './database.js';
33
44
  import { searchHybrid, getOptimizedContext, consolidateMemories } from './search.js';
34
45
  import { getRecentCommits } from './git.js';
35
46
  import { verifyChainIntegrity } from './attestation.js';
47
+ import { searchCache } from './cache.js';
48
+
49
+ // ============================================================
50
+ // CONSTANTS
51
+ // ============================================================
52
+
53
+ /** Maximum allowed memory content length (50,000 characters) */
54
+ const MAX_MEMORY_CONTENT_LENGTH = 50000;
55
+
56
+ /** Minimum content length (must have actual content) */
57
+ const MIN_MEMORY_CONTENT_LENGTH = 1;
58
+
59
+ // ============================================================
60
+ // WATCHER REGISTRY
61
+ // ============================================================
36
62
 
37
63
  // In-memory registry of active git watchers
38
64
  const watchers = new Map();
39
65
 
66
+ /**
67
+ * Clean up all active git watchers. Called during graceful shutdown.
68
+ * (Bug 3 fix: prevents memory leak from orphaned setInterval handles)
69
+ */
70
+ export function cleanupWatchers() {
71
+ for (const [repoPath, intervalId] of watchers.entries()) {
72
+ clearInterval(intervalId);
73
+ console.error(`[persyst-watcher] Stopped watching: ${repoPath}`);
74
+ }
75
+ watchers.clear();
76
+ }
77
+
78
+ // ============================================================
79
+ // VALIDATION HELPERS
80
+ // ============================================================
81
+
82
+ /**
83
+ * Validate memory content for size and emptiness.
84
+ * @param {string} content - The content to validate
85
+ * @returns {{ valid: boolean, error?: string }} Validation result
86
+ */
87
+ function validateMemoryContent(content) {
88
+ if (!content || content.trim().length < MIN_MEMORY_CONTENT_LENGTH) {
89
+ return { valid: false, error: 'Memory content cannot be empty or whitespace-only.' };
90
+ }
91
+ if (content.length > MAX_MEMORY_CONTENT_LENGTH) {
92
+ return {
93
+ valid: false,
94
+ error: `Memory content exceeds maximum length of ${MAX_MEMORY_CONTENT_LENGTH} characters (got ${content.length}). Please split into smaller memories.`
95
+ };
96
+ }
97
+ return { valid: true };
98
+ }
99
+
40
100
  /**
41
101
  * Register all MCP tools on the server.
42
102
  * @param {McpServer} server - The MCP server instance
@@ -66,6 +126,13 @@ export function registerTools(server) {
66
126
  },
67
127
  async ({ content, importance, agent_id, session_id }) => {
68
128
  try {
129
+ // Bug 7 + Feature 4: Validate content size
130
+ const validation = validateMemoryContent(content);
131
+ if (!validation.valid) {
132
+ return text({ error: validation.error });
133
+ }
134
+
135
+ // Deduplication check
69
136
  const existing = getMemoryByContent(content);
70
137
  if (existing) {
71
138
  boostMemory(existing.id);
@@ -85,7 +152,48 @@ export function registerTools(server) {
85
152
  const embedding = await generateEmbedding(content);
86
153
  insertVector(id, embedding);
87
154
 
88
- return text({ success: true, id, message: `Memory #${id} stored` });
155
+ // Feature 1: Invalidate search cache on write
156
+ searchCache.invalidate();
157
+
158
+ // Feature 2: Contradiction Detection
159
+ let contradictions = [];
160
+ try {
161
+ const similarHits = searchVector(embedding, 3);
162
+ for (const hit of similarHits) {
163
+ const hitId = Number(hit.rowid);
164
+ if (hitId === id) continue; // Skip self
165
+
166
+ const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
167
+ if (sim > 0.75) {
168
+ const existingMemory = getMemoryById(hitId);
169
+ if (!existingMemory) continue;
170
+
171
+ // Check if content is substantially different (Jaccard distance > 0.5)
172
+ const jaccard = jaccardDistance(content, existingMemory.content);
173
+ if (jaccard > 0.5) {
174
+ // This is a contradiction: similar topic, different content
175
+ logContradiction(hitId, id, `Auto-detected contradiction (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
176
+ contradictions.push({
177
+ old_memory_id: hitId,
178
+ old_content_preview: existingMemory.content.slice(0, 100),
179
+ similarity: sim.toFixed(4),
180
+ content_difference: jaccard.toFixed(4)
181
+ });
182
+ }
183
+ }
184
+ }
185
+ } catch (e) {
186
+ // Contradiction detection is best-effort, don't fail the memory insertion
187
+ console.error(`[persyst] Contradiction detection error: ${e.message}`);
188
+ }
189
+
190
+ const result = { success: true, id, message: `Memory #${id} stored` };
191
+ if (contradictions.length > 0) {
192
+ result.contradictions_detected = contradictions;
193
+ result.message += `. Detected ${contradictions.length} contradiction(s) — older memories archived.`;
194
+ }
195
+
196
+ return text(result);
89
197
  } catch (err) {
90
198
  return text({ error: err.message });
91
199
  }
@@ -145,6 +253,12 @@ export function registerTools(server) {
145
253
  },
146
254
  async ({ id, content, agent_id }) => {
147
255
  try {
256
+ // Bug 7 + Feature 4: Validate content size
257
+ const validation = validateMemoryContent(content);
258
+ if (!validation.valid) {
259
+ return text({ error: validation.error });
260
+ }
261
+
148
262
  const oldMemory = getMemory(id);
149
263
  if (!oldMemory) return text({ error: `Memory #${id} not found` });
150
264
 
@@ -161,6 +275,9 @@ export function registerTools(server) {
161
275
  // Record contradiction and archive the old one
162
276
  logContradiction(id, newId, 'Content updated via update_memory');
163
277
 
278
+ // Feature 1: Invalidate search cache on write
279
+ searchCache.invalidate();
280
+
164
281
  return text({
165
282
  success: true,
166
283
  id: newId,
@@ -183,6 +300,10 @@ export function registerTools(server) {
183
300
  try {
184
301
  const deleted = deleteMemory(id);
185
302
  if (!deleted) return text({ error: `Memory #${id} not found` });
303
+
304
+ // Feature 1: Invalidate search cache on write
305
+ searchCache.invalidate();
306
+
186
307
  return text({ success: true, id, message: `Memory #${id} deleted` });
187
308
  } catch (err) {
188
309
  return text({ error: err.message });
@@ -234,13 +355,14 @@ export function registerTools(server) {
234
355
  },
235
356
  async ({ repo_path, count }) => {
236
357
  try {
237
- const commits = getRecentCommits(repo_path, count);
358
+ const commits = await getRecentCommits(repo_path, count);
238
359
  let added = 0;
239
360
  let skipped = 0;
240
361
 
241
362
  for (const commit of commits) {
242
363
  const hashPrefix = commit.hash.slice(0, 7);
243
- if (memoryExists(`[${hashPrefix}]%`)) {
364
+ // Bug 1 fix: use LIKE-based query for hash prefix matching
365
+ if (memoryExistsByHashPrefix(`[${hashPrefix}]%`)) {
244
366
  skipped++;
245
367
  continue;
246
368
  }
@@ -272,6 +394,9 @@ export function registerTools(server) {
272
394
  added++;
273
395
  }
274
396
 
397
+ // Feature 1: Invalidate search cache after git ingestion
398
+ if (added > 0) searchCache.invalidate();
399
+
275
400
  return text({
276
401
  success: true,
277
402
  added,
@@ -465,11 +590,12 @@ export function registerTools(server) {
465
590
  const intervalId = setInterval(async () => {
466
591
  console.error(`[persyst-watcher] Running scheduled ingestion for: ${repo_path}`);
467
592
  try {
468
- const result = getRecentCommits(repo_path, 10);
593
+ const result = await getRecentCommits(repo_path, 10);
469
594
  let added = 0;
470
595
  for (const commit of result) {
471
596
  const hashPrefix = commit.hash.slice(0, 7);
472
- if (memoryExists(`[${hashPrefix}]%`)) continue;
597
+ // Bug 1 fix: use LIKE-based query for hash prefix matching
598
+ if (memoryExistsByHashPrefix(`[${hashPrefix}]%`)) continue;
473
599
 
474
600
  const id = insertMemory(commit.fullText, commit.importance, {
475
601
  source_type: 'git',
@@ -489,6 +615,7 @@ export function registerTools(server) {
489
615
  added++;
490
616
  }
491
617
  if (added > 0) {
618
+ searchCache.invalidate();
492
619
  console.error(`[persyst-watcher] Ingested ${added} new commits from ${repo_path}`);
493
620
  }
494
621
  } catch (e) {
@@ -545,7 +672,7 @@ export function registerTools(server) {
545
672
  }
546
673
 
547
674
  // ============================================================
548
- // HELPER
675
+ // HELPERS
549
676
  // ============================================================
550
677
 
551
678
  /** Format a response as MCP text content */
@@ -554,3 +681,24 @@ function text(data) {
554
681
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
555
682
  };
556
683
  }
684
+
685
+ /**
686
+ * Compute Jaccard distance between two text strings.
687
+ * Used for contradiction detection — higher distance means more different content.
688
+ * @param {string} a - First text
689
+ * @param {string} b - Second text
690
+ * @returns {number} Distance score between 0 (identical) and 1 (completely different)
691
+ */
692
+ function jaccardDistance(a, b) {
693
+ const wordsA = new Set(a.toLowerCase().split(/\s+/));
694
+ const wordsB = new Set(b.toLowerCase().split(/\s+/));
695
+
696
+ let intersection = 0;
697
+ for (const word of wordsA) {
698
+ if (wordsB.has(word)) intersection++;
699
+ }
700
+
701
+ const union = wordsA.size + wordsB.size - intersection;
702
+ if (union === 0) return 0;
703
+ return 1 - (intersection / union);
704
+ }