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/bin/aider.js +204 -0
- package/bin/setup.js +168 -0
- package/hooks/persyst-hook.js +234 -0
- package/index.js +16 -7
- package/package.json +6 -2
- package/src/attestation.js +0 -1
- package/src/cache.js +122 -0
- package/src/database.js +51 -9
- package/src/git.js +23 -17
- package/src/search.js +124 -18
- package/src/server.js +4 -3
- package/src/tools.js +155 -7
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|