persyst-mcp 2.2.0 → 2.2.2
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/extract-worker.js +330 -14
- package/bin/setup.js +6 -0
- package/hooks/persyst-hook.js +72 -9
- package/package.json +2 -2
- package/src/attestation.js +7 -1
- package/src/database.js +81 -21
- package/src/search.js +127 -61
- package/src/server.js +116 -13
- package/src/tools.js +187 -114
package/bin/extract-worker.js
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
} from 'fs';
|
|
31
31
|
import { fileURLToPath } from 'url';
|
|
32
32
|
import { dirname } from 'path';
|
|
33
|
+
import http from 'http';
|
|
34
|
+
import https from 'https';
|
|
33
35
|
|
|
34
36
|
const __filename = fileURLToPath(import.meta.url);
|
|
35
37
|
const __dirname = dirname(__filename);
|
|
@@ -58,6 +60,331 @@ const DEDUP_SIMILARITY_THRESHOLD = 0.80;
|
|
|
58
60
|
const RECENT_MEMORY_WINDOW_S = 60; // Check last 60 seconds for agent race
|
|
59
61
|
const MIN_CONFIDENCE = 0.65;
|
|
60
62
|
|
|
63
|
+
// ============================================================
|
|
64
|
+
// LLM EXTRACTION PIPELINE (Tiers 1 & 2)
|
|
65
|
+
// ============================================================
|
|
66
|
+
|
|
67
|
+
const EXTRACTION_SYSTEM_PROMPT = `You are a precise developer memory extraction assistant.
|
|
68
|
+
Analyze the following developer conversation turn or transcript and extract any:
|
|
69
|
+
1. Explicit user preferences (e.g. "I prefer HSL colors", "I like clean UI")
|
|
70
|
+
2. Architectural decisions (e.g. "We decided to expose a gateway server on port 4321")
|
|
71
|
+
3. Project stack choices (e.g. "Using Node.js for backend")
|
|
72
|
+
4. Coding rules/styles (e.g. "Always use camelCase for variables")
|
|
73
|
+
5. Project config/settings (e.g. "Port is set to 4321")
|
|
74
|
+
|
|
75
|
+
Do not extract temporary tasks, questions, or vague conversational statements.
|
|
76
|
+
Do not invent facts. Only extract facts that are clearly stated or implied by the developer.
|
|
77
|
+
|
|
78
|
+
You MUST respond with a valid JSON array of objects, and absolutely NOTHING else. No markdown formatting, no explanation.
|
|
79
|
+
Each object must have the following fields:
|
|
80
|
+
- "content": A clean, concise statement of the preference/decision (e.g., "Preference: Use HSL tailwind colors"). Start the content with the prefix indicating the category: "Preference: ...", "Decision: ...", "Stack: ...", "Rule: ...", "Config: ...".
|
|
81
|
+
- "category": One of "preference", "decision", "stack", "naming", "architecture", "rule", "config".
|
|
82
|
+
- "confidence": A float value between 0.65 and 1.0 representing your confidence.
|
|
83
|
+
|
|
84
|
+
Example output:
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
"content": "Preference: Always use vanilla CSS for maximum control.",
|
|
88
|
+
"category": "preference",
|
|
89
|
+
"confidence": 0.95
|
|
90
|
+
}
|
|
91
|
+
]`;
|
|
92
|
+
|
|
93
|
+
function parseJsonArray(text) {
|
|
94
|
+
try {
|
|
95
|
+
let clean = text.trim();
|
|
96
|
+
if (clean.startsWith('```json')) {
|
|
97
|
+
clean = clean.slice(7);
|
|
98
|
+
} else if (clean.startsWith('```')) {
|
|
99
|
+
clean = clean.slice(3);
|
|
100
|
+
}
|
|
101
|
+
if (clean.endsWith('```')) {
|
|
102
|
+
clean = clean.slice(0, -3);
|
|
103
|
+
}
|
|
104
|
+
clean = clean.trim();
|
|
105
|
+
const arr = JSON.parse(clean);
|
|
106
|
+
if (Array.isArray(arr)) return arr;
|
|
107
|
+
return [];
|
|
108
|
+
} catch (err) {
|
|
109
|
+
try {
|
|
110
|
+
const start = text.indexOf('[');
|
|
111
|
+
const end = text.lastIndexOf(']');
|
|
112
|
+
if (start !== -1 && end !== -1) {
|
|
113
|
+
const substring = text.slice(start, end + 1);
|
|
114
|
+
const arr = JSON.parse(substring);
|
|
115
|
+
if (Array.isArray(arr)) return arr;
|
|
116
|
+
}
|
|
117
|
+
} catch (_) {}
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractAnthropic(text, apiKey) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const payload = JSON.stringify({
|
|
125
|
+
model: 'claude-3-5-sonnet-20241022',
|
|
126
|
+
max_tokens: 1000,
|
|
127
|
+
system: EXTRACTION_SYSTEM_PROMPT,
|
|
128
|
+
messages: [
|
|
129
|
+
{ role: 'user', content: `Extract facts from this transcript:\n\n${text}` }
|
|
130
|
+
]
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const req = https.request({
|
|
134
|
+
hostname: 'api.anthropic.com',
|
|
135
|
+
port: 443,
|
|
136
|
+
path: '/v1/messages',
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: {
|
|
139
|
+
'x-api-key': apiKey,
|
|
140
|
+
'anthropic-version': '2023-06-01',
|
|
141
|
+
'content-type': 'application/json',
|
|
142
|
+
'content-length': Buffer.byteLength(payload)
|
|
143
|
+
},
|
|
144
|
+
timeout: 8000
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
req.on('response', (res) => {
|
|
148
|
+
let data = '';
|
|
149
|
+
res.on('data', chunk => { data += chunk; });
|
|
150
|
+
res.on('end', () => {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(data);
|
|
153
|
+
if (res.statusCode !== 200) {
|
|
154
|
+
reject(new Error(`Anthropic error: ${parsed.error?.message || data}`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const contentText = parsed.content?.[0]?.text || '';
|
|
158
|
+
resolve(parseJsonArray(contentText));
|
|
159
|
+
} catch (e) {
|
|
160
|
+
reject(e);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
req.on('error', reject);
|
|
166
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Anthropic timeout')); });
|
|
167
|
+
req.write(payload);
|
|
168
|
+
req.end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractOpenAI(text, apiKey) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const payload = JSON.stringify({
|
|
175
|
+
model: 'gpt-4o-mini',
|
|
176
|
+
messages: [
|
|
177
|
+
{ role: 'system', content: EXTRACTION_SYSTEM_PROMPT },
|
|
178
|
+
{ role: 'user', content: `Extract facts from this transcript:\n\n${text}` }
|
|
179
|
+
],
|
|
180
|
+
response_format: { type: 'json_object' }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const req = https.request({
|
|
184
|
+
hostname: 'api.openai.com',
|
|
185
|
+
port: 443,
|
|
186
|
+
path: '/v1/chat/completions',
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: {
|
|
189
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
190
|
+
'Content-Type': 'application/json',
|
|
191
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
192
|
+
},
|
|
193
|
+
timeout: 8000
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
req.on('response', (res) => {
|
|
197
|
+
let data = '';
|
|
198
|
+
res.on('data', chunk => { data += chunk; });
|
|
199
|
+
res.on('end', () => {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(data);
|
|
202
|
+
if (res.statusCode !== 200) {
|
|
203
|
+
reject(new Error(`OpenAI error: ${parsed.error?.message || data}`));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const contentText = parsed.choices?.[0]?.message?.content || '';
|
|
207
|
+
let obj = JSON.parse(contentText);
|
|
208
|
+
if (Array.isArray(obj)) {
|
|
209
|
+
resolve(obj);
|
|
210
|
+
} else if (obj && typeof obj === 'object') {
|
|
211
|
+
const key = Object.keys(obj).find(k => Array.isArray(obj[k]));
|
|
212
|
+
if (key) {
|
|
213
|
+
resolve(obj[key]);
|
|
214
|
+
} else {
|
|
215
|
+
resolve([]);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
resolve([]);
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
reject(e);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
req.on('error', reject);
|
|
227
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('OpenAI timeout')); });
|
|
228
|
+
req.write(payload);
|
|
229
|
+
req.end();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function extractOllama(text, model) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const payload = JSON.stringify({
|
|
236
|
+
model: model,
|
|
237
|
+
messages: [
|
|
238
|
+
{ role: 'system', content: EXTRACTION_SYSTEM_PROMPT },
|
|
239
|
+
{ role: 'user', content: `Extract facts from this transcript:\n\n${text}` }
|
|
240
|
+
],
|
|
241
|
+
stream: false,
|
|
242
|
+
format: 'json'
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const req = http.request({
|
|
246
|
+
hostname: '127.0.0.1',
|
|
247
|
+
port: 11434,
|
|
248
|
+
path: '/api/chat',
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
253
|
+
},
|
|
254
|
+
timeout: 12000
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
req.on('response', (res) => {
|
|
258
|
+
let data = '';
|
|
259
|
+
res.on('data', chunk => { data += chunk; });
|
|
260
|
+
res.on('end', () => {
|
|
261
|
+
try {
|
|
262
|
+
if (res.statusCode !== 200) {
|
|
263
|
+
reject(new Error(`Ollama error: status ${res.statusCode}`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const parsed = JSON.parse(data);
|
|
267
|
+
const contentText = parsed.message?.content || '';
|
|
268
|
+
let obj = JSON.parse(contentText);
|
|
269
|
+
if (Array.isArray(obj)) {
|
|
270
|
+
resolve(obj);
|
|
271
|
+
} else if (obj && typeof obj === 'object') {
|
|
272
|
+
const key = Object.keys(obj).find(k => Array.isArray(obj[k]));
|
|
273
|
+
if (key) {
|
|
274
|
+
resolve(obj[key]);
|
|
275
|
+
} else {
|
|
276
|
+
resolve([]);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
resolve([]);
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
reject(e);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
req.on('error', reject);
|
|
288
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Ollama timeout')); });
|
|
289
|
+
req.write(payload);
|
|
290
|
+
req.end();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function checkOllamaAlive() {
|
|
295
|
+
return new Promise((resolve) => {
|
|
296
|
+
const req = http.request({
|
|
297
|
+
hostname: '127.0.0.1',
|
|
298
|
+
port: 11434,
|
|
299
|
+
path: '/api/tags',
|
|
300
|
+
method: 'GET',
|
|
301
|
+
timeout: 1000
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
req.on('response', (res) => {
|
|
305
|
+
if (res.statusCode === 200) {
|
|
306
|
+
let data = '';
|
|
307
|
+
res.on('data', chunk => { data += chunk; });
|
|
308
|
+
res.on('end', () => {
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(data);
|
|
311
|
+
const models = parsed.models || [];
|
|
312
|
+
resolve({ alive: true, models: models.map(m => m.name) });
|
|
313
|
+
} catch (_) {
|
|
314
|
+
resolve({ alive: true, models: [] });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
resolve({ alive: false });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
req.on('error', () => {
|
|
323
|
+
resolve({ alive: false });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
req.on('timeout', () => {
|
|
327
|
+
req.destroy();
|
|
328
|
+
resolve({ alive: false });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
req.end();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function extractFacts(text) {
|
|
336
|
+
// Try Anthropic first
|
|
337
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
338
|
+
try {
|
|
339
|
+
log('INFO', 'Attempting Anthropic fact extraction...');
|
|
340
|
+
const facts = await extractAnthropic(text, process.env.ANTHROPIC_API_KEY);
|
|
341
|
+
log('INFO', `Anthropic extraction succeeded: extracted ${facts.length} facts.`);
|
|
342
|
+
return { facts, tier: 'anthropic' };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
log('WARN', `Anthropic extraction failed: ${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Try OpenAI second
|
|
349
|
+
if (process.env.OPENAI_API_KEY) {
|
|
350
|
+
try {
|
|
351
|
+
log('INFO', 'Attempting OpenAI fact extraction...');
|
|
352
|
+
const facts = await extractOpenAI(text, process.env.OPENAI_API_KEY);
|
|
353
|
+
log('INFO', `OpenAI extraction succeeded: extracted ${facts.length} facts.`);
|
|
354
|
+
return { facts, tier: 'openai' };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log('WARN', `OpenAI extraction failed: ${err.message}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Try Ollama third
|
|
361
|
+
try {
|
|
362
|
+
const ollamaStatus = await checkOllamaAlive();
|
|
363
|
+
if (ollamaStatus.alive && ollamaStatus.models && ollamaStatus.models.length > 0) {
|
|
364
|
+
const targetModel = ollamaStatus.models.find(m => m.includes('qwen') || m.includes('coder') || m.includes('llama')) || ollamaStatus.models[0];
|
|
365
|
+
if (targetModel) {
|
|
366
|
+
log('INFO', `Attempting Ollama extraction using model: ${targetModel}...`);
|
|
367
|
+
const facts = await extractOllama(text, targetModel);
|
|
368
|
+
log('INFO', `Ollama extraction succeeded: extracted ${facts.length} facts.`);
|
|
369
|
+
return { facts, tier: \`ollama:\${targetModel}\` };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
log('WARN', `Ollama extraction failed: ${err.message}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Fallback to Heuristic
|
|
377
|
+
log('INFO', 'All LLM extraction options unavailable/failed. Falling back to Heuristic extraction.');
|
|
378
|
+
try {
|
|
379
|
+
const { extractHeuristic } = await import('../src/extractor-heuristic.js');
|
|
380
|
+
const heuristicFacts = extractHeuristic(text);
|
|
381
|
+
return { facts: heuristicFacts, tier: 'heuristic' };
|
|
382
|
+
} catch (err) {
|
|
383
|
+
log('ERROR', `Heuristic extraction fallback failed: ${err.message}`);
|
|
384
|
+
return { facts: [], tier: 'failed' };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
61
388
|
|
|
62
389
|
|
|
63
390
|
// ============================================================
|
|
@@ -285,21 +612,10 @@ async function main() {
|
|
|
285
612
|
try {
|
|
286
613
|
log('INFO', `Processing: ${filename} (retry: ${retryCount})`);
|
|
287
614
|
|
|
288
|
-
const facts =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// 1. Run Tier 2 Heuristic Extraction (always safe, zero cost)
|
|
292
|
-
try {
|
|
293
|
-
const { extractHeuristic } = await import('../src/extractor-heuristic.js');
|
|
294
|
-
heuristicFacts = extractHeuristic(data.text);
|
|
295
|
-
for (const f of heuristicFacts) {
|
|
296
|
-
facts.push({ ...f, tier: 'heuristic' });
|
|
297
|
-
}
|
|
298
|
-
} catch (heurErr) {
|
|
299
|
-
log('ERROR', `Heuristic extraction failed: ${heurErr.message}`);
|
|
300
|
-
}
|
|
615
|
+
const { facts: extractedFacts, tier } = await extractFacts(data.text);
|
|
616
|
+
const facts = extractedFacts.map(f => ({ ...f, tier }));
|
|
301
617
|
|
|
302
|
-
log('INFO', `Extracted ${facts.length}
|
|
618
|
+
log('INFO', `Extracted ${facts.length} fact(s) using tier: ${tier}`);
|
|
303
619
|
|
|
304
620
|
// Deduplicate facts within this run
|
|
305
621
|
const uniqueFacts = [];
|
package/bin/setup.js
CHANGED
package/hooks/persyst-hook.js
CHANGED
|
@@ -33,6 +33,7 @@ import { dirname, resolve, join } from 'path';
|
|
|
33
33
|
import { spawn } from 'child_process';
|
|
34
34
|
import { writeFileSync, readdirSync, mkdirSync, existsSync } from 'fs';
|
|
35
35
|
import { homedir } from 'os';
|
|
36
|
+
import http from 'http';
|
|
36
37
|
|
|
37
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
39
|
const __dirname = dirname(__filename);
|
|
@@ -85,6 +86,59 @@ function readStdin() {
|
|
|
85
86
|
// MCP CLIENT CONNECTION
|
|
86
87
|
// ============================================================
|
|
87
88
|
|
|
89
|
+
let isHttpAvailable = true;
|
|
90
|
+
let stdioClient = null;
|
|
91
|
+
|
|
92
|
+
async function getStdioClient() {
|
|
93
|
+
if (stdioClient) return stdioClient;
|
|
94
|
+
stdioClient = await connectToPersyst();
|
|
95
|
+
return stdioClient;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function callToolViaHttp(toolName, args, timeoutMs = 150) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const payload = JSON.stringify({ name: toolName, arguments: args });
|
|
101
|
+
const req = http.request({
|
|
102
|
+
hostname: '127.0.0.1',
|
|
103
|
+
port: 4321,
|
|
104
|
+
path: '/tool',
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.setTimeout(timeoutMs, () => {
|
|
113
|
+
req.destroy();
|
|
114
|
+
reject(new Error('HTTP timeout'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
req.on('response', (res) => {
|
|
118
|
+
if (res.statusCode !== 200) {
|
|
119
|
+
reject(new Error(`HTTP status ${res.statusCode}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let data = '';
|
|
123
|
+
res.on('data', chunk => { data += chunk; });
|
|
124
|
+
res.on('end', () => {
|
|
125
|
+
try {
|
|
126
|
+
resolve(JSON.parse(data));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
reject(e);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
req.on('error', (err) => {
|
|
134
|
+
reject(err);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
req.write(payload);
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
88
142
|
/**
|
|
89
143
|
* Connect to the Persyst MCP server as a client.
|
|
90
144
|
* Uses StdioClientTransport to spawn and communicate with the server.
|
|
@@ -121,7 +175,20 @@ async function connectToPersyst() {
|
|
|
121
175
|
* Call a Persyst MCP tool and parse the JSON result.
|
|
122
176
|
*/
|
|
123
177
|
async function callTool(client, toolName, args) {
|
|
124
|
-
|
|
178
|
+
if (isHttpAvailable) {
|
|
179
|
+
try {
|
|
180
|
+
const httpResult = await callToolViaHttp(toolName, args, 150);
|
|
181
|
+
if (httpResult && httpResult.content && httpResult.content[0] && httpResult.content[0].text) {
|
|
182
|
+
return JSON.parse(httpResult.content[0].text);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
isHttpAvailable = false;
|
|
186
|
+
process.stderr.write(`[persyst-hook] HTTP Gateway call failed (${err.message}). Falling back to stdio client.\n`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const activeClient = await getStdioClient();
|
|
191
|
+
const result = await activeClient.callTool({ name: toolName, arguments: args });
|
|
125
192
|
if (result.content && result.content[0] && result.content[0].text) {
|
|
126
193
|
return JSON.parse(result.content[0].text);
|
|
127
194
|
}
|
|
@@ -360,8 +427,6 @@ async function handleStop(input) {
|
|
|
360
427
|
// ============================================================
|
|
361
428
|
|
|
362
429
|
async function main() {
|
|
363
|
-
let client = null;
|
|
364
|
-
|
|
365
430
|
try {
|
|
366
431
|
const input = await readStdin();
|
|
367
432
|
const eventName = input.hook_event_name;
|
|
@@ -379,17 +444,15 @@ async function main() {
|
|
|
379
444
|
return;
|
|
380
445
|
}
|
|
381
446
|
|
|
382
|
-
// Connect to Persyst with hard timeout
|
|
383
447
|
const hookStart = Date.now();
|
|
384
|
-
client = await connectToPersyst();
|
|
385
448
|
|
|
386
449
|
let response;
|
|
387
450
|
if (eventName === 'SessionStart') {
|
|
388
|
-
response = await handleSessionStart(
|
|
451
|
+
response = await handleSessionStart(null, input);
|
|
389
452
|
} else if (eventName === 'UserPromptSubmit') {
|
|
390
453
|
// Apply hard timeout for prompt-time hook execution
|
|
391
454
|
response = await Promise.race([
|
|
392
|
-
handleUserPromptSubmit(
|
|
455
|
+
handleUserPromptSubmit(null, input),
|
|
393
456
|
new Promise((resolve) =>
|
|
394
457
|
setTimeout(() => {
|
|
395
458
|
process.stderr.write(`[persyst-hook] UserPromptSubmit hit ${MAX_HOOK_LATENCY_MS}ms timeout, returning partial.\n`);
|
|
@@ -409,8 +472,8 @@ async function main() {
|
|
|
409
472
|
console.log(JSON.stringify({}));
|
|
410
473
|
} finally {
|
|
411
474
|
// Clean up MCP connection
|
|
412
|
-
if (
|
|
413
|
-
try { await
|
|
475
|
+
if (stdioClient) {
|
|
476
|
+
try { await stdioClient.close(); } catch (_) {}
|
|
414
477
|
}
|
|
415
478
|
process.exit(0);
|
|
416
479
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "persyst-mcp",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"start": "node index.js",
|
|
29
29
|
"test": "cross-env NODE_ENV=test node test/smoke.js",
|
|
30
|
-
"test:heavy": "cross-env NODE_ENV=test node --test test/test_*.js",
|
|
30
|
+
"test:heavy": "cross-env NODE_ENV=test node --test --test-concurrency=1 test/test_*.js",
|
|
31
31
|
"worker": "node bin/extract-worker.js",
|
|
32
32
|
"extract": "node bin/extract.js"
|
|
33
33
|
},
|
package/src/attestation.js
CHANGED
|
@@ -60,10 +60,16 @@ export function createAttestation(query, memories, agentId = null, sessionId = n
|
|
|
60
60
|
// Map memories to {id, content_hash, score}
|
|
61
61
|
const memoriesRetrieved = memories.map(m => {
|
|
62
62
|
const contentHash = crypto.createHash('sha256').update(m.content).digest('hex');
|
|
63
|
+
let scoreVal = 0;
|
|
64
|
+
if (m.hybrid_score !== undefined && m.hybrid_score !== null) {
|
|
65
|
+
scoreVal = m.hybrid_score;
|
|
66
|
+
} else if (m.score !== undefined && m.score !== null) {
|
|
67
|
+
scoreVal = m.score;
|
|
68
|
+
}
|
|
63
69
|
return {
|
|
64
70
|
id: m.id,
|
|
65
71
|
content_hash: contentHash,
|
|
66
|
-
score: parseFloat(
|
|
72
|
+
score: parseFloat(scoreVal)
|
|
67
73
|
};
|
|
68
74
|
});
|
|
69
75
|
|