persyst-mcp 1.0.1 → 2.0.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
@@ -1,24 +1,14 @@
1
1
  /**
2
2
  * tools.js — MCP Tool Definitions & Handlers
3
3
  *
4
- * Defines all 11 tools that AI agents can call via MCP:
4
+ * Defines all 19 tools that AI agents can call via MCP.
5
5
  *
6
- * Core (MVP):
7
- * 1. add_memory — Store a new memory
8
- * 2. search_memories — Hybrid keyword + semantic search
9
- * 3. get_memory — Get one memory by ID
10
- * 4. update_memory — Update content (re-embeds automatically)
11
- * 5. delete_memory — Remove a memory permanently
12
- * 6. get_recent_memories — Latest N memories
13
- * 7. get_important_memories — Top N by importance
14
- *
15
- * Advanced (Phase 3):
16
- * 8. ingest_git_commits — Import git history as memories
17
- * 9. add_entity — Create a named entity
18
- * 10. link_entity_memory — Connect entity ↔ memory
19
- * 11. search_by_entity — Find memories linked to an entity
20
- *
21
- * Uses Zod schemas for input validation (required by McpServer).
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
22
12
  */
23
13
 
24
14
  import { z } from 'zod';
@@ -37,14 +27,80 @@ import {
37
27
  insertEdge,
38
28
  getMemoriesByEntity,
39
29
  getAllEntities,
40
- memoryExists
30
+ memoryExists,
31
+ memoryExistsByHashPrefix,
32
+ getMemoryByContent,
33
+ boostMemory,
34
+ logContradiction,
35
+ getAllAgentStats,
36
+ getAttestationsByDateRange,
37
+ getMemoryHistoryChain,
38
+ searchAllMemoriesFts,
39
+ getAnyMemoryById,
40
+ searchVector,
41
+ getMemoryById,
42
+ getActiveMemoryCount
41
43
  } from './database.js';
42
- import { searchHybrid } from './search.js';
44
+ import { searchHybrid, getOptimizedContext, consolidateMemories } from './search.js';
43
45
  import { getRecentCommits } from './git.js';
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
+ // ============================================================
62
+
63
+ // In-memory registry of active git watchers
64
+ const watchers = new Map();
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
+ }
44
99
 
45
100
  /**
46
101
  * Register all MCP tools on the server.
47
102
  * @param {McpServer} server - The MCP server instance
103
+ * @returns {number} The total count of registered tools
48
104
  */
49
105
  export function registerTools(server) {
50
106
  let count = 0;
@@ -54,46 +110,121 @@ export function registerTools(server) {
54
110
  count++;
55
111
  };
56
112
 
113
+ // ============================================================
114
+ // CORE TOOLS
115
+ // ============================================================
57
116
 
58
- // ========================================
59
117
  // 1. ADD MEMORY
60
- // ========================================
61
118
  server.tool(
62
119
  'add_memory',
63
120
  'Store a new memory. It will be searchable by both keywords and meaning.',
64
121
  {
65
122
  content: z.string().describe('The memory content to store'),
66
- importance: z.number().min(0).max(1).default(1.0)
67
- .describe('Importance score from 0 (low) to 1 (high)')
123
+ importance: z.number().min(0).max(1).default(1.0).describe('Importance score from 0 (low) to 1 (high)'),
124
+ agent_id: z.string().optional().describe('Agent ID for provenance tracking'),
125
+ session_id: z.string().optional().describe('Session ID')
68
126
  },
69
- async ({ content, importance }) => {
70
- const id = insertMemory(content, importance);
71
- const embedding = await generateEmbedding(content);
72
- insertVector(id, embedding);
127
+ async ({ content, importance, agent_id, session_id }) => {
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
+ }
73
134
 
74
- return text({ success: true, id, message: `Memory #${id} stored` });
135
+ // Deduplication check
136
+ const existing = getMemoryByContent(content);
137
+ if (existing) {
138
+ boostMemory(existing.id);
139
+ return text({
140
+ success: true,
141
+ id: existing.id,
142
+ message: `Memory #${existing.id} already exists. Boosted importance.`
143
+ });
144
+ }
145
+
146
+ const id = insertMemory(content, importance, {
147
+ source_type: agent_id ? 'agent' : 'manual',
148
+ source_id: agent_id || null,
149
+ confidence: 1.0
150
+ });
151
+
152
+ const embedding = await generateEmbedding(content);
153
+ insertVector(id, embedding);
154
+
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);
197
+ } catch (err) {
198
+ return text({ error: err.message });
199
+ }
75
200
  }
