persyst-mcp 2.2.1 → 2.2.3
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/database.js +30 -17
- package/src/search.js +34 -4
- package/src/server.js +124 -13
- package/src/tools.js +155 -108
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.3",
|
|
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/database.js
CHANGED
|
@@ -288,7 +288,7 @@ const stmts = {
|
|
|
288
288
|
"SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY importance_score DESC LIMIT ?"
|
|
289
289
|
),
|
|
290
290
|
getProvenance: db.prepare(
|
|
291
|
-
'SELECT * FROM provenance WHERE memory_id = ?'
|
|
291
|
+
'SELECT * FROM provenance WHERE memory_id = ? ORDER BY id DESC'
|
|
292
292
|
),
|
|
293
293
|
getAllAgentStats: db.prepare(
|
|
294
294
|
'SELECT * FROM agent_stats ORDER BY reputation_score DESC'
|
|
@@ -544,6 +544,12 @@ export function deleteVec(id) {
|
|
|
544
544
|
export function deleteMemory(id) {
|
|
545
545
|
stmts.deleteEdgesByMemory.run(id, id);
|
|
546
546
|
deleteVec(id); // Remove vector first (no cascades on virtual tables)
|
|
547
|
+
try {
|
|
548
|
+
db.prepare('DELETE FROM provenance WHERE memory_id = ?').run(id);
|
|
549
|
+
db.prepare('DELETE FROM contradictions WHERE old_memory_id = ? OR new_memory_id = ?').run(id, id);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
console.error(`[persyst] Clean up provenance/contradictions error: ${e.message}`);
|
|
552
|
+
}
|
|
547
553
|
const result = stmts.deleteMemory.run(id);
|
|
548
554
|
return result.changes > 0;
|
|
549
555
|
}
|
|
@@ -769,9 +775,11 @@ export function logContradiction(oldMemoryId, newMemoryId, reason = '') {
|
|
|
769
775
|
stmts.archiveMemory.run(oldMemoryId);
|
|
770
776
|
stmts.insertContradiction.run(oldMemoryId, newMemoryId, reason);
|
|
771
777
|
|
|
772
|
-
// Set parent_id to link memories for bidirectional history tracing
|
|
778
|
+
// Set parent_id to link memories for bidirectional history tracing (always newer pointing to older)
|
|
773
779
|
try {
|
|
774
|
-
|
|
780
|
+
const parentId = Math.min(oldMemoryId, newMemoryId);
|
|
781
|
+
const childId = Math.max(oldMemoryId, newMemoryId);
|
|
782
|
+
db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(parentId, childId);
|
|
775
783
|
} catch (e) {
|
|
776
784
|
console.error(`[persyst] Failed to set parent_id on contradiction: ${e.message}`);
|
|
777
785
|
}
|
|
@@ -812,6 +820,9 @@ export function getProvenance(memoryId) {
|
|
|
812
820
|
*/
|
|
813
821
|
export function incrementAgentStat(agentId, action) {
|
|
814
822
|
const normalizedAgentId = agentId.toLowerCase();
|
|
823
|
+
if (normalizedAgentId === 'antigravity-worker' || normalizedAgentId === 'user-dialogue') {
|
|
824
|
+
return; // Ignore internal/system identities from reputation penalties
|
|
825
|
+
}
|
|
815
826
|
stmts.upsertAgent.run(normalizedAgentId);
|
|
816
827
|
if (action === 'created') {
|
|
817
828
|
stmts.incrementCreated.run(normalizedAgentId);
|
|
@@ -910,26 +921,28 @@ export function getMemoryHistoryChain(memoryId) {
|
|
|
910
921
|
|
|
911
922
|
const placeholders = ids.map(() => '?').join(',');
|
|
912
923
|
const rows = db.prepare(`
|
|
913
|
-
SELECT
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
WHERE m.id IN (${placeholders})
|
|
917
|
-
ORDER BY m.created_at ASC
|
|
924
|
+
SELECT * FROM memories
|
|
925
|
+
WHERE id IN (${placeholders})
|
|
926
|
+
ORDER BY created_at ASC
|
|
918
927
|
`).all(...ids);
|
|
919
928
|
|
|
920
|
-
const uniqueRows = [];
|
|
921
|
-
const seenIds = new Set();
|
|
922
929
|
for (const row of rows) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
930
|
+
const prov = getProvenance(row.id);
|
|
931
|
+
if (prov) {
|
|
932
|
+
row.source_type = prov.source_type;
|
|
933
|
+
row.source_id = prov.source_id;
|
|
934
|
+
row.confidence = prov.confidence;
|
|
935
|
+
} else {
|
|
936
|
+
row.source_type = 'manual';
|
|
937
|
+
row.source_id = null;
|
|
938
|
+
row.confidence = 1.0;
|
|
939
|
+
}
|
|
940
|
+
if (row.source_type === 'agent' && row.source_id) {
|
|
941
|
+
row.source_id = row.source_id.toLowerCase();
|
|
929
942
|
}
|
|
930
943
|
}
|
|
931
944
|
|
|
932
|
-
return
|
|
945
|
+
return rows;
|
|
933
946
|
}
|
|
934
947
|
|
|
935
948
|
/**
|
package/src/search.js
CHANGED
|
@@ -290,11 +290,12 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
// BFS to traverse memories and entities uniformly up to depth
|
|
293
|
+
// BFS to traverse memories and entities uniformly up to depth 6
|
|
294
294
|
while (hopQueue.length > 0) {
|
|
295
295
|
const { id, type, depth } = hopQueue.shift();
|
|
296
|
-
if (depth >=
|
|
296
|
+
if (depth >= 6) continue;
|
|
297
297
|
|
|
298
|
+
// --- 2a. Explicit Graph Edges (from edges table) ---
|
|
298
299
|
const connectedEdges = db.prepare(`
|
|
299
300
|
SELECT * FROM edges
|
|
300
301
|
WHERE (source_id = ? AND source_type = ?)
|
|
@@ -317,6 +318,35 @@ export async function getOptimizedContext(queryText, maxTokens, agentId = null,
|
|
|
317
318
|
hopQueue.push({ id: nextId, type: nextType, depth: depth + 1 });
|
|
318
319
|
}
|
|
319
320
|
}
|
|
321
|
+
|
|
322
|
+
// --- 2b. Implicit Name-Based Edges (for robustness when explicit edges are missing) ---
|
|
323
|
+
if (type === 'memory') {
|
|
324
|
+
const memoryRow = db.prepare('SELECT content FROM memories WHERE id = ?').get(id);
|
|
325
|
+
if (memoryRow && memoryRow.content) {
|
|
326
|
+
const contentLower = memoryRow.content.toLowerCase();
|
|
327
|
+
for (const ent of entities) {
|
|
328
|
+
if (contentLower.includes(ent.name.toLowerCase())) {
|
|
329
|
+
const nextKey = `entity:${ent.id}`;
|
|
330
|
+
if (!visitedNodes.has(nextKey)) {
|
|
331
|
+
visitedNodes.add(nextKey);
|
|
332
|
+
hopQueue.push({ id: ent.id, type: 'entity', depth: depth + 1 });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (type === 'entity') {
|
|
338
|
+
const ent = entities.find(e => e.id === id);
|
|
339
|
+
if (ent && ent.name) {
|
|
340
|
+
const matchingMemories = db.prepare('SELECT id FROM memories WHERE content LIKE ? AND valid_until IS NULL').all(`%${ent.name}%`);
|
|
341
|
+
for (const row of matchingMemories) {
|
|
342
|
+
const nextKey = `memory:${row.id}`;
|
|
343
|
+
if (!visitedNodes.has(nextKey)) {
|
|
344
|
+
visitedNodes.add(nextKey);
|
|
345
|
+
hopQueue.push({ id: row.id, type: 'memory', depth: depth + 1 });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
320
350
|
}
|
|
321
351
|
|
|
322
352
|
// Now collect all hopped memories from the visited nodes
|
|
@@ -444,7 +474,7 @@ function checkRelationship(a, b) {
|
|
|
444
474
|
}
|
|
445
475
|
|
|
446
476
|
// Contradiction: similar topic, differing key terms
|
|
447
|
-
if (jaccard > 0.15 && jaccard < 0.
|
|
477
|
+
if (jaccard > 0.15 && jaccard < 0.65) {
|
|
448
478
|
return { type: 'contradiction' };
|
|
449
479
|
}
|
|
450
480
|
|
|
@@ -489,7 +519,7 @@ export async function consolidateMemories(namespace = null) {
|
|
|
489
519
|
SELECT rowid AS id, distance
|
|
490
520
|
FROM memories_vec
|
|
491
521
|
WHERE embedding MATCH ?
|
|
492
|
-
AND k =
|
|
522
|
+
AND k = 30
|
|
493
523
|
`).all(embedding.embedding);
|
|
494
524
|
|
|
495
525
|
const group = [];
|
package/src/server.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* server.js — MCP Server Setup
|
|
2
|
+
* server.js — MCP Server & Local HTTP Gateway Setup
|
|
3
3
|
*
|
|
4
|
-
* Creates the MCP server, registers all tools, and connects
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Creates the MCP server, registers all tools, and connects via stdio.
|
|
5
|
+
* Also spins up a local HTTP/JSON Gateway on port 4321 to support low-latency
|
|
6
|
+
* prompt hooks and local agent swarms without subprocess overhead.
|
|
7
7
|
*
|
|
8
|
-
* IMPORTANT: Never write to stdout — it's reserved for MCP protocol.
|
|
9
8
|
* All logging goes to stderr via console.error().
|
|
10
9
|
*/
|
|
11
10
|
|
|
11
|
+
import http from 'http';
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
-
import { registerTools, cleanupWatchers } from './tools.js';
|
|
14
|
+
import { registerTools, cleanupWatchers, addMemoryInternal, executeToolInternal } from './tools.js';
|
|
15
15
|
import { applyTemporalDecay, closeDatabase } from './database.js';
|
|
16
|
-
import { consolidateMemories } from './search.js';
|
|
16
|
+
import { consolidateMemories, searchHybrid, getOptimizedContext } from './search.js';
|
|
17
17
|
import { startWatcher, stopWatcher } from './watcher.js';
|
|
18
|
+
import { verifyChainIntegrity } from './attestation.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Start the Persyst MCP server.
|
|
21
|
-
* This is called from index.js (the entry point).
|
|
21
|
+
* Start the Persyst MCP server & HTTP Gateway.
|
|
22
22
|
*/
|
|
23
23
|
export async function startServer() {
|
|
24
24
|
// --- Create MCP server ---
|
|
25
25
|
const server = new McpServer({
|
|
26
26
|
name: 'persyst',
|
|
27
|
-
version: '2.1
|
|
27
|
+
version: '2.2.1'
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
// --- Register all tools ---
|
|
@@ -34,12 +34,122 @@ export async function startServer() {
|
|
|
34
34
|
// --- Start background log watcher daemon ---
|
|
35
35
|
startWatcher();
|
|
36
36
|
|
|
37
|
+
// --- Start local HTTP Gateway (port 4321) ---
|
|
38
|
+
const httpPort = 4321;
|
|
39
|
+
const httpServer = http.createServer((req, res) => {
|
|
40
|
+
// CORS headers for local swarms and browser testing
|
|
41
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
43
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
44
|
+
|
|
45
|
+
if (req.method === 'OPTIONS') {
|
|
46
|
+
res.writeHead(204);
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (req.method !== 'POST') {
|
|
52
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed. Use POST.' }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let body = '';
|
|
58
|
+
req.on('data', chunk => { body += chunk; });
|
|
59
|
+
req.on('end', async () => {
|
|
60
|
+
try {
|
|
61
|
+
const payload = JSON.parse(body || '{}');
|
|
62
|
+
|
|
63
|
+
if (req.url === '/search') {
|
|
64
|
+
const { query, limit = 5, agent_id, session_id } = payload;
|
|
65
|
+
if (!query) {
|
|
66
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
67
|
+
res.end(JSON.stringify({ error: 'Missing required field: query' }));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const results = await searchHybrid(query, limit, agent_id, session_id, agent_id || null);
|
|
71
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
72
|
+
res.end(JSON.stringify({ success: true, results }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (req.url === '/add') {
|
|
77
|
+
const { content, importance = 1.0, agent_id, session_id, shared = true } = payload;
|
|
78
|
+
if (!content) {
|
|
79
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
80
|
+
res.end(JSON.stringify({ error: 'Missing required field: content' }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const result = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
|
|
84
|
+
if (result.error) {
|
|
85
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
86
|
+
} else {
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
88
|
+
}
|
|
89
|
+
res.end(JSON.stringify(result));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (req.url === '/context') {
|
|
94
|
+
const { query, max_tokens = 2000, agent_id, session_id } = payload;
|
|
95
|
+
if (!query) {
|
|
96
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ error: 'Missing required field: query' }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const context = await getOptimizedContext(query, max_tokens, agent_id, session_id);
|
|
101
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify(context));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (req.url === '/tool') {
|
|
107
|
+
const { name, arguments: args } = payload;
|
|
108
|
+
if (!name) {
|
|
109
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
110
|
+
res.end(JSON.stringify({ error: 'Missing required field: name' }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const result = await executeToolInternal(name, args || {});
|
|
114
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify(result));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (req.url === '/verify') {
|
|
120
|
+
const result = await verifyChainIntegrity();
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify(result));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify({ error: 'Endpoint Not Found' }));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
httpServer.on('error', (err) => {
|
|
136
|
+
if (err.code === 'EADDRINUSE') {
|
|
137
|
+
console.error(`[persyst] HTTP Gateway port ${httpPort} is already in use. Stdio MCP server will continue running.`);
|
|
138
|
+
} else {
|
|
139
|
+
console.error('[persyst] HTTP Gateway error:', err.message);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
httpServer.listen(httpPort, '127.0.0.1', () => {
|
|
144
|
+
console.error(`[persyst] HTTP Gateway listening on http://127.0.0.1:${httpPort} ✓`);
|
|
145
|
+
});
|
|
146
|
+
|
|
37
147
|
// --- Start temporal decay timer ---
|
|
38
148
|
// Runs every hour: reduces importance of memories not accessed in 7+ days
|
|
39
149
|
const decayTimer = setInterval(applyTemporalDecay, 3600000);
|
|
40
150
|
|
|
41
151
|
// --- Start daily consolidation sweep ---
|
|
42
|
-
// Runs every 24 hours: merges similar memories
|
|
152
|
+
// Runs every 24 hours: merges similar memories
|
|
43
153
|
const consolidationTimer = setInterval(async () => {
|
|
44
154
|
console.error('[persyst] Running scheduled daily memory consolidation sweep...');
|
|
45
155
|
try {
|
|
@@ -50,13 +160,14 @@ export async function startServer() {
|
|
|
50
160
|
}
|
|
51
161
|
}, 86400000);
|
|
52
162
|
|
|
53
|
-
// --- Graceful shutdown
|
|
163
|
+
// --- Graceful shutdown ---
|
|
54
164
|
const shutdown = () => {
|
|
55
165
|
console.error('[persyst] Shutting down...');
|
|
56
166
|
clearInterval(decayTimer);
|
|
57
167
|
clearInterval(consolidationTimer);
|
|
58
168
|
stopWatcher(); // Stop background log watcher
|
|
59
|
-
cleanupWatchers(); //
|
|
169
|
+
cleanupWatchers(); // Stop all git repo watchers
|
|
170
|
+
httpServer.close(); // Close HTTP gateway
|
|
60
171
|
closeDatabase();
|
|
61
172
|
process.exit(0);
|
|
62
173
|
};
|
package/src/tools.js
CHANGED
|
@@ -33,6 +33,7 @@ import db, {
|
|
|
33
33
|
boostMemory,
|
|
34
34
|
logContradiction,
|
|
35
35
|
getProvenance,
|
|
36
|
+
incrementAgentStat,
|
|
36
37
|
getAllAgentStats,
|
|
37
38
|
getAttestationsByDateRange,
|
|
38
39
|
getMemoryHistoryChain,
|
|
@@ -99,6 +100,148 @@ function validateMemoryContent(content) {
|
|
|
99
100
|
return { valid: true };
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Internal logic for storing a new memory (dedup, vector creation, contradiction detection).
|
|
105
|
+
* Shared by both the stdio MCP tool and the HTTP Gateway server.
|
|
106
|
+
*/
|
|
107
|
+
export async function addMemoryInternal({ content, importance = 1.0, agent_id, session_id, shared = true }) {
|
|
108
|
+
try {
|
|
109
|
+
const normalizedAgentId = agent_id ? agent_id.toLowerCase() : null;
|
|
110
|
+
|
|
111
|
+
// Bug 7 + Feature 4: Validate content size
|
|
112
|
+
const validation = validateMemoryContent(content);
|
|
113
|
+
if (!validation.valid) {
|
|
114
|
+
return { error: validation.error };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Derive namespace from agent_id and shared flag
|
|
118
|
+
const namespace = (shared || !normalizedAgentId) ? 'shared' : normalizedAgentId;
|
|
119
|
+
|
|
120
|
+
// Deduplication check (namespace-aware)
|
|
121
|
+
const existing = getMemoryByContent(content, namespace);
|
|
122
|
+
if (existing) {
|
|
123
|
+
// Re-attribute provenance to the calling agent if it was previously auto-attributed to log-watcher
|
|
124
|
+
const prov = getProvenance(existing.id);
|
|
125
|
+
if (prov && (prov.source_id === 'antigravity-worker' || prov.source_id === 'user-dialogue') && normalizedAgentId) {
|
|
126
|
+
try {
|
|
127
|
+
db.prepare("UPDATE provenance SET source_type = 'agent', source_id = ?, confidence = 1.0 WHERE memory_id = ?")
|
|
128
|
+
.run(normalizedAgentId, existing.id);
|
|
129
|
+
incrementAgentStat(normalizedAgentId, 'created');
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`[persyst] Re-attribute provenance error: ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
boostMemory(existing.id);
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
id: existing.id,
|
|
138
|
+
namespace,
|
|
139
|
+
message: `Memory #${existing.id} already exists. Boosted importance.`
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const id = insertMemory(content, importance, {
|
|
144
|
+
source_type: normalizedAgentId ? 'agent' : 'manual',
|
|
145
|
+
source_id: normalizedAgentId,
|
|
146
|
+
confidence: 1.0
|
|
147
|
+
}, namespace);
|
|
148
|
+
|
|
149
|
+
const embedding = await generateEmbedding(content);
|
|
150
|
+
insertVector(id, embedding);
|
|
151
|
+
|
|
152
|
+
// Feature 1: Invalidate search cache on write
|
|
153
|
+
searchCache.invalidate();
|
|
154
|
+
|
|
155
|
+
// Feature 2: Contradiction Detection
|
|
156
|
+
let contradictions = [];
|
|
157
|
+
try {
|
|
158
|
+
const similarHits = searchVector(embedding, 20);
|
|
159
|
+
for (const hit of similarHits) {
|
|
160
|
+
const hitId = Number(hit.rowid);
|
|
161
|
+
if (hitId === id) continue; // Skip self
|
|
162
|
+
|
|
163
|
+
const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
|
|
164
|
+
if (sim > 0.70) {
|
|
165
|
+
const existingMemory = getMemoryById(hitId, namespace);
|
|
166
|
+
if (!existingMemory) continue;
|
|
167
|
+
|
|
168
|
+
const jaccard = jaccardDistance(content, existingMemory.content);
|
|
169
|
+
// Contradiction: similar topic (high similarity), but differing key terms
|
|
170
|
+
if (jaccard > 0 && jaccard < 0.65) {
|
|
171
|
+
// Fetch provenances for trust calculation
|
|
172
|
+
const oldProv = getProvenance(hitId);
|
|
173
|
+
let oldReputation = 1.0;
|
|
174
|
+
if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
|
|
175
|
+
const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(oldProv.source_id);
|
|
176
|
+
if (agentRow) oldReputation = agentRow.reputation_score;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let newReputation = 1.0;
|
|
180
|
+
if (normalizedAgentId) {
|
|
181
|
+
const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(normalizedAgentId);
|
|
182
|
+
if (agentRow) newReputation = agentRow.reputation_score;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const trustOld = (oldProv ? oldProv.confidence : 1.0) * oldReputation;
|
|
186
|
+
const trustNew = 1.0 * newReputation; // New confidence is 1.0
|
|
187
|
+
|
|
188
|
+
const isSelfUpdate = oldProv && oldProv.source_type === 'agent' && oldProv.source_id === normalizedAgentId;
|
|
189
|
+
|
|
190
|
+
if (isSelfUpdate || trustNew > trustOld) {
|
|
191
|
+
// New is preferred
|
|
192
|
+
logContradiction(hitId, id, `Auto-detected contradiction: new memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
|
|
193
|
+
contradictions.push({
|
|
194
|
+
old_memory_id: hitId,
|
|
195
|
+
old_content_preview: existingMemory.content.slice(0, 100),
|
|
196
|
+
similarity: sim.toFixed(4),
|
|
197
|
+
content_difference: jaccard.toFixed(4),
|
|
198
|
+
resolution: 'replaced_old'
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
// Old is preferred
|
|
202
|
+
logContradiction(id, hitId, `Auto-detected contradiction: existing memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
|
|
203
|
+
contradictions.push({
|
|
204
|
+
old_memory_id: hitId,
|
|
205
|
+
old_content_preview: existingMemory.content.slice(0, 100),
|
|
206
|
+
similarity: sim.toFixed(4),
|
|
207
|
+
content_difference: jaccard.toFixed(4),
|
|
208
|
+
resolution: 'kept_old'
|
|
209
|
+
});
|
|
210
|
+
break; // New memory was archived, stop contradiction check
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.error(`[persyst] Contradiction detection error: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const result = { success: true, id, namespace, message: `Memory #${id} stored` };
|
|
220
|
+
if (contradictions.length > 0) {
|
|
221
|
+
result.contradictions_detected = contradictions;
|
|
222
|
+
result.message += `. Detected ${contradictions.length} contradiction(s) — older memories archived.`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { error: err.message };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const toolHandlers = new Map();
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Programmatically execute any registered MCP tool.
|
|
235
|
+
* Used by the HTTP Gateway server to route requests to tool handlers.
|
|
236
|
+
*/
|
|
237
|
+
export async function executeToolInternal(name, args) {
|
|
238
|
+
const handler = toolHandlers.get(name);
|
|
239
|
+
if (!handler) {
|
|
240
|
+
throw new Error(`Tool ${name} not found`);
|
|
241
|
+
}
|
|
242
|
+
return await handler(args);
|
|
243
|
+
}
|
|
244
|
+
|
|
102
245
|
/**
|
|
103
246
|
* Register all MCP tools on the server.
|
|
104
247
|
* @param {McpServer} server - The MCP server instance
|
|
@@ -108,6 +251,11 @@ export function registerTools(server) {
|
|
|
108
251
|
let count = 0;
|
|
109
252
|
const originalTool = server.tool.bind(server);
|
|
110
253
|
server.tool = (...args) => {
|
|
254
|
+
const name = args[0];
|
|
255
|
+
const handler = args[args.length - 1];
|
|
256
|
+
if (typeof handler === 'function') {
|
|
257
|
+
toolHandlers.set(name, handler);
|
|
258
|
+
}
|
|
111
259
|
originalTool(...args);
|
|
112
260
|
count++;
|
|
113
261
|
};
|
|
@@ -128,114 +276,11 @@ export function registerTools(server) {
|
|
|
128
276
|
shared: z.boolean().default(true).describe('If true, memory is visible to all agents. If false, only visible to this agent.')
|
|
129
277
|
},
|
|
130
278
|
async ({ content, importance, agent_id, session_id, shared }) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!validation.valid) {
|
|
135
|
-
return text({ error: validation.error });
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Derive namespace from agent_id and shared flag
|
|
139
|
-
const namespace = (shared || !agent_id) ? 'shared' : agent_id;
|
|
140
|
-
|
|
141
|
-
// Deduplication check (namespace-aware)
|
|
142
|
-
const existing = getMemoryByContent(content, namespace);
|
|
143
|
-
if (existing) {
|
|
144
|
-
boostMemory(existing.id);
|
|
145
|
-
return text({
|
|
146
|
-
success: true,
|
|
147
|
-
id: existing.id,
|
|
148
|
-
namespace,
|
|
149
|
-
message: `Memory #${existing.id} already exists. Boosted importance.`
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const id = insertMemory(content, importance, {
|
|
154
|
-
source_type: agent_id ? 'agent' : 'manual',
|
|
155
|
-
source_id: agent_id || null,
|
|
156
|
-
confidence: 1.0
|
|
157
|
-
}, namespace);
|
|
158
|
-
|
|
159
|
-
const embedding = await generateEmbedding(content);
|
|
160
|
-
insertVector(id, embedding);
|
|
161
|
-
|
|
162
|
-
// Feature 1: Invalidate search cache on write
|
|
163
|
-
searchCache.invalidate();
|
|
164
|
-
|
|
165
|
-
// Feature 2: Contradiction Detection
|
|
166
|
-
let contradictions = [];
|
|
167
|
-
try {
|
|
168
|
-
const similarHits = searchVector(embedding, 3);
|
|
169
|
-
for (const hit of similarHits) {
|
|
170
|
-
const hitId = Number(hit.rowid);
|
|
171
|
-
if (hitId === id) continue; // Skip self
|
|
172
|
-
|
|
173
|
-
const sim = Math.max(0, 1 - (hit.distance * hit.distance) / 2);
|
|
174
|
-
if (sim > 0.75) {
|
|
175
|
-
const existingMemory = getMemoryById(hitId, namespace);
|
|
176
|
-
if (!existingMemory) continue;
|
|
177
|
-
|
|
178
|
-
const jaccard = jaccardDistance(content, existingMemory.content);
|
|
179
|
-
// Contradiction: similar topic (high similarity), but differing key terms
|
|
180
|
-
if (jaccard > 0 && jaccard < 0.5) {
|
|
181
|
-
// Fetch provenances for trust calculation
|
|
182
|
-
const oldProv = getProvenance(hitId);
|
|
183
|
-
let oldReputation = 1.0;
|
|
184
|
-
if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
|
|
185
|
-
const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(oldProv.source_id);
|
|
186
|
-
if (agentRow) oldReputation = agentRow.reputation_score;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let newReputation = 1.0;
|
|
190
|
-
if (agent_id) {
|
|
191
|
-
const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(agent_id);
|
|
192
|
-
if (agentRow) newReputation = agentRow.reputation_score;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const trustOld = (oldProv ? oldProv.confidence : 1.0) * oldReputation;
|
|
196
|
-
const trustNew = 1.0 * newReputation; // New confidence is 1.0
|
|
197
|
-
|
|
198
|
-
const isSelfUpdate = oldProv && oldProv.source_type === 'agent' && oldProv.source_id === agent_id;
|
|
199
|
-
|
|
200
|
-
if (isSelfUpdate || trustNew > trustOld) {
|
|
201
|
-
// New is preferred
|
|
202
|
-
logContradiction(hitId, id, `Auto-detected contradiction: new memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
|
|
203
|
-
contradictions.push({
|
|
204
|
-
old_memory_id: hitId,
|
|
205
|
-
old_content_preview: existingMemory.content.slice(0, 100),
|
|
206
|
-
similarity: sim.toFixed(4),
|
|
207
|
-
content_difference: jaccard.toFixed(4),
|
|
208
|
-
resolution: 'replaced_old'
|
|
209
|
-
});
|
|
210
|
-
} else {
|
|
211
|
-
// Old is preferred
|
|
212
|
-
logContradiction(id, hitId, `Auto-detected contradiction: existing memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
|
|
213
|
-
contradictions.push({
|
|
214
|
-
old_memory_id: hitId,
|
|
215
|
-
old_content_preview: existingMemory.content.slice(0, 100),
|
|
216
|
-
similarity: sim.toFixed(4),
|
|
217
|
-
content_difference: jaccard.toFixed(4),
|
|
218
|
-
resolution: 'kept_old'
|
|
219
|
-
});
|
|
220
|
-
break; // New memory was archived, stop contradiction check
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} catch (e) {
|
|
226
|
-
console.error(`[persyst] Contradiction detection error: ${e.message}`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const result = { success: true, id, namespace, message: `Memory #${id} stored` };
|
|
230
|
-
if (contradictions.length > 0) {
|
|
231
|
-
result.contradictions_detected = contradictions;
|
|
232
|
-
result.message += `. Detected ${contradictions.length} contradiction(s) — older memories archived.`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return text(result);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
return text({ error: err.message });
|
|
279
|
+
const res = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
|
|
280
|
+
if (res.error) {
|
|
281
|
+
return text({ error: res.error });
|
|
238
282
|
}
|
|
283
|
+
return text(res);
|
|
239
284
|
}
|
|
240
285
|
);
|
|
241
286
|
|
|
@@ -295,6 +340,8 @@ export function registerTools(server) {
|
|
|
295
340
|
},
|
|
296
341
|
async ({ id, content, agent_id }) => {
|
|
297
342
|
try {
|
|
343
|
+
const normalizedAgentId = agent_id ? agent_id.toLowerCase() : null;
|
|
344
|
+
|
|
298
345
|
// Bug 7 + Feature 4: Validate content size
|
|
299
346
|
const validation = validateMemoryContent(content);
|
|
300
347
|
if (!validation.valid) {
|
|
@@ -306,7 +353,7 @@ export function registerTools(server) {
|
|
|
306
353
|
|
|
307
354
|
// Retrieve old agent_id from provenance
|
|
308
355
|
const oldProv = getProvenance(id);
|
|
309
|
-
const resolvedAgentId =
|
|
356
|
+
const resolvedAgentId = normalizedAgentId || (oldProv && oldProv.source_type === 'agent' ? oldProv.source_id : null);
|
|
310
357
|
|
|
311
358
|
// Insert new version
|
|
312
359
|
const newId = insertMemory(
|