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/LICENSE +21 -0
- package/README.md +85 -62
- package/index.js +4 -1
- package/package.json +16 -2
- package/src/attestation.js +206 -0
- package/src/cache.js +122 -0
- package/src/database.js +369 -33
- package/src/git.js +87 -20
- package/src/search.js +375 -49
- package/src/server.js +19 -4
- package/src/tools.js +502 -98
package/src/tools.js
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools.js — MCP Tool Definitions & Handlers
|
|
3
3
|
*
|
|
4
|
-
* Defines all
|
|
4
|
+
* Defines all 19 tools that AI agents can call via MCP.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
deleteVec(id);
|
|
127
|
-
insertVector(id, embedding);
|
|
278
|
+
// Feature 1: Invalidate search cache on write
|
|
279
|
+
searchCache.invalidate();
|
|
128
280
|
|
|
129
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
204
|
-
const id = insertMemory(commit.fullText,
|
|
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
|
-
//
|
|
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).
|
|
416
|
+
'Create a named entity (person, tech, project, concept, file).',
|
|
236
417
|
{
|
|
237
|
-
name: z.string().describe('Entity name (e.g. "React", "
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
438
|
+
relation: z.string().default('mentions').describe('Relationship type')
|
|
256
439
|
},
|
|
257
440
|
async ({ entity_name, memory_id, relation }) => {
|
|
258
|
-
|
|
259
|
-
|
|
441
|
+
try {
|
|
442
|
+
const entity = getEntityByName(entity_name);
|
|
443
|
+
if (!entity) return text({ error: `Entity "${entity_name}" not found.` });
|
|
260
444
|
|
|
261
|
-
|
|
262
|
-
|
|
445
|
+
const memory = getMemory(memory_id);
|
|
446
|
+
if (!memory) return text({ error: `Memory #${memory_id} not found` });
|
|
263
447
|
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
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
|
-
|
|
280
|
-
|
|
464
|
+
try {
|
|
465
|
+
const entity = getEntityByName(entity_name);
|
|
466
|
+
if (!entity) return text({ error: `Entity "${entity_name}" not found` });
|
|
281
467
|
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|