persyst-mcp 2.0.0 → 2.1.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/bin/aider.js +204 -0
- package/bin/setup.js +168 -0
- package/hooks/persyst-hook.js +234 -0
- package/index.js +16 -7
- package/package.json +6 -2
- package/src/search.js +2 -2
- package/src/server.js +1 -1
package/bin/aider.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persyst-aider — Aider wrapper with automatic Persyst memory injection
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx persyst-aider [aider-args...]
|
|
8
|
+
*
|
|
9
|
+
* Examples:
|
|
10
|
+
* npx persyst-aider --model anthropic/claude-sonnet-4
|
|
11
|
+
* npx persyst-aider --model openai/gpt-4o --auto-commits
|
|
12
|
+
*
|
|
13
|
+
* How it works:
|
|
14
|
+
* 1. On startup: connects to Persyst, ingests recent git commits
|
|
15
|
+
* 2. Before each prompt: queries Persyst for relevant memories, prepends context
|
|
16
|
+
* 3. On exit: ingests any new git commits created during the session
|
|
17
|
+
*
|
|
18
|
+
* Design decisions:
|
|
19
|
+
* - Only enriches prompts > 15 chars (skip "y", "ok", "/run", etc.)
|
|
20
|
+
* - Does NOT parse Aider's output (too fragile with ANSI codes, streaming, etc.)
|
|
21
|
+
* - Passes all args directly to Aider — fully transparent proxy
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'child_process';
|
|
25
|
+
import { createInterface } from 'readline';
|
|
26
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
27
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
import { dirname, resolve } from 'path';
|
|
30
|
+
|
|
31
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
+
const __dirname = dirname(__filename);
|
|
33
|
+
|
|
34
|
+
const MIN_PROMPT_LENGTH = 15;
|
|
35
|
+
const CWD = process.cwd();
|
|
36
|
+
|
|
37
|
+
// ============================================================
|
|
38
|
+
// MCP CLIENT
|
|
39
|
+
// ============================================================
|
|
40
|
+
|
|
41
|
+
let persystClient = null;
|
|
42
|
+
|
|
43
|
+
async function connectToPersyst() {
|
|
44
|
+
if (persystClient) return persystClient;
|
|
45
|
+
|
|
46
|
+
const persystPath = resolve(__dirname, '..', 'index.js');
|
|
47
|
+
|
|
48
|
+
const transport = new StdioClientTransport({
|
|
49
|
+
command: 'node',
|
|
50
|
+
args: [persystPath]
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
persystClient = new Client({
|
|
54
|
+
name: 'persyst-aider',
|
|
55
|
+
version: '1.0.0'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await persystClient.connect(transport);
|
|
59
|
+
return persystClient;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function callTool(toolName, args) {
|
|
63
|
+
const client = await connectToPersyst();
|
|
64
|
+
const result = await client.callTool({ name: toolName, arguments: args });
|
|
65
|
+
if (result.content && result.content[0] && result.content[0].text) {
|
|
66
|
+
return JSON.parse(result.content[0].text);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function closePersyst() {
|
|
72
|
+
if (persystClient) {
|
|
73
|
+
try { await persystClient.close(); } catch (_) {}
|
|
74
|
+
persystClient = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// MEMORY FUNCTIONS
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ingest recent git commits from the current directory.
|
|
84
|
+
*/
|
|
85
|
+
async function ingestGitCommits() {
|
|
86
|
+
try {
|
|
87
|
+
const result = await callTool('ingest_git_commits', {
|
|
88
|
+
repo_path: CWD,
|
|
89
|
+
count: 15
|
|
90
|
+
});
|
|
91
|
+
if (result && result.added > 0) {
|
|
92
|
+
console.error(`[persyst] Ingested ${result.added} git commits into memory`);
|
|
93
|
+
}
|
|
94
|
+
} catch (_) {
|
|
95
|
+
// Not a git repo or Persyst unavailable — silent
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Search for memories relevant to the user's prompt.
|
|
101
|
+
* Returns a formatted context string or null.
|
|
102
|
+
*/
|
|
103
|
+
async function getMemoryContext(prompt) {
|
|
104
|
+
try {
|
|
105
|
+
const result = await callTool('search_memories', {
|
|
106
|
+
query: prompt.slice(0, 200),
|
|
107
|
+
limit: 5,
|
|
108
|
+
agent_id: 'aider'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!result || !result.results || result.results.length === 0) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lines = ['[Persyst Memory — auto-retrieved context]'];
|
|
116
|
+
for (const mem of result.results) {
|
|
117
|
+
lines.push(`• ${mem.content}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('[End Memory]');
|
|
120
|
+
lines.push('');
|
|
121
|
+
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
} catch (_) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================
|
|
129
|
+
// MAIN
|
|
130
|
+
// ============================================================
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const aiderArgs = process.argv.slice(2);
|
|
134
|
+
|
|
135
|
+
// Check if Aider is available
|
|
136
|
+
console.error('[persyst] Starting Aider with Persyst memory...');
|
|
137
|
+
|
|
138
|
+
// Step 1: Connect to Persyst and ingest git history
|
|
139
|
+
try {
|
|
140
|
+
await connectToPersyst();
|
|
141
|
+
console.error('[persyst] Connected to memory server ✓');
|
|
142
|
+
await ingestGitCommits();
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`[persyst] Warning: Could not connect to memory server: ${err.message}`);
|
|
145
|
+
console.error('[persyst] Aider will run without memory injection.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Step 2: Spawn Aider as a child process
|
|
149
|
+
const aider = spawn('aider', aiderArgs, {
|
|
150
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
151
|
+
shell: true,
|
|
152
|
+
cwd: CWD
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Step 3: Set up stdin interception
|
|
156
|
+
const rl = createInterface({
|
|
157
|
+
input: process.stdin,
|
|
158
|
+
terminal: false
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
rl.on('line', async (line) => {
|
|
162
|
+
const trimmed = line.trim();
|
|
163
|
+
|
|
164
|
+
// Only enrich prompts that are long enough to be real questions
|
|
165
|
+
if (trimmed.length >= MIN_PROMPT_LENGTH && persystClient) {
|
|
166
|
+
try {
|
|
167
|
+
const context = await getMemoryContext(trimmed);
|
|
168
|
+
if (context) {
|
|
169
|
+
// Prepend memory context to the prompt
|
|
170
|
+
aider.stdin.write(context);
|
|
171
|
+
console.error(`[persyst] Injected ${context.split('\n').length - 3} memories`);
|
|
172
|
+
}
|
|
173
|
+
} catch (_) {
|
|
174
|
+
// Memory injection failed — just pass through the original prompt
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Always forward the original line to Aider
|
|
179
|
+
aider.stdin.write(line + '\n');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Handle user Ctrl+C
|
|
183
|
+
process.on('SIGINT', () => {
|
|
184
|
+
aider.kill('SIGINT');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Step 4: On Aider exit, ingest any new commits and clean up
|
|
188
|
+
aider.on('close', async (code) => {
|
|
189
|
+
console.error('[persyst] Aider session ended. Indexing new commits...');
|
|
190
|
+
await ingestGitCommits();
|
|
191
|
+
await closePersyst();
|
|
192
|
+
process.exit(code || 0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Handle stdin close (user closed terminal)
|
|
196
|
+
rl.on('close', () => {
|
|
197
|
+
aider.stdin.end();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
main().catch(err => {
|
|
202
|
+
console.error(`[persyst] Fatal error: ${err.message}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
});
|
package/bin/setup.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persyst-setup — One-command installer for Persyst Claude Code hooks
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx persyst-mcp setup
|
|
8
|
+
*
|
|
9
|
+
* What it does:
|
|
10
|
+
* 1. Copies persyst-hook.js to ~/.persyst/hooks/
|
|
11
|
+
* 2. Creates or merges ~/.claude/settings.json with hook registrations
|
|
12
|
+
* 3. Prints success message with instructions
|
|
13
|
+
*
|
|
14
|
+
* Design:
|
|
15
|
+
* - Non-destructive: merges with existing settings, never overwrites
|
|
16
|
+
* - Cross-platform: works on Windows, macOS, and Linux
|
|
17
|
+
* - Idempotent: safe to run multiple times
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
|
21
|
+
import { join, resolve, dirname } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = dirname(__filename);
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// PATHS
|
|
30
|
+
// ============================================================
|
|
31
|
+
|
|
32
|
+
const HOME = homedir();
|
|
33
|
+
const PERSYST_DIR = join(HOME, '.persyst');
|
|
34
|
+
const PERSYST_HOOKS_DIR = join(PERSYST_DIR, 'hooks');
|
|
35
|
+
const HOOK_DEST = join(PERSYST_HOOKS_DIR, 'persyst-hook.js');
|
|
36
|
+
|
|
37
|
+
const CLAUDE_DIR = join(HOME, '.claude');
|
|
38
|
+
const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
39
|
+
|
|
40
|
+
// Source hook file — shipped with the npm package
|
|
41
|
+
const HOOK_SOURCE = resolve(__dirname, '..', 'hooks', 'persyst-hook.js');
|
|
42
|
+
|
|
43
|
+
// ============================================================
|
|
44
|
+
// HOOK CONFIGURATION
|
|
45
|
+
// ============================================================
|
|
46
|
+
|
|
47
|
+
const HOOK_ENTRY = {
|
|
48
|
+
type: 'command',
|
|
49
|
+
command: `node "${HOOK_DEST}"`
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const HOOK_CONFIG = {
|
|
53
|
+
SessionStart: [
|
|
54
|
+
{
|
|
55
|
+
matcher: '',
|
|
56
|
+
hooks: [{ ...HOOK_ENTRY }]
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
UserPromptSubmit: [
|
|
60
|
+
{
|
|
61
|
+
matcher: '',
|
|
62
|
+
hooks: [{ ...HOOK_ENTRY, timeout: 10 }]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ============================================================
|
|
68
|
+
// HELPERS
|
|
69
|
+
// ============================================================
|
|
70
|
+
|
|
71
|
+
function ensureDir(dir) {
|
|
72
|
+
if (!existsSync(dir)) {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonFile(filePath) {
|
|
78
|
+
try {
|
|
79
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
80
|
+
return JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge Persyst hook entries into existing settings.
|
|
88
|
+
* Does NOT overwrite existing hooks — appends Persyst entries if not already present.
|
|
89
|
+
*/
|
|
90
|
+
function mergeHookSettings(existing) {
|
|
91
|
+
const settings = { ...existing };
|
|
92
|
+
if (!settings.hooks) {
|
|
93
|
+
settings.hooks = {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const [eventName, hookGroups] of Object.entries(HOOK_CONFIG)) {
|
|
97
|
+
if (!settings.hooks[eventName]) {
|
|
98
|
+
settings.hooks[eventName] = [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check if a Persyst hook is already registered
|
|
102
|
+
const alreadyRegistered = settings.hooks[eventName].some(group =>
|
|
103
|
+
group.hooks && group.hooks.some(h =>
|
|
104
|
+
h.command && h.command.includes('persyst-hook')
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (!alreadyRegistered) {
|
|
109
|
+
settings.hooks[eventName].push(...hookGroups);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return settings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============================================================
|
|
117
|
+
// MAIN
|
|
118
|
+
// ============================================================
|
|
119
|
+
|
|
120
|
+
function run() {
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log(' 🧠 Persyst — Claude Code Hook Setup');
|
|
123
|
+
console.log(' ════════════════════════════════════');
|
|
124
|
+
console.log('');
|
|
125
|
+
|
|
126
|
+
// Step 1: Verify hook source exists
|
|
127
|
+
if (!existsSync(HOOK_SOURCE)) {
|
|
128
|
+
console.error(` ❌ Hook source not found at: ${HOOK_SOURCE}`);
|
|
129
|
+
console.error(' Make sure you are running this from the persyst-mcp package.');
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Step 2: Copy hook file to ~/.persyst/hooks/
|
|
134
|
+
console.log(' 📁 Installing hook script...');
|
|
135
|
+
ensureDir(PERSYST_HOOKS_DIR);
|
|
136
|
+
copyFileSync(HOOK_SOURCE, HOOK_DEST);
|
|
137
|
+
console.log(` ✅ Copied to ${HOOK_DEST}`);
|
|
138
|
+
|
|
139
|
+
// Step 3: Merge into ~/.claude/settings.json
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(' ⚙️ Configuring Claude Code...');
|
|
142
|
+
ensureDir(CLAUDE_DIR);
|
|
143
|
+
|
|
144
|
+
const existingSettings = readJsonFile(CLAUDE_SETTINGS);
|
|
145
|
+
const mergedSettings = mergeHookSettings(existingSettings);
|
|
146
|
+
|
|
147
|
+
writeFileSync(CLAUDE_SETTINGS, JSON.stringify(mergedSettings, null, 2) + '\n', 'utf8');
|
|
148
|
+
console.log(` ✅ Updated ${CLAUDE_SETTINGS}`);
|
|
149
|
+
|
|
150
|
+
// Step 4: Print success
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(' ════════════════════════════════════');
|
|
153
|
+
console.log(' ✅ Setup complete!');
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(' Persyst will now automatically:');
|
|
156
|
+
console.log(' • Load your stored memories when Claude Code starts');
|
|
157
|
+
console.log(' • Search for relevant context on every prompt');
|
|
158
|
+
console.log(' • Index your git commits into the memory database');
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(' ⚡ Restart Claude Code to activate the hooks.');
|
|
161
|
+
console.log('');
|
|
162
|
+
console.log(' Memory database: ~/.persyst/persyst.db');
|
|
163
|
+
console.log(' Hook script: ~/.persyst/hooks/persyst-hook.js');
|
|
164
|
+
console.log(' Claude settings: ~/.claude/settings.json');
|
|
165
|
+
console.log('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
run();
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persyst-hook.js — Claude Code Hook for Persyst Memory
|
|
5
|
+
*
|
|
6
|
+
* Automatically injects relevant memories into Claude Code's context
|
|
7
|
+
* on SessionStart and UserPromptSubmit events.
|
|
8
|
+
*
|
|
9
|
+
* How it works:
|
|
10
|
+
* 1. Claude Code sends a JSON payload on stdin with hook_event_name, session_id, cwd, etc.
|
|
11
|
+
* 2. This script connects to the Persyst MCP server via StdioClientTransport.
|
|
12
|
+
* 3. It calls get_optimized_context or search_memories to retrieve relevant memories.
|
|
13
|
+
* 4. It returns a JSON response on stdout with additionalContext for Claude Code to inject.
|
|
14
|
+
*
|
|
15
|
+
* Installation:
|
|
16
|
+
* npx persyst-mcp setup
|
|
17
|
+
*
|
|
18
|
+
* Manual registration in ~/.claude/settings.json:
|
|
19
|
+
* { "hooks": { "SessionStart": [...], "UserPromptSubmit": [...] } }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
23
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import { dirname, resolve } from 'path';
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
+
const __dirname = dirname(__filename);
|
|
29
|
+
|
|
30
|
+
// Minimum prompt length to trigger memory search (skip "y", "ok", "/run", etc.)
|
|
31
|
+
const MIN_PROMPT_LENGTH = 15;
|
|
32
|
+
|
|
33
|
+
// Maximum time to wait for Persyst MCP connection (ms)
|
|
34
|
+
const CONNECTION_TIMEOUT = 8000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read the full JSON payload from stdin.
|
|
38
|
+
* Claude Code sends the hook context as a single JSON object.
|
|
39
|
+
*/
|
|
40
|
+
function readStdin() {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let data = '';
|
|
43
|
+
process.stdin.setEncoding('utf8');
|
|
44
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
45
|
+
process.stdin.on('end', () => {
|
|
46
|
+
try {
|
|
47
|
+
resolve(JSON.parse(data));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
reject(new Error(`Failed to parse stdin JSON: ${e.message}`));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
process.stdin.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Connect to the Persyst MCP server as a client.
|
|
58
|
+
* Uses StdioClientTransport to spawn and communicate with the server.
|
|
59
|
+
*/
|
|
60
|
+
async function connectToPersyst() {
|
|
61
|
+
// Resolve the path to Persyst's index.js relative to this hook file
|
|
62
|
+
const persystPath = resolve(__dirname, '..', 'index.js');
|
|
63
|
+
|
|
64
|
+
const transport = new StdioClientTransport({
|
|
65
|
+
command: 'node',
|
|
66
|
+
args: [persystPath]
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const client = new Client({
|
|
70
|
+
name: 'persyst-hook',
|
|
71
|
+
version: '1.0.0'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Connect with a timeout
|
|
75
|
+
await Promise.race([
|
|
76
|
+
client.connect(transport),
|
|
77
|
+
new Promise((_, reject) =>
|
|
78
|
+
setTimeout(() => reject(new Error('Persyst connection timeout')), CONNECTION_TIMEOUT)
|
|
79
|
+
)
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
return client;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Call a Persyst MCP tool and parse the JSON result.
|
|
87
|
+
*/
|
|
88
|
+
async function callTool(client, toolName, args) {
|
|
89
|
+
const result = await client.callTool({ name: toolName, arguments: args });
|
|
90
|
+
if (result.content && result.content[0] && result.content[0].text) {
|
|
91
|
+
return JSON.parse(result.content[0].text);
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle SessionStart: load project-wide context and ingest git history.
|
|
98
|
+
*/
|
|
99
|
+
async function handleSessionStart(client, input) {
|
|
100
|
+
const cwd = input.cwd || process.cwd();
|
|
101
|
+
const repoName = cwd.replace(/\\/g, '/').split('/').pop();
|
|
102
|
+
|
|
103
|
+
// 1. Get project-wide memory context
|
|
104
|
+
const contextResult = await callTool(client, 'get_optimized_context', {
|
|
105
|
+
query: `Project ${repoName} conventions, architecture, user preferences, coding rules`,
|
|
106
|
+
max_tokens: 2000,
|
|
107
|
+
agent_id: 'claude-code',
|
|
108
|
+
session_id: input.session_id || undefined
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 2. Ingest recent git commits (best-effort, don't fail if not a git repo)
|
|
112
|
+
try {
|
|
113
|
+
await callTool(client, 'ingest_git_commits', {
|
|
114
|
+
repo_path: cwd,
|
|
115
|
+
count: 15
|
|
116
|
+
});
|
|
117
|
+
} catch (_) {
|
|
118
|
+
// Not a git repo or git not available — that's fine
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. Build the additional context string
|
|
122
|
+
let additionalContext = '';
|
|
123
|
+
if (contextResult && contextResult.context) {
|
|
124
|
+
additionalContext = contextResult.context;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 4. Get memory count for status line
|
|
128
|
+
let memoryCount = 0;
|
|
129
|
+
try {
|
|
130
|
+
const recentResult = await callTool(client, 'get_recent_memories', { limit: 1 });
|
|
131
|
+
if (recentResult && recentResult.count !== undefined) {
|
|
132
|
+
// The count from get_recent is just the returned count, not total
|
|
133
|
+
// Use a search to estimate total active memories
|
|
134
|
+
const importantResult = await callTool(client, 'get_important_memories', { limit: 100 });
|
|
135
|
+
memoryCount = importantResult?.count || 0;
|
|
136
|
+
}
|
|
137
|
+
} catch (_) {
|
|
138
|
+
// Non-critical
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (additionalContext) {
|
|
142
|
+
additionalContext = `[Persyst Memory: ${memoryCount} memories loaded for project "${repoName}"]\n${additionalContext}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
hookSpecificOutput: {
|
|
147
|
+
hookEventName: 'SessionStart',
|
|
148
|
+
additionalContext: additionalContext || undefined
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Handle UserPromptSubmit: search for memories relevant to the user's prompt.
|
|
155
|
+
*/
|
|
156
|
+
async function handleUserPromptSubmit(client, input) {
|
|
157
|
+
const prompt = input.prompt || '';
|
|
158
|
+
|
|
159
|
+
// Skip trivial prompts (commands, confirmations, short inputs)
|
|
160
|
+
if (prompt.trim().length < MIN_PROMPT_LENGTH) {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Use search_memories for speed on per-prompt lookups (faster than get_optimized_context)
|
|
165
|
+
const searchResult = await callTool(client, 'search_memories', {
|
|
166
|
+
query: prompt.slice(0, 200), // Truncate very long prompts for search efficiency
|
|
167
|
+
limit: 5,
|
|
168
|
+
agent_id: 'claude-code',
|
|
169
|
+
session_id: input.session_id || undefined
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (!searchResult || !searchResult.results || searchResult.results.length === 0) {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Format memories as context
|
|
177
|
+
let contextLines = ['=== PERSYST MEMORY (auto-retrieved) ==='];
|
|
178
|
+
for (const mem of searchResult.results) {
|
|
179
|
+
contextLines.push(`• [Memory #${mem.id}] ${mem.content}`);
|
|
180
|
+
}
|
|
181
|
+
contextLines.push('=== END MEMORY ===');
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
hookSpecificOutput: {
|
|
185
|
+
hookEventName: 'UserPromptSubmit',
|
|
186
|
+
additionalContext: contextLines.join('\n')
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Main entry point.
|
|
193
|
+
*/
|
|
194
|
+
async function main() {
|
|
195
|
+
let client = null;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const input = await readStdin();
|
|
199
|
+
const eventName = input.hook_event_name;
|
|
200
|
+
|
|
201
|
+
// Only handle events we care about
|
|
202
|
+
if (eventName !== 'SessionStart' && eventName !== 'UserPromptSubmit') {
|
|
203
|
+
console.log(JSON.stringify({}));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Connect to Persyst
|
|
208
|
+
client = await connectToPersyst();
|
|
209
|
+
|
|
210
|
+
let response;
|
|
211
|
+
if (eventName === 'SessionStart') {
|
|
212
|
+
response = await handleSessionStart(client, input);
|
|
213
|
+
} else if (eventName === 'UserPromptSubmit') {
|
|
214
|
+
response = await handleUserPromptSubmit(client, input);
|
|
215
|
+
} else {
|
|
216
|
+
response = {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(JSON.stringify(response));
|
|
220
|
+
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Hooks must NEVER break Claude Code — always fail silently
|
|
223
|
+
console.error(`[persyst-hook] Error: ${err.message}`);
|
|
224
|
+
console.log(JSON.stringify({}));
|
|
225
|
+
} finally {
|
|
226
|
+
// Clean up MCP connection
|
|
227
|
+
if (client) {
|
|
228
|
+
try { await client.close(); } catch (_) {}
|
|
229
|
+
}
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
main();
|
package/index.js
CHANGED
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
* Starts the MCP server on stdio transport.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
|
-
* node index.js (direct)
|
|
11
|
-
* npx persyst-mcp (via npm)
|
|
10
|
+
* node index.js (direct — starts MCP server)
|
|
11
|
+
* npx persyst-mcp (via npm — starts MCP server)
|
|
12
|
+
* npx persyst-mcp setup (install Claude Code hooks)
|
|
12
13
|
* persyst-mcp (if installed globally)
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// Handle subcommands before starting the server
|
|
17
|
+
const subcommand = process.argv[2];
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
19
|
+
if (subcommand === 'setup') {
|
|
20
|
+
// Delegate to the setup CLI
|
|
21
|
+
await import('./bin/setup.js');
|
|
22
|
+
} else {
|
|
23
|
+
// Default: start the MCP server
|
|
24
|
+
const { startServer } = await import('./src/server.js');
|
|
25
|
+
await startServer().catch(err => {
|
|
26
|
+
console.error('❌ Persyst failed to start:', err.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "persyst-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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
|
-
"persyst-mcp": "index.js"
|
|
8
|
+
"persyst-mcp": "index.js",
|
|
9
|
+
"persyst-setup": "bin/setup.js",
|
|
10
|
+
"persyst-aider": "bin/aider.js"
|
|
9
11
|
},
|
|
10
12
|
"engines": {
|
|
11
13
|
"node": ">=18.0.0"
|
|
@@ -13,6 +15,8 @@
|
|
|
13
15
|
"files": [
|
|
14
16
|
"index.js",
|
|
15
17
|
"src/",
|
|
18
|
+
"bin/",
|
|
19
|
+
"hooks/",
|
|
16
20
|
"README.md",
|
|
17
21
|
"LICENSE"
|
|
18
22
|
],
|
package/src/search.js
CHANGED
|
@@ -64,8 +64,8 @@ export async function searchHybrid(queryText, limit = 5, agentId = null, session
|
|
|
64
64
|
keyword_match: isKeywordMatch
|
|
65
65
|
};
|
|
66
66
|
})
|
|
67
|
-
// Filter out low similarity semantic matches if they have no keyword match (threshold 0.
|
|
68
|
-
.filter(r => r.keyword_match || r.similarity >= 0.
|
|
67
|
+
// Filter out low similarity semantic matches if they have no keyword match (threshold 0.30)
|
|
68
|
+
.filter(r => r.keyword_match || r.similarity >= 0.30);
|
|
69
69
|
|
|
70
70
|
// Add keyword-only hits that semantic search missed
|
|
71
71
|
const semanticIds = new Set(semanticResults.map(r => r.id));
|