76
201
  );
77
202
 
78
- // ========================================
79
203
  // 2. SEARCH MEMORIES
80
- // ========================================
81
204
  server.tool(
82
205
  'search_memories',
83
- 'Search memories using hybrid keyword + semantic search. Finds exact matches AND similar meanings (e.g. "dark mode" finds "night theme").',
206
+ 'Search memories using hybrid keyword + semantic search with cryptographic attestation.',
84
207
  {
85
208
  query: z.string().describe('What to search for'),
86
- limit: z.number().default(5).describe('Max results (default: 5)')
209
+ limit: z.number().default(5).describe('Max results (default: 5)'),
210
+ agent_id: z.string().optional().describe('Agent ID calling this search'),
211
+ session_id: z.string().optional().describe('Session ID')
87
212
  },
88
- async ({ query, limit }) => {
89
- const results = await searchHybrid(query, limit);
90
- return text({ results, count: results.length });
213
+ async ({ query, limit, agent_id, session_id }) => {
214
+ try {
215
+ const results = await searchHybrid(query, limit, agent_id, session_id);
216
+ return text({
217
+ results,
218
+ count: results.length,
219
+ attestation: results.attestation
220
+ });
221
+ } catch (err) {
222
+ return text({ error: err.message });
223
+ }
91
224
  }
92
225
  );
93
226
 
94
- // ========================================
95
227
  // 3. GET MEMORY
96
- // ========================================
97
228
  server.tool(
98
229
  'get_memory',
99
230
  'Get a specific memory by its ID. Boosts its importance automatically.',
@@ -101,38 +232,64 @@ export function registerTools(server) {
101
232
  id: z.number().describe('Memory ID to retrieve')
102
233
  },
103
234
  async ({ id }) => {
104
- const memory = getMemory(id);
105
- if (!memory) return text({ error: `Memory #${id} not found` });
106
- return text(memory);
235
+ try {
236
+ const memory = getMemory(id);
237
+ if (!memory) return text({ error: `Memory #${id} not found` });
238
+ return text(memory);
239
+ } catch (err) {
240
+ return text({ error: err.message });
241
+ }
107
242
  }
108
243
  );
109
244
 
110
- // ========================================
111
245
  // 4. UPDATE MEMORY
112
- // ========================================
113
246
  server.tool(
114
247
  'update_memory',
115
- 'Update the content of an existing memory. Automatically re-generates the search embedding.',
248
+ 'Update the content of an existing memory. Archives the old content and saves the new version.',
116
249
  {
117
250
  id: z.number().describe('Memory ID to update'),
118
- content: z.string().describe('New memory content')
251
+ content: z.string().describe('New memory content'),
252
+ agent_id: z.string().optional().describe('Agent ID making this update')
119
253
  },
120
- async ({ id, content }) => {
121
- const updated = updateMemoryContent(id, content);
122
- if (!updated) return text({ error: `Memory #${id} not found` });
254
+ async ({ id, content, agent_id }) => {
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
+
262
+ const oldMemory = getMemory(id);
263
+ if (!oldMemory) return text({ error: `Memory #${id} not found` });
264
+
265
+ // Insert new version
266
+ const newId = insertMemory(content, oldMemory.importance_score, {
267
+ source_type: agent_id ? 'agent' : 'manual',
268
+ source_id: agent_id || null,
269
+ confidence: 1.0
270
+ });
271
+
272
+ const embedding = await generateEmbedding(content);
273
+ insertVector(newId, embedding);
274
+
275
+ // Record contradiction and archive the old one
276
+ logContradiction(id, newId, 'Content updated via update_memory');
123
277
 
124
- // Re-generate embedding for updated content
125
- const embedding = await generateEmbedding(content);
126
- deleteVec(id);
127
- insertVector(id, embedding);
278
+ // Feature 1: Invalidate search cache on write
279
+ searchCache.invalidate();
128
280
 
129
- return text({ success: true, id, message: `Memory #${id} updated` });
281
+ return text({
282
+ success: true,
283
+ id: newId,
284
+ message: `Memory #${id} updated. New version stored as #${newId}`
285
+ });
286
+ } catch (err) {
287
+ return text({ error: err.message });
288
+ }
130
289
  }
131
290
  );
132
291
 
133
- // ========================================
134
292
  // 5. DELETE MEMORY
135
- // ========================================
136
293
  server.tool(
137
294
  'delete_memory',
138
295
  'Permanently delete a memory by its ID.',
@@ -140,15 +297,21 @@ export function registerTools(server) {
140
297
  id: z.number().describe('Memory ID to delete')
141
298
  },
142
299
  async ({ id }) => {
143
- const deleted = deleteMemory(id);
144
- if (!deleted) return text({ error: `Memory #${id} not found` });
145
- return text({ success: true, id, message: `Memory #${id} deleted` });
300
+ try {
301
+ const deleted = deleteMemory(id);
302
+ if (!deleted) return text({ error: `Memory #${id} not found` });
303
+
304
+ // Feature 1: Invalidate search cache on write
305
+ searchCache.invalidate();
306
+
307
+ return text({ success: true, id, message: `Memory #${id} deleted` });
308
+ } catch (err) {
309
+ return text({ error: err.message });
310
+ }
146
311
  }
147
312
  );
148
313
 
149
- // ========================================
150
314
  // 6. GET RECENT MEMORIES
151
- // ========================================
152
315
  server.tool(
153
316
  'get_recent_memories',
154
317
  'Get the most recently created memories, newest first.',
@@ -156,14 +319,16 @@ export function registerTools(server) {
156
319
  limit: z.number().default(10).describe('How many to return (default: 10)')
157
320
  },
158
321
  async ({ limit }) => {
159
- const memories = getRecentMemories(limit);
160
- return text({ memories, count: memories.length });
322
+ try {
323
+ const memories = getRecentMemories(limit);
324
+ return text({ memories, count: memories.length });
325
+ } catch (err) {
326
+ return text({ error: err.message });
327
+ }
161
328
  }
162
329
  );
163
330
 
164
- // ========================================
165
331
  // 7. GET IMPORTANT MEMORIES
166
- // ========================================
167
332
  server.tool(
168
333
  'get_important_memories',
169
334
  'Get memories ranked by importance score, highest first.',
@@ -171,49 +336,67 @@ export function registerTools(server) {
171
336
  limit: z.number().default(10).describe('How many to return (default: 10)')
172
337
  },
173
338
  async ({ limit }) => {
174
- const memories = getImportantMemories(limit);
175
- return text({ memories, count: memories.length });
339
+ try {
340
+ const memories = getImportantMemories(limit);
341
+ return text({ memories, count: memories.length });
342
+ } catch (err) {
343
+ return text({ error: err.message });
344
+ }
176
345
  }
177
346
  );
178
347
 
179
- // ========================================
180
348
  // 8. INGEST GIT COMMITS
181
- // ========================================
182
349
  server.tool(
183
350
  'ingest_git_commits',
184
- 'Import recent git commits from a repository as memories. Each commit becomes a searchable memory. Deduplicates automatically — safe to call multiple times.',
351
+ 'Import recent git commits, parse PR/file links, and categorize decisions.',
185
352
  {
186
353
  repo_path: z.string().describe('Absolute path to the git repository'),
187
354
  count: z.number().default(20).describe('Number of recent commits to import (default: 20)')
188
355
  },
189
356
  async ({ repo_path, count }) => {
190
357
  try {
191
- const commits = getRecentCommits(repo_path, count);
358
+ const commits = await getRecentCommits(repo_path, count);
192
359
  let added = 0;
193
360
  let skipped = 0;
194
361
 
195
362
  for (const commit of commits) {
196
- // Dedup by commit hash prefix
197
363
  const hashPrefix = commit.hash.slice(0, 7);
198
- if (memoryExists(`[${hashPrefix}]%`)) {
364
+ // Bug 1 fix: use LIKE-based query for hash prefix matching
365
+ if (memoryExistsByHashPrefix(`[${hashPrefix}]%`)) {
199
366
  skipped++;
200
367
  continue;
201
368
  }
202
369
 
203
- // Store commit as memory
204
- const id = insertMemory(commit.fullText, 0.6);
370
+ // Insert memory with provenance
371
+ const id = insertMemory(commit.fullText, commit.importance, {
372
+ source_type: 'git',
373
+ source_id: commit.hash,
374
+ confidence: 0.8
375
+ });
376
+
205
377
  const embedding = await generateEmbedding(commit.fullText);
206
378
  insertVector(id, embedding);
207
379
 
208
- // Auto-create author entity and link
380
+ // Link Author
209
381
  const authorId = insertEntity(commit.author, 'person');
210
382
  if (authorId) {
211
383
  insertEdge(authorId, id, 'authored', 'entity', 'memory');
212
384
  }
213
385
 
386
+ // Link Files Touched
387
+ for (const file of commit.files) {
388
+ const fileId = insertEntity(file, 'file');
389
+ if (fileId) {
390
+ insertEdge(fileId, id, 'touches', 'entity', 'memory');
391
+ }
392
+ }
393
+
214
394
  added++;
215
395
  }
216
396
 
397
+ // Feature 1: Invalidate search cache after git ingestion
398
+ if (added > 0) searchCache.invalidate();
399
+
217
400
  return text({
218
401
  success: true,
219
402
  added,
@@ -227,69 +410,269 @@ export function registerTools(server) {
227
410
  }
228
411
  );
229
412
 
230
- // ========================================
231
413
  // 9. ADD ENTITY
232
- // ========================================
233
414
  server.tool(
234
415
  'add_entity',
235
- 'Create a named entity (person, tech, project, concept, file). Entities can be linked to memories for graph traversal.',
416
+ 'Create a named entity (person, tech, project, concept, file).',
236
417
  {
237
- name: z.string().describe('Entity name (e.g. "React", "John", "auth-service")'),
418
+ name: z.string().describe('Entity name (e.g. "React", "auth-service")'),
238
419
  type: z.string().describe('Entity type: person, tech, project, concept, file')
239
420
  },
240
421
  async ({ name, type }) => {
241
- const id = insertEntity(name, type);
242
- return text({ success: true, id, name, type, message: `Entity "${name}" created` });
422
+ try {
423
+ const id = insertEntity(name, type);
424
+ return text({ success: true, id, name, type, message: `Entity "${name}" created` });
425
+ } catch (err) {
426
+ return text({ error: err.message });
427
+ }
243
428
  }
244
429
  );
245
430
 
246
- // ========================================
247
431
  // 10. LINK ENTITY TO MEMORY
248
- // ========================================
249
432
  server.tool(
250
433
  'link_entity_memory',
251
- 'Connect an entity to a memory with a relationship label (e.g. "mentions", "is_about", "decided_by").',
434
+ 'Connect an entity to a memory with a relationship label.',
252
435
  {
253
436
  entity_name: z.string().describe('Name of the entity'),
254
437
  memory_id: z.number().describe('ID of the memory to link'),
255
- relation: z.string().default('mentions').describe('Relationship type (e.g. mentions, is_about, decided_by)')
438
+ relation: z.string().default('mentions').describe('Relationship type')
256
439
  },
257
440
  async ({ entity_name, memory_id, relation }) => {
258
- const entity = getEntityByName(entity_name);
259
- if (!entity) return text({ error: `Entity "${entity_name}" not found. Create it first with add_entity.` });
441
+ try {
442
+ const entity = getEntityByName(entity_name);
443
+ if (!entity) return text({ error: `Entity "${entity_name}" not found.` });
260
444
 
261
- const memory = getMemory(memory_id);
262
- if (!memory) return text({ error: `Memory #${memory_id} not found` });
445
+ const memory = getMemory(memory_id);
446
+ if (!memory) return text({ error: `Memory #${memory_id} not found` });
263
447
 
264
- insertEdge(entity.id, memory_id, relation, 'entity', 'memory');
265
- return text({ success: true, entity: entity_name, memory_id, relation, message: `Linked "${entity_name}" → memory #${memory_id}` });
448
+ insertEdge(entity.id, memory_id, relation, 'entity', 'memory');
449
+ return text({ success: true, entity: entity_name, memory_id, relation });
450
+ } catch (err) {
451
+ return text({ error: err.message });
452
+ }
266
453
  }
267
454
  );
268
455
 
269
- // ========================================
270
456
  // 11. SEARCH BY ENTITY
271
- // ========================================
272
457
  server.tool(
273
458
  'search_by_entity',
274
- 'Find all memories linked to a specific entity. Returns memories connected via edges in the knowledge graph.',
459
+ 'Find all memories linked to a specific entity.',
275
460
  {
276
461
  entity_name: z.string().describe('Name of the entity to search for')
277
462
  },
278
463
  async ({ entity_name }) => {
279
- const entity = getEntityByName(entity_name);
280
- if (!entity) return text({ error: `Entity "${entity_name}" not found` });
464
+ try {
465
+ const entity = getEntityByName(entity_name);
466
+ if (!entity) return text({ error: `Entity "${entity_name}" not found` });
281
467
 
282
- const memories = getMemoriesByEntity(entity.id);
283
- return text({ entity, memories, count: memories.length });
468
+ const memories = getMemoriesByEntity(entity.id);
469
+ return text({ entity, memories, count: memories.length });
470
+ } catch (err) {
471
+ return text({ error: err.message });
472
+ }
284
473
  }
285
474
  );
475
+
476
+ // ============================================================
477
+ // PRODUCTION-GRADE / NEW TOOLS
478
+ // ============================================================
479
+
480
+ // 12. GET MEMORY HISTORY
481
+ server.tool(
482
+ 'get_memory_history',
483
+ 'Retrieve all versions of a memory, including archived versions and contradictions.',
484
+ {
485
+ query: z.string().describe('The content or search query to find the memory versions for')
486
+ },
487
+ async ({ query }) => {
488
+ try {
489
+ const hits = searchAllMemoriesFts(query, 5);
490
+ if (hits.length === 0) {
491
+ return text({ message: 'No memories matching query found.' });
492
+ }
493
+
494
+ const histories = {};
495
+ for (const hit of hits) {
496
+ const chain = getMemoryHistoryChain(hit.id);
497
+ histories[hit.id] = chain;
498
+ }
499
+
500
+ return text({ query, histories });
501
+ } catch (err) {
502
+ return text({ error: err.message });
503
+ }
504
+ }
505
+ );
506
+
507
+ // 13. GET AGENT STATS
508
+ server.tool(
509
+ 'get_agent_stats',
510
+ 'Retrieve reputation statistics and activity logs for all active agents.',
511
+ {},
512
+ async () => {
513
+ try {
514
+ const stats = getAllAgentStats();
515
+ return text({ stats, count: stats.length });
516
+ } catch (err) {
517
+ return text({ error: err.message });
518
+ }
519
+ }
520
+ );
521
+
522
+ // 14. EXPORT AUDIT LOG
523
+ server.tool(
524
+ 'export_audit_log',
525
+ 'Exports query attestation log records within a timestamp range for compliance audits.',
526
+ {
527
+ start_date: z.string().describe('Start date ISO8601 (e.g. 2026-06-01T00:00:00Z)'),
528
+ end_date: z.string().describe('End date ISO8601')
529
+ },
530
+ async ({ start_date, end_date }) => {
531
+ try {
532
+ const logs = getAttestationsByDateRange(start_date, end_date);
533
+ return text({ logs, count: logs.length });
534
+ } catch (err) {
535
+ return text({ error: err.message });
536
+ }
537
+ }
538
+ );
539
+
540
+ // 15. VERIFY ATTESTATION
541
+ server.tool(
542
+ 'verify_attestation',
543
+ 'Verify the Ed25519 signature and hash-chain integrity of a specific attestation.',
544
+ {
545
+ attestation_id: z.string().describe('The UUID of the attestation to verify')
546
+ },
547
+ async ({ attestation_id }) => {
548
+ try {
549
+ const report = verifyChainIntegrity(attestation_id);
550
+ return text(report);
551
+ } catch (err) {
552
+ return text({ error: err.message });
553
+ }
554
+ }
555
+ );
556
+
557
+ // 16. GET FILE HISTORY
558
+ server.tool(
559
+ 'get_file_history',
560
+ 'Fetch all commit memories and architectural choices that modified a specific file.',
561
+ {
562
+ file_path: z.string().describe('Relative or absolute file path')
563
+ },
564
+ async ({ file_path }) => {
565
+ try {
566
+ const entity = getEntityByName(file_path);
567
+ if (!entity) return text({ message: `No git history entity found for file: ${file_path}`, memories: [] });
568
+
569
+ const memories = getMemoriesByEntity(entity.id);
570
+ return text({ file_path, memories, count: memories.length });
571
+ } catch (err) {
572
+ return text({ error: err.message });
573
+ }
574
+ }
575
+ );
576
+
577
+ // 17. WATCH GIT REPO
578
+ server.tool(
579
+ 'watch_git_repo',
580
+ 'Subscribe to and poll a repository for changes, auto-ingesting new commits every 5 minutes.',
581
+ {
582
+ repo_path: z.string().describe('Absolute path to the repository')
583
+ },
584
+ async ({ repo_path }) => {
585
+ try {
586
+ if (watchers.has(repo_path)) {
587
+ return text({ success: true, message: `Repository ${repo_path} is already being watched.` });
588
+ }
589
+
590
+ const intervalId = setInterval(async () => {
591
+ console.error(`[persyst-watcher] Running scheduled ingestion for: ${repo_path}`);
592
+ try {
593
+ const result = await getRecentCommits(repo_path, 10);
594
+ let added = 0;
595
+ for (const commit of result) {
596
+ const hashPrefix = commit.hash.slice(0, 7);
597
+ // Bug 1 fix: use LIKE-based query for hash prefix matching
598
+ if (memoryExistsByHashPrefix(`[${hashPrefix}]%`)) continue;
599
+
600
+ const id = insertMemory(commit.fullText, commit.importance, {
601
+ source_type: 'git',
602
+ source_id: commit.hash,
603
+ confidence: 0.8
604
+ });
605
+ const embedding = await generateEmbedding(commit.fullText);
606
+ insertVector(id, embedding);
607
+
608
+ const authorId = insertEntity(commit.author, 'person');
609
+ if (authorId) insertEdge(authorId, id, 'authored', 'entity', 'memory');
610
+
611
+ for (const file of commit.files) {
612
+ const fileId = insertEntity(file, 'file');
613
+ if (fileId) insertEdge(fileId, id, 'touches', 'entity', 'memory');
614
+ }
615
+ added++;
616
+ }
617
+ if (added > 0) {
618
+ searchCache.invalidate();
619
+ console.error(`[persyst-watcher] Ingested ${added} new commits from ${repo_path}`);
620
+ }
621
+ } catch (e) {
622
+ console.error(`[persyst-watcher] Ingestion failed for ${repo_path}: ${e.message}`);
623
+ }
624
+ }, 300000); // 5 minutes
625
+
626
+ watchers.set(repo_path, intervalId);
627
+ return text({ success: true, message: `Started watching repository at ${repo_path}` });
628
+ } catch (err) {
629
+ return text({ error: err.message });
630
+ }
631
+ }
632
+ );
633
+
634
+ // 18. GET OPTIMIZED CONTEXT
635
+ server.tool(
636
+ 'get_optimized_context',
637
+ 'Compile a condensed context prompt within a token budget by hopping the knowledge graph and ranking by temporal decay + agent reputation.',
638
+ {
639
+ query: z.string().describe('The search query context'),
640
+ max_tokens: z.number().default(4000).describe('Token budget for LLM context compression (default: 4000)'),
641
+ agent_id: z.string().optional().describe('Agent ID requesting context'),
642
+ session_id: z.string().optional().describe('Session ID')
643
+ },
644
+ async ({ query, max_tokens, agent_id, session_id }) => {
645
+ try {
646
+ const contextData = await getOptimizedContext(query, max_tokens, agent_id, session_id);
647
+ return text(contextData);
648
+ } catch (err) {
649
+ return text({ error: err.message });
650
+ }
651
+ }
652
+ );
653
+
654
+ // 19. CONSOLIDATE MEMORIES
655
+ server.tool(
656
+ 'consolidate_memories',
657
+ 'Manually trigger the semantic deduplication sweep to merge highly similar memories (similarity > 0.85).',
658
+ {},
659
+ async () => {
660
+ try {
661
+ const report = await consolidateMemories();
662
+ return text(report);
663
+ } catch (err) {
664
+ return text({ error: err.message });
665
+ }
666
+ }
667
+ );
668
+
286
669
  // Restore original method and return count
287
670
  server.tool = originalTool;
288
671
  return count;
289
672
  }
290
673
 
291
674
  // ============================================================
292
- // HELPER
675
+ // HELPERS
293
676
  // ============================================================
294
677
 
295
678
  /** Format a response as MCP text content */
@@ -298,3 +681,24 @@ function text(data) {
298
681
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
299
682
  };
300
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
+ }