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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zayn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Local-first MCP memory server for coding agents.**
4
4
 
5
- Persyst gives AI coding agents (Claude Code, Cursor, Aider, Windsurf) persistent memory across sessions. It stores memories in a local SQLite database with hybrid keyword + semantic search — no cloud, no API keys, works offline.
5
+ Persyst gives AI coding agents (Claude Code, Cursor, VS Code, Aider, Windsurf, Antigravity) persistent memory across sessions. It stores memories in a local SQLite database with hybrid keyword + semantic search — no cloud, no API keys, works offline.
6
6
 
7
7
  ## How It Works
8
8
 
@@ -14,59 +14,96 @@ Your AI Agent ←→ MCP (stdio) ←→ Persyst ←→ SQLite (local)
14
14
  2. **Agent searches memories** → Persyst finds matches by both keywords AND meaning
15
15
  3. **"dark mode" ↔ "night theme"** → Semantic search understands synonyms
16
16
 
17
- ## Quick Start
17
+ > 🚨 **First-Run Note**: On the first start, Persyst will automatically download the local embedding model (`all-MiniLM-L6-v2` ~50MB). This can take 30-60 seconds depending on your connection. The server will log `Loading embedding model...` and then proceed normally.
18
18
 
19
- ### 1. Install
19
+ ---
20
20
 
21
- ```bash
22
- npm install -g persyst-mcp
23
- ```
21
+ ## Quick Start
24
22
 
25
- ### 2. Add to Claude Code
23
+ You don't need to install anything globally. You can run it instantly using `npx`:
26
24
 
27
- Edit your Claude Code MCP config (`claude_desktop_config.json`):
25
+ ### 1. Add to Claude Code or Claude Desktop
28
26
 
27
+ #### Claude Code (CLI)
28
+ Add this to your global configuration file located at `~/.claude.json`:
29
29
  ```json
30
30
  {
31
31
  "mcpServers": {
32
32
  "persyst": {
33
- "command": "persyst-mcp"
33
+ "command": "npx",
34
+ "args": ["-y", "persyst-mcp"]
34
35
  }
35
36
  }
36
37
  }
37
38
  ```
38
39
 
39
- ### 3. Use It
40
-
41
- In Claude Code, the agent can now call tools like:
42
- - `add_memory` — Store a fact
43
- - `search_memories` — Find relevant memories
44
- - `get_memory` — Get a specific memory
45
- - `update_memory` — Update a memory
46
- - `delete_memory` — Remove a memory
47
- - `get_recent_memories` — Latest memories
48
- - `get_important_memories` — Most important memories
40
+ #### Claude Desktop
41
+ Add this to your Claude Desktop configuration file:
42
+ * **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
43
+ * **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
44
+ * **Linux**: `~/.config/Claude/claude_desktop_config.json`
49
45
 
