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.
@@ -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(client, input) {
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
- // 1. Get project-wide memory context
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(client, 'ingest_git_commits', {
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(client, 'get_recent_memories', { limit: 1 });
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(client, 'get_important_memories', { limit: 100 });
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(client, input) {
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(client, 'search_memories', {
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(null, input);
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(null, input),
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
- const child = spawn('C:\\Program Files\\nodejs\\node.exe', [
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
- if (!paths.includes(nodeBin)) paths.push(nodeBin);
48
- if (!paths.includes(gitBin)) paths.push(gitBin);
49
-
50
- // Make sure system folders are there
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.includes(p)) paths.push(p);
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",
4
- "description": "Local-first MCP memory server with hybrid keyword + semantic search for coding agents",
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": "index.js",
9
- "persyst-setup": "bin/setup.js",
10
- "persyst-aider": "bin/aider.js",
11
- "persyst-init": "bin/init.js",
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": "cross-env NODE_ENV=test node --test --test-concurrency=1 test/test_*.js",
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
  },
@@ -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
- * Verifies signature and chain integrity.
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
- // If there's a previous link, check it
188
- if (att.previous_hash) {
189
- const prevAtt = getAttestationByHash(att.previous_hash);
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 ${att.previous_hash} not found` };
212
+ return { valid: false, error: `Broken chain: Previous attestation with hash ${current.previous_hash} not found` };
192
213
  }
193
214
 
194
- if (prevAtt.id >= att.id) {
195
- return { valid: false, error: `Broken chain: Invalid sequence order` };
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
- return { valid: true, attestation: att };
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
  }