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/README.md +64 -2
- package/bin/export.js +116 -0
- package/bin/import.js +160 -0
- package/bin/init.js +168 -32
- package/bin/mcp.js +7 -0
- package/hooks/persyst-hook.js +9 -10
- package/index.js +42 -12
- package/package.json +15 -10
- package/src/attestation.js +49 -28
- package/src/database.js +229 -36
- package/src/events.js +19 -0
- package/src/extractor-heuristic.js +505 -324
- package/src/sdk.d.ts +175 -0
- package/src/sdk.js +218 -0
- package/src/search.js +144 -83
- package/src/server.js +766 -93
- package/src/setup-wasm.js +34 -39
- package/src/text-utils.js +41 -0
- package/src/tools.js +58 -46
- package/src/watcher.js +174 -50
package/hooks/persyst-hook.js
CHANGED
|
@@ -281,12 +281,11 @@ function spawnWorker() {
|
|
|
281
281
|
/**
|
|
282
282
|
* Handle SessionStart: load project-wide context and ingest git history.
|
|
283
283
|
*/
|
|
284
|
-
async function handleSessionStart(
|
|
284
|
+
async function handleSessionStart(input) {
|
|
285
285
|
const cwd = input.cwd || process.cwd();
|
|
286
286
|
const repoName = cwd.replace(/\\/g, '/').split('/').pop();
|
|
287
287
|
|
|
288
|
-
|
|
289
|
-
const contextResult = await callTool(client, 'get_optimized_context', {
|
|
288
|
+
const contextResult = await callTool(null, 'get_optimized_context', {
|
|
290
289
|
query: `Project ${repoName} conventions, architecture, user preferences, coding rules`,
|
|
291
290
|
max_tokens: 2000,
|
|
292
291
|
agent_id: 'claude-code',
|
|
@@ -295,7 +294,7 @@ async function handleSessionStart(client, input) {
|
|
|
295
294
|
|
|
296
295
|
// 2. Ingest recent git commits (best-effort, don't fail if not a git repo)
|
|
297
296
|
try {
|
|
298
|
-
await callTool(
|
|
297
|
+
await callTool(null, 'ingest_git_commits', {
|
|
299
298
|
repo_path: cwd,
|
|
300
299
|
count: 15
|
|
301
300
|
});
|
|
@@ -312,11 +311,11 @@ async function handleSessionStart(client, input) {
|
|
|
312
311
|
// 4. Get memory count for status line
|
|
313
312
|
let memoryCount = 0;
|
|
314
313
|
try {
|
|
315
|
-
const recentResult = await callTool(
|
|
314
|
+
const recentResult = await callTool(null, 'get_recent_memories', { limit: 1 });
|
|
316
315
|
if (recentResult && recentResult.count !== undefined) {
|
|
317
316
|
// The count from get_recent is just the returned count, not total
|
|
318
317
|
// Use a search to estimate total active memories
|
|
319
|
-
const importantResult = await callTool(
|
|
318
|
+
const importantResult = await callTool(null, 'get_important_memories', { limit: 100 });
|
|
320
319
|
memoryCount = importantResult?.count || 0;
|
|
321
320
|
}
|
|
322
321
|
} catch (_) {
|
|
@@ -339,7 +338,7 @@ async function handleSessionStart(client, input) {
|
|
|
339
338
|
* Handle UserPromptSubmit: search for memories relevant to the user's prompt.
|
|
340
339
|
* Also runs Tier 2 heuristic extraction inline (zero-cost).
|
|
341
340
|
*/
|
|
342
|
-
async function handleUserPromptSubmit(
|
|
341
|
+
async function handleUserPromptSubmit(input) {
|
|
343
342
|
const prompt = input.prompt || '';
|
|
344
343
|
|
|
345
344
|
// Skip trivial prompts (commands, confirmations, short inputs)
|
|
@@ -367,7 +366,7 @@ async function handleUserPromptSubmit(client, input) {
|
|
|
367
366
|
|
|
368
367
|
// --- Memory Retrieval (existing behavior) ---
|
|
369
368
|
// Use search_memories for speed on per-prompt lookups (faster than get_optimized_context)
|
|
370
|
-
const searchResult = await callTool(
|
|
369
|
+
const searchResult = await callTool(null, 'search_memories', {
|
|
371
370
|
query: prompt.slice(0, 200), // Truncate very long prompts for search efficiency
|
|
372
371
|
limit: 5,
|
|
373
372
|
agent_id: 'claude-code',
|
|
@@ -448,11 +447,11 @@ async function main() {
|
|
|
448
447
|
|
|
449
448
|
let response;
|
|
450
449
|
if (eventName === 'SessionStart') {
|
|
451
|
-
response = await handleSessionStart(
|
|
450
|
+
response = await handleSessionStart(input);
|
|
452
451
|
} else if (eventName === 'UserPromptSubmit') {
|
|
453
452
|
// Apply hard timeout for prompt-time hook execution
|
|
454
453
|
response = await Promise.race([
|
|
455
|
-
handleUserPromptSubmit(
|
|
454
|
+
handleUserPromptSubmit(input),
|
|
456
455
|
new Promise((resolve) =>
|
|
457
456
|
setTimeout(() => {
|
|
458
457
|
process.stderr.write(`[persyst-hook] UserPromptSubmit hit ${MAX_HOOK_LATENCY_MS}ms timeout, returning partial.\n`);
|
package/index.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
// If running inside Bun (like Qwen's internal runtime), spawn Node.js instead
|
|
19
19
|
if (process.versions.bun && !process.env.PERSYST_RUN_BY_NODE) {
|
|
20
20
|
const { spawn } = await import('child_process');
|
|
21
|
-
|
|
21
|
+
// Prefer NODE env var (set by nvm/fnm/volta), then fall back to 'node' on PATH
|
|
22
|
+
const nodeExec = process.env.NODE || 'node';
|
|
23
|
+
const child = spawn(nodeExec, [
|
|
22
24
|
process.argv[1],
|
|
23
25
|
...process.argv.slice(2)
|
|
24
26
|
], {
|
|
@@ -37,22 +39,42 @@ if (process.versions.bun && !process.env.PERSYST_RUN_BY_NODE) {
|
|
|
37
39
|
|
|
38
40
|
// Fix PATH on Windows if running in environments like Qwen Desktop that override PATH
|
|
39
41
|
if (process.platform === 'win32') {
|
|
40
|
-
const nodeBin = 'C:\\Program Files\\nodejs';
|
|
41
|
-
const gitBin = 'C:\\Program Files\\Git\\cmd';
|
|
42
|
-
const systemBin = 'C:\\WINDOWS\\system32;C:\\WINDOWS';
|
|
43
|
-
|
|
44
42
|
const currentPath = process.env.PATH || '';
|
|
45
43
|
const paths = currentPath.split(';');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
|
|
45
|
+
// Dynamically find node and git on PATH using where.exe
|
|
46
|
+
const { execFileSync } = await import('child_process');
|
|
47
|
+
for (const cmd of ['node', 'git']) {
|
|
48
|
+
try {
|
|
49
|
+
const result = execFileSync('where.exe', [cmd], { encoding: 'utf8', timeout: 2000 });
|
|
50
|
+
const binDir = result.trim().split('\r\n')[0].trim();
|
|
51
|
+
if (binDir) {
|
|
52
|
+
const dir = binDir.substring(0, binDir.lastIndexOf('\\'));
|
|
53
|
+
if (dir && !paths.some(p => p.toLowerCase() === dir.toLowerCase())) {
|
|
54
|
+
paths.push(dir);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// where.exe failed — fall back to common paths
|
|
59
|
+
if (cmd === 'node') {
|
|
60
|
+
for (const p of ['C:\\Program Files\\nodejs', process.env.NVM_SYMLINK, `${process.env.USERPROFILE}\\AppData\\Roaming\\nvm\\v20.11.0`].filter(Boolean)) {
|
|
61
|
+
if (!paths.some(ex => ex.toLowerCase() === p.toLowerCase())) paths.push(p);
|
|
62
|
+
}
|
|
63
|
+
} else if (cmd === 'git') {
|
|
64
|
+
for (const p of ['C:\\Program Files\\Git\\cmd', 'C:\\Program Files\\Git\\bin', `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`].filter(Boolean)) {
|
|
65
|
+
if (!paths.some(ex => ex.toLowerCase() === p.toLowerCase())) paths.push(p);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ensure system folders are present
|
|
72
|
+
const systemBin = 'C:\\WINDOWS\\system32;C:\\WINDOWS';
|
|
51
73
|
const sysPaths = systemBin.split(';');
|
|
52
74
|
sysPaths.forEach(p => {
|
|
53
|
-
if (!paths.
|
|
75
|
+
if (!paths.some(ex => ex.toLowerCase() === p.toLowerCase())) paths.push(p);
|
|
54
76
|
});
|
|
55
|
-
|
|
77
|
+
|
|
56
78
|
process.env.PATH = paths.join(';');
|
|
57
79
|
}
|
|
58
80
|
|
|
@@ -80,6 +102,14 @@ if (subcommand === 'setup') {
|
|
|
80
102
|
} else if (subcommand === 'worker') {
|
|
81
103
|
// Run the background extraction worker directly
|
|
82
104
|
await import('./bin/extract-worker.js');
|
|
105
|
+
} else if (subcommand === 'export') {
|
|
106
|
+
// Export memories to a JSONL file
|
|
107
|
+
process.argv.splice(2, 1);
|
|
108
|
+
await import('./bin/export.js');
|
|
109
|
+
} else if (subcommand === 'import') {
|
|
110
|
+
// Import memories from a JSONL file
|
|
111
|
+
process.argv.splice(2, 1);
|
|
112
|
+
await import('./bin/import.js');
|
|
83
113
|
} else {
|
|
84
114
|
// Default: start the MCP server
|
|
85
115
|
const { startServer } = await import('./src/server.js');
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "persyst-mcp",
|
|
3
|
-
"version": "2.2.
|
|
4
|
-
"description": "Local-first MCP memory
|
|
3
|
+
"version": "2.2.6",
|
|
4
|
+
"description": "Local-first, compliance-grade MCP memory layer with hybrid keyword + semantic search for coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./sdk": {
|
|
10
|
+
"types": "./src/sdk.d.ts",
|
|
11
|
+
"default": "./src/sdk.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
7
14
|
"bin": {
|
|
8
|
-
"persyst-mcp": "
|
|
9
|
-
"persyst-setup": "bin/setup.js",
|
|
10
|
-
"persyst-
|
|
11
|
-
"persyst
|
|
12
|
-
"persyst-ingest": "bin/ingest.js",
|
|
13
|
-
"persyst-extract": "bin/extract.js",
|
|
14
|
-
"persyst-worker": "bin/extract-worker.js"
|
|
15
|
+
"persyst-mcp": "./bin/mcp.js",
|
|
16
|
+
"persyst-setup": "./bin/setup.js",
|
|
17
|
+
"persyst-init": "./bin/init.js",
|
|
18
|
+
"persyst": "./index.js"
|
|
15
19
|
},
|
|
16
20
|
"engines": {
|
|
17
21
|
"node": ">=18.0.0"
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
"scripts": {
|
|
28
32
|
"start": "node index.js",
|
|
29
33
|
"test": "cross-env NODE_ENV=test node test/smoke.js",
|
|
30
|
-
"test:heavy": "
|
|
34
|
+
"test:heavy": "node test/run_sequentially.js",
|
|
31
35
|
"worker": "node bin/extract-worker.js",
|
|
32
36
|
"extract": "node bin/extract.js"
|
|
33
37
|
},
|
|
@@ -60,6 +64,7 @@
|
|
|
60
64
|
"@huggingface/transformers": "^4.2.0",
|
|
61
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
66
|
"better-sqlite3": "^12.10.0",
|
|
67
|
+
"chokidar": "^5.0.0",
|
|
63
68
|
"sqlite-vec": "^0.1.9",
|
|
64
69
|
"zod": "^3.23.0"
|
|
65
70
|
},
|
package/src/attestation.js
CHANGED
|
@@ -9,7 +9,7 @@ import crypto from 'crypto';
|
|
|
9
9
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
|
-
import db, { getLastAttestation, insertAttestation, getAttestationById } from './database.js';
|
|
12
|
+
import db, { stmts, getLastAttestation, insertAttestation, getAttestationById } from './database.js';
|
|
13
13
|
|
|
14
14
|
const KEYS_DIR = join(homedir(), '.persyst', 'keys');
|
|
15
15
|
|
|
@@ -69,7 +69,7 @@ export function createAttestation(query, memories, agentId = null, sessionId = n
|
|
|
69
69
|
return {
|
|
70
70
|
id: m.id,
|
|
71
71
|
content_hash: contentHash,
|
|
72
|
-
score: parseFloat(scoreVal)
|
|
72
|
+
score: Math.round(parseFloat(scoreVal) * 10000)
|
|
73
73
|
};
|
|
74
74
|
});
|
|
75
75
|
|
|
@@ -135,6 +135,22 @@ export function verifyAttestationRecord(attestation) {
|
|
|
135
135
|
};
|
|
136
136
|
|
|
137
137
|
const dataToSign = JSON.stringify(doc);
|
|
138
|
+
const fullRecord = {
|
|
139
|
+
...doc,
|
|
140
|
+
signature: attestation.signature
|
|
141
|
+
};
|
|
142
|
+
const computedHash = crypto.createHash('sha256').update(JSON.stringify(fullRecord)).digest('hex');
|
|
143
|
+
|
|
144
|
+
// Check hash first — if it matches, doc reconstruction is correct
|
|
145
|
+
const hashMatch = computedHash === attestation.hash;
|
|
146
|
+
if (!hashMatch) {
|
|
147
|
+
console.error('[persyst-attest] HASH MISMATCH for', attestation.attestation_id);
|
|
148
|
+
console.error('[persyst-attest] stored hash:', attestation.hash);
|
|
149
|
+
console.error('[persyst-attest] computed hash:', computedHash);
|
|
150
|
+
console.error('[persyst-attest] doc:', JSON.stringify(doc));
|
|
151
|
+
return { valid: false, error: 'Attestation hash mismatch' };
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
const publicKey = getPublicKey();
|
|
139
155
|
|
|
140
156
|
// Verify signature
|
|
@@ -150,20 +166,14 @@ export function verifyAttestationRecord(attestation) {
|
|
|
150
166
|
);
|
|
151
167
|
|
|
152
168
|
if (!isSignatureValid) {
|
|
169
|
+
console.error('[persyst-attest] SIG VERIFY FAIL for', attestation.attestation_id);
|
|
170
|
+
console.error('[persyst-attest] Hash matches but signature invalid');
|
|
171
|
+
console.error('[persyst-attest] dataToSign:', dataToSign);
|
|
172
|
+
console.error('[persyst-attest] signature:', attestation.signature);
|
|
173
|
+
console.error('[persyst-attest] public key:', publicKey);
|
|
153
174
|
return { valid: false, error: 'Signature verification failed' };
|
|
154
175
|
}
|
|
155
176
|
|
|
156
|
-
// Verify hash integrity
|
|
157
|
-
const fullRecord = {
|
|
158
|
-
...doc,
|
|
159
|
-
signature: attestation.signature
|
|
160
|
-
};
|
|
161
|
-
const computedHash = crypto.createHash('sha256').update(JSON.stringify(fullRecord)).digest('hex');
|
|
162
|
-
|
|
163
|
-
if (computedHash !== attestation.hash) {
|
|
164
|
-
return { valid: false, error: 'Attestation hash mismatch' };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
177
|
return { valid: true };
|
|
168
178
|
} catch (err) {
|
|
169
179
|
return { valid: false, error: err.message };
|
|
@@ -171,7 +181,10 @@ export function verifyAttestationRecord(attestation) {
|
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
/**
|
|
174
|
-
*
|
|
184
|
+
* Iteratively verifies signature and chain integrity.
|
|
185
|
+
* Walks backwards from the target attestation to the genesis link,
|
|
186
|
+
* confirming each previous_hash matches the predecessor's actual hash
|
|
187
|
+
* and that sequence order strictly increases.
|
|
175
188
|
*/
|
|
176
189
|
export function verifyChainIntegrity(attestationId) {
|
|
177
190
|
const att = getAttestationById(attestationId);
|
|
@@ -184,29 +197,37 @@ export function verifyChainIntegrity(attestationId) {
|
|
|
184
197
|
return selfVerify;
|
|
185
198
|
}
|
|
186
199
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
200
|
+
// Iterative chain walk — no recursion, no stack overflow risk
|
|
201
|
+
const MAX_CHAIN_DEPTH = 10000;
|
|
202
|
+
let current = att;
|
|
203
|
+
let depth = 0;
|
|
204
|
+
|
|
205
|
+
while (current.previous_hash) {
|
|
206
|
+
if (depth >= MAX_CHAIN_DEPTH) {
|
|
207
|
+
return { valid: false, error: 'Broken chain: chain length exceeds maximum' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const prevAtt = stmts.getAttestationByHash.get(current.previous_hash);
|
|
190
211
|
if (!prevAtt) {
|
|
191
|
-
return { valid: false, error: `Broken chain: Previous attestation with hash ${
|
|
212
|
+
return { valid: false, error: `Broken chain: Previous attestation with hash ${current.previous_hash} not found` };
|
|
192
213
|
}
|
|
193
214
|
|
|
194
|
-
if (prevAtt.
|
|
195
|
-
return { valid: false, error:
|
|
215
|
+
if (prevAtt.hash !== current.previous_hash) {
|
|
216
|
+
return { valid: false, error: 'Broken chain: previous_hash does not match predecessor hash' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (prevAtt.id >= current.id) {
|
|
220
|
+
return { valid: false, error: 'Broken chain: Invalid sequence order' };
|
|
196
221
|
}
|
|
197
222
|
|
|
198
223
|
const prevVerify = verifyAttestationRecord(prevAtt);
|
|
199
224
|
if (!prevVerify.valid) {
|
|
200
225
|
return { valid: false, error: `Broken chain: Previous link is invalid: ${prevVerify.error}` };
|
|
201
226
|
}
|
|
202
|
-
}
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
228
|
+
current = prevAtt;
|
|
229
|
+
depth++;
|
|
230
|
+
}
|
|
206
231
|
|
|
207
|
-
|
|
208
|
-
* Helper to fetch attestation by hash since it's not exposed globally.
|
|
209
|
-
*/
|
|
210
|
-
function getAttestationByHash(hash) {
|
|
211
|
-
return db.prepare('SELECT * FROM attestations WHERE hash = ?').get(hash) || null;
|
|
232
|
+
return { valid: true, attestation: current };
|
|
212
233
|
}
|