persyst-mcp 2.2.4 → 2.2.5

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 CHANGED
@@ -116,6 +116,13 @@ Add Persyst to your Antigravity agent configuration file at `~/.gemini/antigravi
116
116
  | `delete_memory` | Delete a memory and clean up edges | `id` (number) |
117
117
  | `get_recent_memories` | Get latest memories | `limit` (number) |
118
118
  | `get_important_memories` | Get by importance score | `limit` (number) |
119
+ | `get_optimized_context` | Get compressed, ranked context block | `query` (string), `max_tokens` (number) |
120
+ | `ingest_git_commits` | Import recent git commits as memories | `repo_path` (string), `count` (number) |
121
+ | `consolidate_memories` | Merge highly similar duplicate memories | — |
122
+ | `get_memory_history` | Retrieve all versions of a memory | `query` (string) |
123
+ | `get_agent_stats` | Agent reputation stats | — |
124
+ | `export_audit_log` | Export attestation audit log | `start_date`, `end_date` (ISO8601) |
125
+ | `verify_attestation` | Verify Ed25519 signature chain | `attestation_id` (string) |
119
126
 
120
127
  ---
121
128
 
@@ -159,6 +166,38 @@ Do not run `npx` with `sudo`. If you run into permission issues, ensure your npm
159
166
 
160
167
  ---
161
168
 
169
+ ## Backup & Migration
170
+
171
+ Persyst includes built-in JSONL export/import commands for portable memory backup and cross-machine migration.
172
+
173
+ ```bash
174
+ # Export all memories to a file
175
+ npx persyst-mcp export
176
+ # → persyst-export-<timestamp>.jsonl
177
+
178
+ # Export to a specific file
179
+ npx persyst-mcp export my-backup.jsonl
180
+
181
+ # Preview what would be imported (dry run)
182
+ npx persyst-mcp import my-backup.jsonl --dry-run
183
+
184
+ # Import memories (skips exact & semantic duplicates automatically)
185
+ npx persyst-mcp import my-backup.jsonl
186
+ ```
187
+
188
+ ---
189
+
190
+ ## Roadmap & Future Directions
191
+
192
+ Persyst is built for the privacy-focused solo developer. We are actively hardening the local-first experience before introducing network dependencies.
193
+
194
+ * **File-Based Sync** ✅ **Done**: `persyst-export` / `persyst-import` JSONL commands for backup and migration.
195
+ * **IDE Integrations**: First-class extensions for Cursor, VS Code, and Aider configuration helper commands.
196
+ * **True P2P Sync (Roadmap)**: Peer-to-peer secure sync between developer devices without relying on central cloud servers.
197
+
198
+ ---
199
+
162
200
  ## License
163
201
 
164
202
  MIT License. See [LICENSE](LICENSE) for details.
203
+
package/bin/export.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * export.js — Persyst Memory JSONL Export CLI
5
+ *
6
+ * Exports all active memories to a portable JSONL file for backup or migration.
7
+ * Each line is a JSON object representing one memory with its full metadata.
8
+ *
9
+ * Usage:
10
+ * persyst-export → exports to persyst-export-<timestamp>.jsonl
11
+ * persyst-export memories.jsonl → exports to memories.jsonl
12
+ * persyst-export --namespace=shared → exports only the shared namespace
13
+ * persyst-export --all → includes archived (valid_until IS NOT NULL) memories
14
+ *
15
+ * The output format is designed to be imported back via `persyst-import`.
16
+ */
17
+
18
+ import { createWriteStream } from 'fs';
19
+ import db, { closeDatabase } from '../src/database.js';
20
+
21
+ // ============================================================
22
+ // ARG PARSING
23
+ // ============================================================
24
+
25
+ const args = process.argv.slice(2);
26
+ const outputFile = args.find(a => !a.startsWith('--')) || `persyst-export-${Date.now()}.jsonl`;
27
+ const namespace = (args.find(a => a.startsWith('--namespace=')) || '').replace('--namespace=', '') || null;
28
+ const includeArchived = args.includes('--all');
29
+
30
+ // ============================================================
31
+ // EXPORT
32
+ // ============================================================
33
+
34
+ try {
35
+ const conditions = [];
36
+ const params = [];
37
+
38
+ if (!includeArchived) {
39
+ conditions.push('m.valid_until IS NULL');
40
+ }
41
+ if (namespace) {
42
+ conditions.push("(m.namespace = ? OR m.namespace = 'shared')");
43
+ params.push(namespace);
44
+ }
45
+
46
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
47
+
48
+ const query = `
49
+ SELECT
50
+ m.id,
51
+ m.content,
52
+ m.importance_score,
53
+ m.namespace,
54
+ m.created_at,
55
+ m.last_accessed,
56
+ m.access_count,
57
+ m.parent_id,
58
+ m.valid_until,
59
+ p.source_type,
60
+ p.source_id,
61
+ p.confidence
62
+ FROM memories m
63
+ LEFT JOIN provenance p ON p.memory_id = m.id
64
+ ${whereClause}
65
+ ORDER BY m.id ASC
66
+ `;
67
+
68
+ const rows = params.length > 0 ? db.prepare(query).all(...params) : db.prepare(query).all();
69
+
70
+ const out = createWriteStream(outputFile, { encoding: 'utf8' });
71
+
72
+ let count = 0;
73
+ for (const row of rows) {
74
+ const record = {
75
+ id: row.id,
76
+ content: row.content,
77
+ importance_score: row.importance_score,
78
+ namespace: row.namespace || 'shared',
79
+ created_at: row.created_at,
80
+ last_accessed: row.last_accessed,
81
+ access_count: row.access_count,
82
+ parent_id: row.parent_id ?? null,
83
+ valid_until: row.valid_until ?? null,
84
+ provenance: row.source_type
85
+ ? {
86
+ source_type: row.source_type,
87
+ source_id: row.source_id ?? null,
88
+ confidence: row.confidence ?? 1.0
89
+ }
90
+ : null
91
+ };
92
+ out.write(JSON.stringify(record) + '\n');
93
+ count++;
94
+ }
95
+
96
+ await new Promise((resolve, reject) => {
97
+ out.end((err) => {
98
+ if (err) reject(err);
99
+ else resolve();
100
+ });
101
+ });
102
+
103
+ console.log(`✅ Exported ${count} memories to: ${outputFile}`);
104
+ if (namespace) {
105
+ console.log(` Namespace filter: "${namespace}" + shared`);
106
+ }
107
+ if (includeArchived) {
108
+ console.log(' Includes archived (superseded) memories.');
109
+ }
110
+
111
+ } catch (err) {
112
+ console.error(`❌ Export failed: ${err.message}`);
113
+ process.exit(1);
114
+ } finally {
115
+ closeDatabase();
116
+ }
package/bin/import.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * import.js — Persyst Memory JSONL Import CLI
5
+ *
6
+ * Imports memories from a JSONL file created by `persyst-export`.
7
+ * Regenerates vector embeddings for each imported memory.
8
+ * Skips duplicates using both exact-content and semantic similarity checks.
9
+ *
10
+ * Usage:
11
+ * persyst-import memories.jsonl
12
+ * persyst-import memories.jsonl --dry-run → preview without writing
13
+ * persyst-import memories.jsonl --namespace=shared → force all into shared namespace
14
+ * persyst-import memories.jsonl --skip-embeddings → skip re-generating embeddings (fast, no semantic search for these)
15
+ *
16
+ * Compatible with the JSONL format produced by `persyst-export`.
17
+ */
18
+
19
+ import { createReadStream } from 'fs';
20
+ import { createInterface } from 'readline';
21
+ import db, {
22
+ insertMemory,
23
+ insertVector,
24
+ memoryExists,
25
+ closeDatabase
26
+ } from '../src/database.js';
27
+ import { generateEmbedding } from '../src/embeddings.js';
28
+ import { searchHybrid } from '../src/search.js';
29
+
30
+ // ============================================================
31
+ // ARG PARSING
32
+ // ============================================================
33
+
34
+ const args = process.argv.slice(2);
35
+ const inputFile = args.find(a => !a.startsWith('--'));
36
+ const isDryRun = args.includes('--dry-run');
37
+ const forceNamespace = (args.find(a => a.startsWith('--namespace=')) || '').replace('--namespace=', '') || null;
38
+ const skipEmbeddings = args.includes('--skip-embeddings');
39
+
40
+ const DEDUP_THRESHOLD = 0.85;
41
+
42
+ if (!inputFile) {
43
+ console.error('❌ Usage: persyst-import <file.jsonl> [--dry-run] [--namespace=<ns>] [--skip-embeddings]');
44
+ process.exit(1);
45
+ }
46
+
47
+ // ============================================================
48
+ // MAIN
49
+ // ============================================================
50
+
51
+ async function main() {
52
+ console.log(`📥 Persyst Import${isDryRun ? ' (DRY RUN — nothing will be written)' : ''}`);
53
+ console.log(` Source: ${inputFile}`);
54
+ if (forceNamespace) console.log(` Forcing namespace: "${forceNamespace}"`);
55
+ if (skipEmbeddings) console.log(' Skipping embedding regeneration.');
56
+ console.log('');
57
+
58
+ const rl = createInterface({
59
+ input: createReadStream(inputFile, { encoding: 'utf8' }),
60
+ crlfDelay: Infinity
61
+ });
62
+
63
+ let lineNum = 0;
64
+ let imported = 0;
65
+ let skipped = 0;
66
+ let errors = 0;
67
+
68
+ for await (const line of rl) {
69
+ lineNum++;
70
+ const trimmed = line.trim();
71
+ if (!trimmed) continue;
72
+
73
+ let record;
74
+ try {
75
+ record = JSON.parse(trimmed);
76
+ } catch (err) {
77
+ console.error(` ⚠️ Line ${lineNum}: Invalid JSON — skipping`);
78
+ errors++;
79
+ continue;
80
+ }
81
+
82
+ const { content, importance_score = 1.0, namespace, provenance, valid_until } = record;
83
+
84
+ if (!content || typeof content !== 'string' || content.trim().length === 0) {
85
+ console.error(` ⚠️ Line ${lineNum}: Empty content — skipping`);
86
+ errors++;
87
+ continue;
88
+ }
89
+
90
+ // Skip archived memories (unless they have a parent_id, we skip them anyway)
91
+ if (valid_until !== null && valid_until !== undefined) {
92
+ skipped++;
93
+ continue;
94
+ }
95
+
96
+ const targetNamespace = forceNamespace || namespace || 'shared';
97
+
98
+ // --- Dedup: exact content match ---
99
+ if (memoryExists(content, targetNamespace)) {
100
+ console.log(` ⏭️ Line ${lineNum}: Already exists — skipping "${content.slice(0, 60)}..."`);
101
+ skipped++;
102
+ continue;
103
+ }
104
+
105
+ // --- Dedup: semantic similarity ---
106
+ if (!skipEmbeddings) {
107
+ try {
108
+ const similar = await searchHybrid(content, 1, null, null, targetNamespace);
109
+ if (similar.length > 0 && parseFloat(similar[0].similarity) >= DEDUP_THRESHOLD) {
110
+ console.log(` ⏭️ Line ${lineNum}: Semantically similar to #${similar[0].id} (sim=${similar[0].similarity}) — skipping`);
111
+ skipped++;
112
+ continue;
113
+ }
114
+ } catch (_) {
115
+ // Non-critical: proceed with import if semantic check fails
116
+ }
117
+ }
118
+
119
+ if (isDryRun) {
120
+ console.log(` ✅ Would import: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ns="${targetNamespace}"`);
121
+ imported++;
122
+ continue;
123
+ }
124
+
125
+ // --- Write to DB ---
126
+ try {
127
+ const prov = provenance || { source_type: 'import', source_id: 'persyst-import', confidence: 1.0 };
128
+ const id = insertMemory(content, importance_score, prov, targetNamespace);
129
+
130
+ if (!skipEmbeddings) {
131
+ const embedding = await generateEmbedding(content);
132
+ insertVector(id, embedding);
133
+ }
134
+
135
+ console.log(` ✅ Imported #${id}: "${content.slice(0, 70)}${content.length > 70 ? '...' : ''}"`);
136
+ imported++;
137
+ } catch (err) {
138
+ console.error(` ❌ Line ${lineNum}: Failed to insert — ${err.message}`);
139
+ errors++;
140
+ }
141
+ }
142
+
143
+ console.log('');
144
+ console.log('═'.repeat(50));
145
+ if (isDryRun) {
146
+ console.log(`📊 Dry run complete: ${imported} would import, ${skipped} skipped, ${errors} errors`);
147
+ } else {
148
+ console.log(`📊 Import complete: ${imported} imported, ${skipped} skipped, ${errors} errors`);
149
+ }
150
+ console.log('═'.repeat(50));
151
+ }
152
+
153
+ main()
154
+ .catch(err => {
155
+ console.error(`❌ Import crashed: ${err.message}`);
156
+ process.exit(1);
157
+ })
158
+ .finally(() => {
159
+ closeDatabase();
160
+ });
@@ -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
  ], {
@@ -80,6 +82,14 @@ if (subcommand === 'setup') {
80
82
  } else if (subcommand === 'worker') {
81
83
  // Run the background extraction worker directly
82
84
  await import('./bin/extract-worker.js');
85
+ } else if (subcommand === 'export') {
86
+ // Export memories to a JSONL file
87
+ process.argv.splice(2, 1);
88
+ await import('./bin/export.js');
89
+ } else if (subcommand === 'import') {
90
+ // Import memories from a JSONL file
91
+ process.argv.splice(2, 1);
92
+ await import('./bin/import.js');
83
93
  } else {
84
94
  // Default: start the MCP server
85
95
  const { startServer } = await import('./src/server.js');
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "persyst-mcp",
3
- "version": "2.2.4",
3
+ "version": "2.2.5",
4
4
  "description": "Local-first MCP memory server 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
15
  "persyst-mcp": "index.js",
9
16
  "persyst-setup": "bin/setup.js",
@@ -11,7 +18,9 @@
11
18
  "persyst-init": "bin/init.js",
12
19
  "persyst-ingest": "bin/ingest.js",
13
20
  "persyst-extract": "bin/extract.js",
14
- "persyst-worker": "bin/extract-worker.js"
21
+ "persyst-worker": "bin/extract-worker.js",
22
+ "persyst-export": "bin/export.js",
23
+ "persyst-import": "bin/import.js"
15
24
  },
16
25
  "engines": {
17
26
  "node": ">=18.0.0"
@@ -27,7 +36,7 @@
27
36
  "scripts": {
28
37
  "start": "node index.js",
29
38
  "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",
39
+ "test:heavy": "node test/run_sequentially.js",
31
40
  "worker": "node bin/extract-worker.js",
32
41
  "extract": "node bin/extract.js"
33
42
  },
@@ -60,6 +69,7 @@
60
69
  "@huggingface/transformers": "^4.2.0",
61
70
  "@modelcontextprotocol/sdk": "^1.29.0",
62
71
  "better-sqlite3": "^12.10.0",
72
+ "chokidar": "^5.0.0",
63
73
  "sqlite-vec": "^0.1.9",
64
74
  "zod": "^3.23.0"
65
75
  },
package/src/database.js CHANGED
@@ -34,6 +34,9 @@ const db = new Database(DB_PATH);
34
34
  db.pragma('journal_mode = WAL'); // Better performance for concurrent reads
35
35
  db.pragma('foreign_keys = ON'); // Enforce referential integrity
36
36
  db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O for faster reads
37
+ db.pragma('synchronous = NORMAL'); // Performance boost for WAL mode
38
+ db.pragma('temp_store = MEMORY'); // Keep temp tables in memory
39
+ db.pragma('cache_size = -64000'); // 64MB cache size
37
40
 
38
41
  // Load sqlite-vec BEFORE creating any vec0 tables
39
42
  sqliteVec.load(db);
@@ -330,7 +333,7 @@ const stmts = {
330
333
  decay: db.prepare(`
331
334
  UPDATE memories
332
335
  SET importance_score = ROUND(MAX(importance_score * 0.95, 0.0), 4)
333
- WHERE (? - last_accessed) > 604800
336
+ WHERE (unixepoch() - last_accessed) > 604800
334
337
  `),
335
338
 
336
339
  // -- Search --
@@ -615,8 +618,7 @@ export function boostMemory(id) {
615
618
  * Called automatically every hour by the server.
616
619
  */
617
620
  export function applyTemporalDecay() {
618
- const now = Math.floor(Date.now() / 1000);
619
- const result = stmts.decay.run(now);
621
+ const result = stmts.decay.run();
620
622
  if (result.changes > 0) {
621
623
  console.error(`[persyst] Decay applied to ${result.changes} memories`);
622
624
  }
@@ -749,7 +751,7 @@ export function memoryExistsByHashPrefix(pattern) {
749
751
  * @returns {number}
750
752
  */
751
753
  export function getActiveMemoryCount(namespace = null) {
752
- if (namespace) {
754
+ if (namespace && namespace !== 'all') {
753
755
  return stmts.getActiveMemoryCountNs.get(namespace).count;
754
756
  }
755
757
  return stmts.getActiveMemoryCount.get().count;
package/src/events.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * events.js — Persyst In-Process Memory Event Bus
3
+ *
4
+ * A shared EventEmitter used by the HTTP gateway (SSE broadcasting),
5
+ * the log watcher, and the MCP tool handlers to signal memory changes
6
+ * in real-time without tight coupling.
7
+ *
8
+ * Events emitted:
9
+ * memory_added { id, content, namespace, source }
10
+ * memory_deleted { id }
11
+ * memories_consolidated { consolidated_groups, details }
12
+ */
13
+
14
+ import { EventEmitter } from 'events';
15
+
16
+ export const memoryEventBus = new EventEmitter();
17
+
18
+ // Support large swarms with many simultaneous SSE subscribers
19
+ memoryEventBus.setMaxListeners(500);