50
- ## Setup for Other Agents
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "persyst": {
50
+ "command": "npx",
51
+ "args": ["-y", "persyst-mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
51
56
 
52
- ### Cursor
57
+ ---
53
58
 
54
- Add to your Cursor MCP settings:
59
+ ## Setup for Other Agents
55
60
 
61
+ ### VS Code (Cline / Roo Code)
62
+ Add this configuration to your user settings under the MCP settings file (`cline_mcp_settings.json`):
56
63
  ```json
57
64
  {
58
- "persyst": {
59
- "command": "persyst-mcp"
65
+ "mcpServers": {
66
+ "persyst": {
67
+ "command": "npx",
68
+ "args": ["-y", "persyst-mcp"]
69
+ }
60
70
  }
61
71
  }
62
72
  ```
63
73
 
64
- ### Aider
74
+ ### Cursor
75
+ Add Persyst in Cursor under **Settings → Features → MCP**:
76
+ 1. Click **+ Add New MCP Server**
77
+ 2. Name: `persyst`
78
+ 3. Type: `stdio`
79
+ 4. Command: `npx -y persyst-mcp`
65
80
 
81
+ ### Aider
82
+ Start Aider from the command line passing the server command:
66
83
  ```bash
67
- # Start the MCP server alongside Aider
68
- persyst-mcp &
84
+ aider --mcp-server persyst:npx -y persyst-mcp
69
85
  ```
86
+ Or append this to your `.aider.conf.yml` project file:
87
+ ```yaml
88
+ mcp-server:
89
+ - name: persyst
90
+ command: npx -y persyst-mcp
91
+ ```
92
+
93
+ ### Antigravity
94
+ Add Persyst to your Antigravity agent configuration file at `~/.gemini/antigravity/mcp_config.json`:
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "persyst": {
99
+ "command": "npx",
100
+ "args": ["-y", "persyst-mcp"]
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ ---
70
107
 
71
108
  ## Available Tools
72
109
 
@@ -76,10 +113,12 @@ persyst-mcp &
76
113
  | `search_memories` | Hybrid keyword + semantic search | `query` (string), `limit` (number) |
77
114
  | `get_memory` | Get memory by ID | `id` (number) |
78
115
  | `update_memory` | Update memory content | `id` (number), `content` (string) |
79
- | `delete_memory` | Delete a memory | `id` (number) |
116
+ | `delete_memory` | Delete a memory and clean up edges | `id` (number) |
80
117
  | `get_recent_memories` | Get latest memories | `limit` (number) |
81
118
  | `get_important_memories` | Get by importance score | `limit` (number) |
82
119
 
120
+ ---
121
+
83
122
  ## How Search Works
84
123
 
85
124
  Persyst uses **hybrid search** — combining two strategies:
@@ -89,28 +128,7 @@ Persyst uses **hybrid search** — combining two strategies:
89
128
 
90
129
  Results from both are merged. Keyword matches get a score boost so exact matches rank higher, but semantic matches still surface related memories.
91
130
 
92
- ## Architecture
93
-
94
- ```
95
- persyst/
96
- ├── index.js ← Entry point (starts MCP server)
97
- ├── src/
98
- │ ├── server.js ← MCP server (stdio transport)
99
- │ ├── database.js ← SQLite + schema + CRUD
100
- │ ├── search.js ← Hybrid search engine
101
- │ ├── embeddings.js ← Local embedding generation
102
- │ └── tools.js ← 7 MCP tool definitions
103
- ├── test/
104
- │ └── smoke.js ← End-to-end test
105
- └── db/ ← Database files (gitignored)
106
- ```
107
-
108
- ## Data Storage
109
-
110
- - Database location: `~/.persyst/persyst.db`
111
- - All data stays on your machine
112
- - No telemetry, no cloud calls, no API keys
113
- - Works offline (airplane mode ✓)
131
+ ---
114
132
 
115
133
  ## Tech Stack
116
134
 
@@ -121,21 +139,26 @@ persyst/
121
139
  - **Embeddings:** @huggingface/transformers + all-MiniLM-L6-v2 (384-dim, ~50MB)
122
140
  - **Protocol:** MCP over stdio
123
141
 
124
- ## Development
142
+ ---
125
143
 
126
- ```bash
127
- # Clone and install
128
- git clone <repo-url>
129
- cd persyst
130
- npm install
144
+ ## Troubleshooting
131
145
 
132
- # Run smoke test
133
- npm test
146
+ #### `better-sqlite3` installation fails
147
+ `better-sqlite3` compiles native C++ code on installation. Make sure you have python and C++ build tools installed on your system:
148
+ * **Windows:** Run `npm install --global windows-build-tools` or install Visual Studio Build Tools.
149
+ * **macOS/Linux:** Run `xcode-select --install` or install `build-essential`.
134
150
 
135
- # Start server directly
136
- node index.js
137
- ```
151
+ #### The agent is stuck or loading forever on startup
152
+ This is normal on the **very first run** because Persyst is downloading the ~50MB embedding model. Wait 30-60 seconds for it to complete. The next runs will be instant.
153
+
154
+ #### Command not found: `persyst-mcp`
155
+ Instead of running it globally, prefer using the `npx -y persyst-mcp` command in your agent configurations. It automatically installs and updates the server non-interactively.
156
+
157
+ #### Permission Denied
158
+ Do not run `npx` with `sudo`. If you run into permission issues, ensure your npm global prefix is owned by your user account.
159
+
160
+ ---
138
161
 
139
162
  ## License
140
163
 
141
- MIT
164
+ MIT License. See [LICENSE](LICENSE) for details.
package/index.js CHANGED
@@ -14,4 +14,7 @@
14
14
 
15
15
  import { startServer } from './src/server.js';
16
16
 
17
- startServer();
17
+ await startServer().catch(err => {
18
+ console.error('❌ Persyst failed to start:', err.message);
19
+ process.exit(1);
20
+ });
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "persyst-mcp",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
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
7
  "bin": {
8
8
  "persyst-mcp": "index.js"
9
9
  },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "src/",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
10
19
  "scripts": {
11
20
  "start": "node index.js",
12
21
  "test": "node test/smoke.js",
@@ -20,7 +29,12 @@
20
29
  "ai",
21
30
  "agents",
22
31
  "semantic-search",
23
- "knowledge-graph"
32
+ "knowledge-graph",
33
+ "claude-code",
34
+ "cursor",
35
+ "aider",
36
+ "windsurf",
37
+ "memory-server"
24
38
  ],
25
39
  "author": "Zayn",
26
40
  "license": "MIT",
@@ -0,0 +1,206 @@
1
+ /**
2
+ * attestation.js — Cryptographic Attestation Engine
3
+ *
4
+ * Implements Ed25519 signature generation and verification for search queries.
5
+ * Chains each attestation by linking to the hash of the previous one.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import db, { getLastAttestation, insertAttestation, getAttestationById } from './database.js';
13
+
14
+ const KEYS_DIR = join(homedir(), '.persyst', 'keys');
15
+
16
+ /**
17
+ * Initialize keypair if it doesn't already exist.
18
+ */
19
+ export function initializeKeys() {
20
+ mkdirSync(KEYS_DIR, { recursive: true });
21
+ const pubPath = join(KEYS_DIR, 'public.pem');
22
+ const privPath = join(KEYS_DIR, 'private.pem');
23
+
24
+ if (!existsSync(pubPath) || !existsSync(privPath)) {
25
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
26
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
27
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
28
+ });
29
+ writeFileSync(pubPath, publicKey);
30
+ writeFileSync(privPath, privateKey);
31
+ console.error('[persyst] Generated new Ed25519 keypair for attestation');
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Read private key.
37
+ */
38
+ function getPrivateKey() {
39
+ const privPath = join(KEYS_DIR, 'private.pem');
40
+ return readFileSync(privPath, 'utf8');
41
+ }
42
+
43
+ /**
44
+ * Read public key.
45
+ */
46
+ export function getPublicKey() {
47
+ const pubPath = join(KEYS_DIR, 'public.pem');
48
+ return readFileSync(pubPath, 'utf8');
49
+ }
50
+
51
+ /**
52
+ * Generate a new attestation for search results.
53
+ */
54
+ export function createAttestation(query, memories, agentId = null, sessionId = null) {
55
+ initializeKeys();
56
+
57
+ const attestationId = crypto.randomUUID();
58
+ const timestamp = new Date().toISOString();
59
+
60
+ // Map memories to {id, content_hash, score}
61
+ const memoriesRetrieved = memories.map(m => {
62
+ const contentHash = crypto.createHash('sha256').update(m.content).digest('hex');
63
+ return {
64
+ id: m.id,
65
+ content_hash: contentHash,
66
+ score: parseFloat(m.hybrid_score || 0)
67
+ };
68
+ });
69
+
70
+ // Fetch previous attestation hash for the hash chain
71
+ const lastAtt = getLastAttestation();
72
+ const previousHash = lastAtt ? lastAtt.hash : null;
73
+
74
+ // Construct document to sign (ordered keys to ensure canonical serialization)
75
+ const doc = {
76
+ attestation_id: attestationId,
77
+ query,
78
+ timestamp,
79
+ memories_retrieved: memoriesRetrieved,
80
+ agent_id: agentId || null,
81
+ session_id: sessionId || null,
82
+ previous_hash: previousHash
83
+ };
84
+
85
+ const dataToSign = JSON.stringify(doc);
86
+
87
+ // Sign document using Ed25519
88
+ const privateKey = getPrivateKey();
89
+ const signature = crypto.sign(null, Buffer.from(dataToSign), {
90
+ key: privateKey,
91
+ type: 'pkcs8',
92
+ format: 'pem'
93
+ }).toString('hex');
94
+
95
+ // Construct full record and compute its hash
96
+ const fullAttestation = {
97
+ ...doc,
98
+ signature
99
+ };
100
+
101
+ const hash = crypto.createHash('sha256').update(JSON.stringify(fullAttestation)).digest('hex');
102
+
103
+ const record = {
104
+ ...fullAttestation,
105
+ hash
106
+ };
107
+
108
+ // Persist to DB
109
+ insertAttestation(record);
110
+
111
+ return record;
112
+ }
113
+
114
+ /**
115
+ * Verify a single attestation record's signature and hash.
116
+ */
117
+ export function verifyAttestationRecord(attestation) {
118
+ try {
119
+ const doc = {
120
+ attestation_id: attestation.attestation_id,
121
+ query: attestation.query,
122
+ timestamp: attestation.timestamp,
123
+ memories_retrieved: typeof attestation.memories_retrieved === 'string'
124
+ ? JSON.parse(attestation.memories_retrieved)
125
+ : attestation.memories_retrieved,
126
+ agent_id: attestation.agent_id || null,
127
+ session_id: attestation.session_id || null,
128
+ previous_hash: attestation.previous_hash || null
129
+ };
130
+
131
+ const dataToSign = JSON.stringify(doc);
132
+ const publicKey = getPublicKey();
133
+
134
+ // Verify signature
135
+ const isSignatureValid = crypto.verify(
136
+ null,
137
+ Buffer.from(dataToSign),
138
+ {
139
+ key: publicKey,
140
+ type: 'spki',
141
+ format: 'pem'
142
+ },
143
+ Buffer.from(attestation.signature, 'hex')
144
+ );
145
+
146
+ if (!isSignatureValid) {
147
+ return { valid: false, error: 'Signature verification failed' };
148
+ }
149
+
150
+ // Verify hash integrity
151
+ const fullRecord = {
152
+ ...doc,
153
+ signature: attestation.signature
154
+ };
155
+ const computedHash = crypto.createHash('sha256').update(JSON.stringify(fullRecord)).digest('hex');
156
+
157
+ if (computedHash !== attestation.hash) {
158
+ return { valid: false, error: 'Attestation hash mismatch' };
159
+ }
160
+
161
+ return { valid: true };
162
+ } catch (err) {
163
+ return { valid: false, error: err.message };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Verifies signature and chain integrity.
169
+ */
170
+ export function verifyChainIntegrity(attestationId) {
171
+ const att = getAttestationById(attestationId);
172
+ if (!att) {
173
+ return { valid: false, error: `Attestation not found: ${attestationId}` };
174
+ }
175
+
176
+ const selfVerify = verifyAttestationRecord(att);
177
+ if (!selfVerify.valid) {
178
+ return selfVerify;
179
+ }
180
+
181
+ // If there's a previous link, check it
182
+ if (att.previous_hash) {
183
+ const prevAtt = getAttestationByHash(att.previous_hash);
184
+ if (!prevAtt) {
185
+ return { valid: false, error: `Broken chain: Previous attestation with hash ${att.previous_hash} not found` };
186
+ }
187
+
188
+ if (prevAtt.id >= att.id) {
189
+ return { valid: false, error: `Broken chain: Invalid sequence order` };
190
+ }
191
+
192
+ const prevVerify = verifyAttestationRecord(prevAtt);
193
+ if (!prevVerify.valid) {
194
+ return { valid: false, error: `Broken chain: Previous link is invalid: ${prevVerify.error}` };
195
+ }
196
+ }
197
+
198
+ return { valid: true, attestation: att };
199
+ }
200
+
201
+ /**
202
+ * Helper to fetch attestation by hash since it's not exposed globally.
203
+ */
204
+ function getAttestationByHash(hash) {
205
+ return db.prepare('SELECT * FROM attestations WHERE hash = ?').get(hash) || null;
206
+ }
package/src/cache.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * cache.js — LRU Query Result Cache
3
+ *
4
+ * In-memory LRU cache for search results to avoid
5
+ * re-computing embeddings for repeated queries.
6
+ *
7
+ * - Configurable max size (default: 100 entries)
8
+ * - Configurable TTL (default: 5 minutes)
9
+ * - Automatic eviction of oldest entries when full
10
+ * - Full invalidation on write operations
11
+ */
12
+
13
+ /**
14
+ * Simple LRU (Least Recently Used) cache with TTL support.
15
+ */
16
+ export class LRUCache {
17
+ /**
18
+ * @param {number} maxSize - Maximum number of entries (default: 100)
19
+ * @param {number} ttlMs - Time-to-live in milliseconds (default: 300000 = 5 min)
20
+ */
21
+ constructor(maxSize = 100, ttlMs = 300000) {
22
+ this.maxSize = maxSize;
23
+ this.ttlMs = ttlMs;
24
+ this.cache = new Map();
25
+ this.hits = 0;
26
+ this.misses = 0;
27
+ }
28
+
29
+ /**
30
+ * Generate a cache key from query parameters.
31
+ * @param {string} query - The search query
32
+ * @param {number} limit - The result limit
33
+ * @returns {string} Cache key
34
+ */
35
+ static key(query, limit) {
36
+ return `${query}::${limit}`;
37
+ }
38
+
39
+ /**
40
+ * Get a cached value if it exists and hasn't expired.
41
+ * Moves the entry to the "most recently used" position.
42
+ *
43
+ * @param {string} key - Cache key
44
+ * @returns {*|null} Cached value or null if miss/expired
45
+ */
46
+ get(key) {
47
+ const entry = this.cache.get(key);
48
+ if (!entry) {
49
+ this.misses++;
50
+ return null;
51
+ }
52
+
53
+ // Check TTL expiry
54
+ if (Date.now() - entry.timestamp > this.ttlMs) {
55
+ this.cache.delete(key);
56
+ this.misses++;
57
+ return null;
58
+ }
59
+
60
+ // Move to end (most recently used) by re-inserting
61
+ this.cache.delete(key);
62
+ this.cache.set(key, entry);
63
+ this.hits++;
64
+ return entry.value;
65
+ }
66
+
67
+ /**
68
+ * Store a value in the cache. Evicts oldest entry if at capacity.
69
+ *
70
+ * @param {string} key - Cache key
71
+ * @param {*} value - Value to cache
72
+ */
73
+ set(key, value) {
74
+ // If key already exists, delete it first (to update position)
75
+ if (this.cache.has(key)) {
76
+ this.cache.delete(key);
77
+ }
78
+
79
+ // Evict oldest (first) entry if at capacity
80
+ if (this.cache.size >= this.maxSize) {
81
+ const oldestKey = this.cache.keys().next().value;
82
+ this.cache.delete(oldestKey);
83
+ }
84
+
85
+ this.cache.set(key, {
86
+ value,
87
+ timestamp: Date.now()
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Invalidate the entire cache. Called on write operations
93
+ * (add_memory, update_memory, delete_memory) to ensure
94
+ * search results are always fresh.
95
+ */
96
+ invalidate() {
97
+ const size = this.cache.size;
98
+ this.cache.clear();
99
+ if (size > 0) {
100
+ console.error(`[persyst-cache] Invalidated ${size} cached entries`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get cache statistics for monitoring.
106
+ * @returns {{ size: number, maxSize: number, ttlMs: number, hits: number, misses: number, hitRate: string }}
107
+ */
108
+ stats() {
109
+ const total = this.hits + this.misses;
110
+ return {
111
+ size: this.cache.size,
112
+ maxSize: this.maxSize,
113
+ ttlMs: this.ttlMs,
114
+ hits: this.hits,
115
+ misses: this.misses,
116
+ hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%'
117
+ };
118
+ }
119
+ }
120
+
121
+ // Singleton instance for search results
122
+ export const searchCache = new LRUCache(100, 300000);