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.
@@ -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
- let heuristicFacts = [];
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} heuristic fact(s)`);
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
@@ -61,6 +61,12 @@ const HOOK_CONFIG = {
61
61
  matcher: '',
62
62
  hooks: [{ ...HOOK_ENTRY, timeout: 10 }]
63
63
  }
64
+ ],
65
+ Stop: [
66
+ {
67
+ matcher: '',
68
+ hooks: [{ ...HOOK_ENTRY }]
69
+ }
64
70
  ]
65
71
  };
66
72
 
@@ -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
- const result = await client.callTool({ name: toolName, arguments: args });
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(client, input);
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(client, input),
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 (client) {
413
- try { await client.close(); } catch (_) {}
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.1",
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
- db.prepare('UPDATE memories SET parent_id = ? WHERE id = ?').run(oldMemoryId, newMemoryId);
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 m.*, p.source_type, p.source_id, p.confidence
914
- FROM memories m
915
- LEFT JOIN provenance p ON m.id = p.memory_id
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
- if (row && !seenIds.has(row.id)) {
924
- seenIds.add(row.id);
925
- if (row.source_type === 'agent' && row.source_id) {
926
- row.source_id = row.source_id.toLowerCase();
927
- }
928
- uniqueRows.push(row);
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 uniqueRows;
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 4
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 >= 4) continue;
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.5) {
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 = 10
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
- * via stdio transport (the standard MCP communication method).
6
- * Sets up hourly temporal decay and daily consolidation background tasks.
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.3'
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 (similarity > 0.85)
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 (Bug 3 fix: also cleans up git watchers) ---
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(); // Bug 3 fix: stop all git repo watchers
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
- try {
132
- // Bug 7 + Feature 4: Validate content size
133
- const validation = validateMemoryContent(content);
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 = agent_id || (oldProv && oldProv.source_type === 'agent' ? oldProv.source_id : null);
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(