persyst-mcp 2.2.5 → 2.2.6

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/src/server.js CHANGED
@@ -1,723 +1,856 @@
1
- /**
2
- * server.js — MCP Server, Local HTTP Gateway & Swarm Hub
3
- *
4
- * Creates the MCP server, registers all tools, and connects via stdio.
5
- * Also runs a local HTTP/JSON Gateway on port 4321 (configurable) to support:
6
- * - Agentic swarms without subprocess overhead
7
- * - IDE context injection via /system-prompt
8
- * - Real-time event streaming via SSE (/events)
9
- * - Batch operations for high-throughput swarm agents
10
- * - Optional API key authentication for remote/multi-host setups
11
- *
12
- * Environment variables:
13
- * PORT — HTTP gateway port (default: 4321)
14
- * PERSYST_HOST — Bind address (default: 127.0.0.1, use 0.0.0.0 for Docker/remote)
15
- * PERSYST_API_KEY — Optional auth token. If set, all endpoints (except /health) require
16
- * Authorization: Bearer <token>
17
- *
18
- * All logging goes to stderr via console.error().
19
- */
20
-
21
- import http from 'http';
22
- import { URL } from 'url';
23
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
- import { registerTools, cleanupWatchers, addMemoryInternal, executeToolInternal } from './tools.js';
26
- import {
27
- applyTemporalDecay,
28
- closeDatabase,
29
- getActiveMemoryCount,
30
- getNamespaceStats,
31
- getAllAgentStats
32
- } from './database.js';
33
- import { consolidateMemories, searchHybrid, getOptimizedContext } from './search.js';
34
- import { startWatcher, stopWatcher } from './watcher.js';
35
- import { verifyChainIntegrity } from './attestation.js';
36
- import { memoryEventBus } from './events.js';
37
-
38
- // Track server birth time for uptime reporting
39
- const SERVER_START_TIME = Date.now();
40
-
41
- // Active SSE client response objects
42
- const sseClients = new Set();
43
-
44
- // ============================================================
45
- // SYSTEM PROMPT FORMATTER
46
- // ============================================================
47
-
48
- /**
49
- * Format optimized context data into a structured system-prompt block.
50
- * Supports three output formats: 'text', 'markdown', 'json'.
51
- *
52
- * @param {Object} contextData - Result from getOptimizedContext()
53
- * @param {string} format - 'text' | 'markdown' | 'json'
54
- * @param {string|null} agentId
55
- * @returns {string}
56
- */
57
- function formatSystemPrompt(contextData, format, agentId) {
58
- const { memories, suggested_actions } = contextData;
59
- const now = new Date().toLocaleString('en-US', { hour12: false }).replace(',', '');
60
- const count = memories.length;
61
-
62
- if (format === 'json') {
63
- return JSON.stringify({ ...contextData, generated_at: new Date().toISOString() }, null, 2);
64
- }
65
-
66
- // Group memories by category prefix
67
- const groups = {
68
- 'Rules & Conventions': [],
69
- 'Architecture & Stack': [],
70
- 'Decisions': [],
71
- 'Preferences': [],
72
- 'Context': []
73
- };
74
-
75
- for (const m of memories) {
76
- const c = m.content;
77
- if (/^(?:Rule|Config):/i.test(c)) groups['Rules & Conventions'].push(c);
78
- else if (/^(?:Stack|Architecture):/i.test(c)) groups['Architecture & Stack'].push(c);
79
- else if (/^Decision:/i.test(c)) groups['Decisions'].push(c);
80
- else if (/^Preference:/i.test(c)) groups['Preferences'].push(c);
81
- else groups['Context'].push(c);
82
- }
83
-
84
- if (format === 'markdown') {
85
- let md = `# Persyst Memory Context\n`;
86
- md += `> ${count} memories | Updated: ${now}`;
87
- if (agentId) md += ` | Agent: \`${agentId}\``;
88
- md += '\n\n';
89
-
90
- for (const [section, items] of Object.entries(groups)) {
91
- if (items.length === 0) continue;
92
- md += `## ${section}\n`;
93
- for (const item of items) md += `- ${item}\n`;
94
- md += '\n';
95
- }
96
-
97
- if (suggested_actions.length > 0) {
98
- md += `## Suggested Actions\n`;
99
- for (const a of suggested_actions) md += `- ${a}\n`;
100
- md += '\n';
101
- }
102
-
103
- md += `---\n*Refresh: \`curl http://127.0.0.1:4321/system-prompt?format=markdown\`*\n`;
104
- return md;
105
- }
106
-
107
- // Plain text (default) — safe to paste into any IDE custom instructions
108
- let text = `=== PERSYST MEMORY CONTEXT ===\n`;
109
- text += `Updated: ${now} | ${count} memories`;
110
- if (agentId) text += ` | Agent: ${agentId}`;
111
- text += '\n\n';
112
-
113
- for (const [section, items] of Object.entries(groups)) {
114
- if (items.length === 0) continue;
115
- text += `[${section.toUpperCase()}]\n`;
116
- for (const item of items) text += `• ${item}\n`;
117
- text += '\n';
118
- }
119
-
120
- if (suggested_actions.length > 0) {
121
- text += `[SUGGESTED ACTIONS]\n`;
122
- for (const a of suggested_actions) text += `• ${a}\n`;
123
- text += '\n';
124
- }
125
-
126
- text += `=== END MEMORY CONTEXT ===\n`;
127
- text += `Refresh: curl http://127.0.0.1:${process.env.PORT || '4321'}/system-prompt\n`;
128
- return text;
129
- }
130
-
131
- // ============================================================
132
- // REQUEST HANDLERS
133
- // ============================================================
134
-
135
- async function handleGetRequest(req, res, url) {
136
- const path = url.pathname;
137
-
138
- // ----------------------------------------------------------
139
- // GET /health — server liveness check for orchestrators
140
- // ----------------------------------------------------------
141
- if (path === '/health') {
142
- const uptime = Math.floor((Date.now() - SERVER_START_TIME) / 1000);
143
- let memories = 0;
144
- try { memories = getActiveMemoryCount(); } catch (_) {}
145
- res.writeHead(200, { 'Content-Type': 'application/json' });
146
- res.end(JSON.stringify({
147
- ok: true,
148
- version: '2.2.5',
149
- uptime_seconds: uptime,
150
- memories,
151
- sse_clients: sseClients.size
152
- }));
153
- return;
154
- }
155
-
156
- // ----------------------------------------------------------
157
- // GET /stats — memory and agent statistics
158
- // ----------------------------------------------------------
159
- if (path === '/stats') {
160
- try {
161
- const namespaces = getNamespaceStats();
162
- const agents = getAllAgentStats();
163
- const uptime = Math.floor((Date.now() - SERVER_START_TIME) / 1000);
164
- res.writeHead(200, { 'Content-Type': 'application/json' });
165
- res.end(JSON.stringify({ uptime_seconds: uptime, namespaces, agents }));
166
- } catch (err) {
167
- res.writeHead(500, { 'Content-Type': 'application/json' });
168
- res.end(JSON.stringify({ error: err.message }));
169
- }
170
- return;
171
- }
172
-
173
- // ----------------------------------------------------------
174
- // GET /system-prompt — formatted memory context for IDE injection
175
- //
176
- // Query params:
177
- // query — search query (default: broad project context)
178
- // max_tokens token budget (default: 1500)
179
- // agent_id restrict to this agent's namespace
180
- // format — 'text' (default) | 'markdown' | 'json'
181
- // ----------------------------------------------------------
182
- if (path === '/system-prompt') {
183
- try {
184
- const query = url.searchParams.get('query') ||
185
- 'project conventions architecture preferences rules stack decisions';
186
- const maxTokens = Math.max(100, parseInt(url.searchParams.get('max_tokens') || '1500', 10));
187
- const agentId = url.searchParams.get('agent_id') || null;
188
- const format = url.searchParams.get('format') || 'text';
189
-
190
- const contextData = await getOptimizedContext(
191
- query, maxTokens, agentId, null, agentId || null, null
192
- );
193
-
194
- const output = formatSystemPrompt(contextData, format, agentId);
195
-
196
- const contentTypeMap = {
197
- json: 'application/json',
198
- markdown: 'text/markdown; charset=utf-8',
199
- text: 'text/plain; charset=utf-8'
200
- };
201
- res.writeHead(200, {
202
- 'Content-Type': contentTypeMap[format] || 'text/plain; charset=utf-8',
203
- 'Cache-Control': 'no-cache'
204
- });
205
- res.end(output);
206
- } catch (err) {
207
- res.writeHead(500, { 'Content-Type': 'application/json' });
208
- res.end(JSON.stringify({ error: err.message }));
209
- }
210
- return;
211
- }
212
-
213
- // ----------------------------------------------------------
214
- // GET /events — Server-Sent Events stream of memory changes
215
- //
216
- // Clients subscribe once and receive real-time push notifications
217
- // for memory_added, memory_deleted, memories_consolidated events.
218
- //
219
- // Example (Python):
220
- // import sseclient, requests
221
- // for event in sseclient.SSEClient('http://127.0.0.1:4321/events'):
222
- // print(event.event, event.data)
223
- //
224
- // Example (Node.js):
225
- // const es = new EventSource('http://127.0.0.1:4321/events');
226
- // es.addEventListener('memory_added', e => console.log(JSON.parse(e.data)));
227
- // ----------------------------------------------------------
228
- if (path === '/events') {
229
- res.writeHead(200, {
230
- 'Content-Type': 'text/event-stream',
231
- 'Cache-Control': 'no-cache',
232
- 'Connection': 'keep-alive',
233
- 'Access-Control-Allow-Origin': '*',
234
- 'X-Accel-Buffering': 'no' // Prevents nginx from buffering SSE
235
- });
236
-
237
- // Send initial connected event
238
- res.write(`event: connected\ndata: ${JSON.stringify({
239
- ok: true,
240
- timestamp: new Date().toISOString(),
241
- server_version: '2.2.5'
242
- })}\n\n`);
243
-
244
- sseClients.add(res);
245
-
246
- // Heartbeat every 15s to keep connection alive through proxies
247
- const heartbeat = setInterval(() => {
248
- try { res.write(': heartbeat\n\n'); } catch (_) { clearInterval(heartbeat); }
249
- }, 15000);
250
-
251
- const onAdded = (data) => {
252
- try { res.write(`event: memory_added\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
253
- };
254
- const onDeleted = (data) => {
255
- try { res.write(`event: memory_deleted\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
256
- };
257
- const onConsolidated = (data) => {
258
- try { res.write(`event: memories_consolidated\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
259
- };
260
-
261
- memoryEventBus.on('memory_added', onAdded);
262
- memoryEventBus.on('memory_deleted', onDeleted);
263
- memoryEventBus.on('memories_consolidated', onConsolidated);
264
-
265
- req.on('close', () => {
266
- clearInterval(heartbeat);
267
- memoryEventBus.off('memory_added', onAdded);
268
- memoryEventBus.off('memory_deleted', onDeleted);
269
- memoryEventBus.off('memories_consolidated', onConsolidated);
270
- sseClients.delete(res);
271
- console.error(`[persyst-sse] Client disconnected. Active: ${sseClients.size}`);
272
- });
273
-
274
- console.error(`[persyst-sse] Client connected. Active: ${sseClients.size}`);
275
- return; // Keep connection alive — do NOT end response
276
- }
277
-
278
- res.writeHead(404, { 'Content-Type': 'application/json' });
279
- res.end(JSON.stringify({ error: 'Not Found' }));
280
- }
281
-
282
- async function handlePostRequest(req, res, payload) {
283
- const path = new URL(req.url, 'http://127.0.0.1').pathname;
284
-
285
- // ----------------------------------------------------------
286
- // POST /remember — quick one-liner memory save
287
- //
288
- // The user explicitly wants to save something. No extraction,
289
- // no filtering, no pattern matching. Just store it.
290
- //
291
- // Body: { content: string, importance?: number, namespace?: string }
292
- // OR: plain text body (e.g. from curl --data "don't forget X")
293
- //
294
- // Example:
295
- // curl -X POST http://127.0.0.1:4321/remember \
296
- // -H 'Content-Type: text/plain' \
297
- // --data 'SSL cert expires March 15'
298
- // ----------------------------------------------------------
299
- if (path === '/remember') {
300
- // Support both plain text and JSON bodies
301
- let content, importance, namespace;
302
- if (typeof payload === 'string') {
303
- content = payload.trim();
304
- importance = 1.0;
305
- namespace = 'shared';
306
- } else {
307
- content = payload.content || payload.text || payload.note || payload.message;
308
- importance = payload.importance || 1.0;
309
- namespace = payload.namespace || 'shared';
310
- }
311
-
312
- if (!content) {
313
- res.writeHead(400, { 'Content-Type': 'application/json' });
314
- res.end(JSON.stringify({ error: 'No content provided. Pass plain text or { content: "..." }' }));
315
- return;
316
- }
317
-
318
- // Prefix with Note: if not already categorized
319
- const normalizedContent = /^(?:Note|Reminder|Rule|Decision|Preference|Stack|Architecture|Config|Warning|FYI):/i.test(content.trim())
320
- ? content.trim()
321
- : `Note: ${content.trim()}`;
322
-
323
- const result = await addMemoryInternal({
324
- content: normalizedContent,
325
- importance,
326
- agent_id: payload.agent_id || null,
327
- session_id: payload.session_id || null,
328
- shared: payload.shared !== false
329
- });
330
-
331
- if (!result.error) {
332
- memoryEventBus.emit('memory_added', {
333
- id: result.id,
334
- content: normalizedContent,
335
- namespace: result.namespace || namespace,
336
- source: 'user-explicit'
337
- });
338
- }
339
-
340
- res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
341
- res.end(JSON.stringify(result));
342
- return;
343
- }
344
-
345
- // ----------------------------------------------------------
346
- // POST /search
347
- // ----------------------------------------------------------
348
- if (path === '/search') {
349
- const { query, limit = 5, agent_id, session_id } = payload;
350
- if (!query) {
351
- res.writeHead(400, { 'Content-Type': 'application/json' });
352
- res.end(JSON.stringify({ error: 'Missing required field: query' }));
353
- return;
354
- }
355
- const results = await searchHybrid(query, limit, agent_id, session_id, agent_id || null);
356
- res.writeHead(200, { 'Content-Type': 'application/json' });
357
- res.end(JSON.stringify({ success: true, results }));
358
- return;
359
- }
360
-
361
- // ----------------------------------------------------------
362
- // POST /add
363
- // ----------------------------------------------------------
364
- if (path === '/add') {
365
- const { content, importance = 1.0, agent_id, session_id, shared = true } = payload;
366
- if (!content) {
367
- res.writeHead(400, { 'Content-Type': 'application/json' });
368
- res.end(JSON.stringify({ error: 'Missing required field: content' }));
369
- return;
370
- }
371
- const result = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
372
- if (result.error) {
373
- res.writeHead(400, { 'Content-Type': 'application/json' });
374
- } else {
375
- res.writeHead(200, { 'Content-Type': 'application/json' });
376
- // Broadcast to SSE subscribers
377
- memoryEventBus.emit('memory_added', {
378
- id: result.id,
379
- content,
380
- namespace: result.namespace,
381
- source: agent_id || 'http'
382
- });
383
- }
384
- res.end(JSON.stringify(result));
385
- return;
386
- }
387
-
388
- // ----------------------------------------------------------
389
- // POST /context
390
- // ----------------------------------------------------------
391
- if (path === '/context') {
392
- const { query, max_tokens = 2000, agent_id, session_id, intent } = payload;
393
- if (!query) {
394
- res.writeHead(400, { 'Content-Type': 'application/json' });
395
- res.end(JSON.stringify({ error: 'Missing required field: query' }));
396
- return;
397
- }
398
- const context = await getOptimizedContext(query, max_tokens, agent_id, session_id, agent_id || null, intent);
399
- res.writeHead(200, { 'Content-Type': 'application/json' });
400
- res.end(JSON.stringify(context));
401
- return;
402
- }
403
-
404
- // ----------------------------------------------------------
405
- // POST /tool — generic MCP tool invocation
406
- // ----------------------------------------------------------
407
- if (path === '/tool') {
408
- const { name, arguments: args } = payload;
409
- if (!name) {
410
- res.writeHead(400, { 'Content-Type': 'application/json' });
411
- res.end(JSON.stringify({ error: 'Missing required field: name' }));
412
- return;
413
- }
414
- const result = await executeToolInternal(name, args || {});
415
- res.writeHead(200, { 'Content-Type': 'application/json' });
416
- res.end(JSON.stringify(result));
417
- return;
418
- }
419
-
420
- // ----------------------------------------------------------
421
- // POST /verify — chain integrity check
422
- // ----------------------------------------------------------
423
- if (path === '/verify') {
424
- const result = await verifyChainIntegrity();
425
- res.writeHead(200, { 'Content-Type': 'application/json' });
426
- res.end(JSON.stringify(result));
427
- return;
428
- }
429
-
430
- // ----------------------------------------------------------
431
- // POST /batch/add — store multiple memories in one round trip
432
- //
433
- // Body: { memories: [{ content, importance?, agent_id?, shared? }, ...] }
434
- // Returns: { success, results: [...], stored, skipped, errors }
435
- //
436
- // Designed for:
437
- // - Swarm agents ingesting session summaries in bulk
438
- // - Migration tools
439
- // - CI pipelines storing build/test results
440
- // ----------------------------------------------------------
441
- if (path === '/batch/add') {
442
- const { memories } = payload;
443
- if (!Array.isArray(memories) || memories.length === 0) {
444
- res.writeHead(400, { 'Content-Type': 'application/json' });
445
- res.end(JSON.stringify({ error: 'memories must be a non-empty array' }));
446
- return;
447
- }
448
-
449
- // Hard cap: prevent abuse
450
- if (memories.length > 200) {
451
- res.writeHead(400, { 'Content-Type': 'application/json' });
452
- res.end(JSON.stringify({ error: 'Batch size exceeds maximum of 200' }));
453
- return;
454
- }
455
-
456
- const results = [];
457
- let stored = 0;
458
- let skipped = 0;
459
- let errors = 0;
460
-
461
- for (const mem of memories) {
462
- const { content, importance = 1.0, agent_id, session_id, shared = true } = mem;
463
- if (!content) {
464
- results.push({ error: 'Missing content', input: mem });
465
- errors++;
466
- continue;
467
- }
468
- try {
469
- const result = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
470
- results.push(result);
471
- if (result.error) {
472
- errors++;
473
- } else if (result.message && result.message.includes('already exists')) {
474
- skipped++;
475
- } else {
476
- stored++;
477
- memoryEventBus.emit('memory_added', {
478
- id: result.id,
479
- content,
480
- namespace: result.namespace,
481
- source: agent_id || 'batch'
482
- });
483
- }
484
- } catch (err) {
485
- results.push({ error: err.message, input: mem });
486
- errors++;
487
- }
488
- }
489
-
490
- res.writeHead(200, { 'Content-Type': 'application/json' });
491
- res.end(JSON.stringify({ success: true, results, stored, skipped, errors }));
492
- return;
493
- }
494
-
495
- // ----------------------------------------------------------
496
- // POST /batch/search — run multiple queries in one round trip
497
- //
498
- // Body: { queries: string[] | Array<{query, limit?, agent_id?}>, limit?: number }
499
- // Returns: { results: { "<query>": [...memories] } }
500
- //
501
- // Designed for:
502
- // - Swarm agents loading context for multiple topics at once
503
- // - Parallel memory retrieval without sequential round trips
504
- // ----------------------------------------------------------
505
- if (path === '/batch/search') {
506
- const { queries, limit = 5 } = payload;
507
- if (!Array.isArray(queries) || queries.length === 0) {
508
- res.writeHead(400, { 'Content-Type': 'application/json' });
509
- res.end(JSON.stringify({ error: 'queries must be a non-empty array' }));
510
- return;
511
- }
512
-
513
- if (queries.length > 50) {
514
- res.writeHead(400, { 'Content-Type': 'application/json' });
515
- res.end(JSON.stringify({ error: 'Batch query size exceeds maximum of 50' }));
516
- return;
517
- }
518
-
519
- // Run all searches in parallel for speed
520
- const searchPromises = queries.map(async (q) => {
521
- if (typeof q === 'string') {
522
- return { key: q, results: await searchHybrid(q, limit, null, null, null) };
523
- } else if (q && typeof q === 'object' && q.query) {
524
- return {
525
- key: q.query,
526
- results: await searchHybrid(q.query, q.limit || limit, q.agent_id || null, null, q.agent_id || null)
527
- };
528
- }
529
- return { key: String(q), results: [] };
530
- });
531
-
532
- const settled = await Promise.allSettled(searchPromises);
533
- const results = {};
534
- for (const s of settled) {
535
- if (s.status === 'fulfilled') {
536
- results[s.value.key] = s.value.results;
537
- }
538
- }
539
-
540
- res.writeHead(200, { 'Content-Type': 'application/json' });
541
- res.end(JSON.stringify({ success: true, results }));
542
- return;
543
- }
544
-
545
- res.writeHead(404, { 'Content-Type': 'application/json' });
546
- res.end(JSON.stringify({ error: 'Endpoint Not Found' }));
547
- }
548
-
549
- // ============================================================
550
- // MAIN SERVER STARTUP
551
- // ============================================================
552
-
553
- /**
554
- * Start the Persyst MCP server & HTTP Gateway.
555
- */
556
- export async function startServer() {
557
- // --- Create MCP server ---
558
- const server = new McpServer({
559
- name: 'persyst',
560
- version: '2.2.5'
561
- });
562
-
563
- // --- Register all tools ---
564
- const registeredCount = registerTools(server);
565
- console.error(`[persyst] ${registeredCount} tools registered ✓`);
566
-
567
- // --- Start background log watcher daemon (skip in test mode) ---
568
- if (process.env.NODE_ENV !== 'test') {
569
- startWatcher();
570
- }
571
-
572
- // --- Gateway configuration ---
573
- const httpPort = parseInt(process.env.PORT || '4321', 10);
574
- const httpHost = process.env.PERSYST_HOST || '127.0.0.1';
575
- const configuredApiKey = process.env.PERSYST_API_KEY || null;
576
-
577
- if (configuredApiKey) {
578
- console.error(`[persyst] API key auth enabled — endpoints require Authorization: Bearer <key>`);
579
- }
580
- if (httpHost !== '127.0.0.1') {
581
- console.error(`[persyst] ⚠️ Gateway bound to ${httpHost} — ensure PERSYST_API_KEY is set for security`);
582
- }
583
-
584
- // --- Start local HTTP Gateway ---
585
- const httpServer = http.createServer((req, res) => {
586
- // CORS headers
587
- res.setHeader('Access-Control-Allow-Origin', '*');
588
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
589
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
590
-
591
- if (req.method === 'OPTIONS') {
592
- res.writeHead(204);
593
- res.end();
594
- return;
595
- }
596
-
597
- // API key authentication middleware
598
- // /health is always public (for orchestrators / Docker health checks)
599
- if (configuredApiKey) {
600
- const urlPath = new URL(req.url || '/', 'http://127.0.0.1').pathname;
601
- if (urlPath !== '/health') {
602
- const authHeader = req.headers['authorization'] || '';
603
- const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
604
- if (token !== configuredApiKey) {
605
- res.writeHead(401, { 'Content-Type': 'application/json' });
606
- res.end(JSON.stringify({
607
- error: 'Unauthorized. Set header: Authorization: Bearer <PERSYST_API_KEY>'
608
- }));
609
- return;
610
- }
611
- }
612
- }
613
-
614
- // Route GET requests (no body reading needed)
615
- if (req.method === 'GET') {
616
- try {
617
- const url = new URL(req.url || '/', `http://${httpHost}`);
618
- handleGetRequest(req, res, url).catch(err => {
619
- try {
620
- res.writeHead(500, { 'Content-Type': 'application/json' });
621
- res.end(JSON.stringify({ error: err.message }));
622
- } catch (_) {}
623
- });
624
- } catch (err) {
625
- res.writeHead(400, { 'Content-Type': 'application/json' });
626
- res.end(JSON.stringify({ error: 'Bad request URL' }));
627
- }
628
- return;
629
- }
630
-
631
- // Route POST requests
632
- if (req.method !== 'POST') {
633
- res.writeHead(405, { 'Content-Type': 'application/json' });
634
- res.end(JSON.stringify({ error: 'Method Not Allowed. Use POST or GET.' }));
635
- return;
636
- }
637
-
638
- let body = '';
639
- req.on('data', chunk => { body += chunk; });
640
- req.on('end', async () => {
641
- try {
642
- // Handle both JSON and plain-text bodies (plain text used by /remember)
643
- const contentType = req.headers['content-type'] || '';
644
- let payload;
645
- if (contentType.includes('text/plain')) {
646
- payload = body.trim(); // Will be handled as string in /remember
647
- } else {
648
- payload = JSON.parse(body || '{}');
649
- }
650
- await handlePostRequest(req, res, payload);
651
- } catch (err) {
652
- try {
653
- res.writeHead(500, { 'Content-Type': 'application/json' });
654
- res.end(JSON.stringify({ error: err.message }));
655
- } catch (_) {}
656
- }
657
- });
658
- });
659
-
660
- httpServer.on('error', (err) => {
661
- if (err.code === 'EADDRINUSE') {
662
- console.error(`[persyst] HTTP Gateway port ${httpPort} already in use. Stdio MCP server will continue.`);
663
- } else {
664
- console.error('[persyst] HTTP Gateway error:', err.message);
665
- }
666
- });
667
-
668
- httpServer.listen(httpPort, httpHost, () => {
669
- console.error(`[persyst] HTTP Gateway listening on http://${httpHost}:${httpPort} ✓`);
670
- console.error(`[persyst] Endpoints: /health /stats /system-prompt /events /remember /search /add /context /tool /verify /batch/add /batch/search`);
671
- });
672
-
673
- // --- Start temporal decay timer (every hour) ---
674
- const decayTimer = setInterval(applyTemporalDecay, 3600000);
675
-
676
- // --- Start daily consolidation sweep ---
677
- const consolidationTimer = setInterval(async () => {
678
- console.error('[persyst] Running scheduled daily memory consolidation sweep...');
679
- try {
680
- const report = await consolidateMemories();
681
- console.error(`[persyst] Consolidation sweep: consolidated ${report.consolidated_groups} duplicate groups.`);
682
- if (report.consolidated_groups > 0) {
683
- memoryEventBus.emit('memories_consolidated', {
684
- consolidated_groups: report.consolidated_groups,
685
- details: report.details
686
- });
687
- }
688
- } catch (err) {
689
- console.error('[persyst] Daily consolidation sweep failed:', err.message);
690
- }
691
- }, 86400000);
692
-
693
- // --- Graceful shutdown ---
694
- const shutdown = () => {
695
- console.error('[persyst] Shutting down...');
696
- clearInterval(decayTimer);
697
- clearInterval(consolidationTimer);
698
- stopWatcher();
699
- cleanupWatchers();
700
-
701
- // Close all SSE connections gracefully
702
- for (const client of sseClients) {
703
- try {
704
- client.write(`event: server_shutdown\ndata: ${JSON.stringify({ message: 'Server shutting down' })}\n\n`);
705
- client.end();
706
- } catch (_) {}
707
- }
708
- sseClients.clear();
709
-
710
- httpServer.close();
711
- closeDatabase();
712
- process.exit(0);
713
- };
714
- process.on('SIGINT', shutdown);
715
- process.on('SIGTERM', shutdown);
716
-
717
- // --- Connect via stdio ---
718
- const transport = new StdioServerTransport();
719
- await server.connect(transport);
720
-
721
- console.error('[persyst] MCP server running on stdio ✓');
722
- console.error('[persyst] Ready to receive tool calls');
723
- }
1
+ /**
2
+ * server.js — MCP Server, Local HTTP Gateway & Swarm Hub
3
+ *
4
+ * Creates the MCP server, registers all tools, and connects via stdio.
5
+ * Also runs a local HTTP/JSON Gateway on port 4321 (configurable) to support:
6
+ * - Agentic swarms without subprocess overhead
7
+ * - IDE context injection via /system-prompt
8
+ * - Real-time event streaming via SSE (/events)
9
+ * - Batch operations for high-throughput swarm agents
10
+ * - Optional API key authentication for remote/multi-host setups
11
+ *
12
+ * Environment variables:
13
+ * PORT — HTTP gateway port (default: 4321)
14
+ * PERSYST_HOST — Bind address (default: 127.0.0.1, use 0.0.0.0 for Docker/remote)
15
+ * PERSYST_API_KEY — Optional auth token. If set, all endpoints (except /health) require
16
+ * Authorization: Bearer <token>
17
+ *
18
+ * All logging goes to stderr via console.error().
19
+ */
20
+
21
+ import http from 'http';
22
+ import { URL } from 'url';
23
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
+ import { registerTools, cleanupWatchers, addMemoryInternal, executeToolInternal } from './tools.js';
26
+ import {
27
+ applyTemporalDecay,
28
+ closeDatabase,
29
+ getActiveMemoryCount,
30
+ getNamespaceStats,
31
+ getAllAgentStats,
32
+ getAttestationsByDateRange
33
+ } from './database.js';
34
+ import { consolidateMemories, searchHybrid, getOptimizedContext } from './search.js';
35
+ import { startWatcher, stopWatcher } from './watcher.js';
36
+ import { verifyChainIntegrity } from './attestation.js';
37
+ import { memoryEventBus } from './events.js';
38
+
39
+ // Track server birth time for uptime reporting
40
+ const SERVER_START_TIME = Date.now();
41
+
42
+ // Active SSE client response objects
43
+ const sseClients = new Set();
44
+
45
+ // ============================================================
46
+ // SYSTEM PROMPT FORMATTER
47
+ // ============================================================
48
+
49
+ /**
50
+ * Format optimized context data into a structured system-prompt block.
51
+ * Supports three output formats: 'text', 'markdown', 'json'.
52
+ *
53
+ * @param {Object} contextData - Result from getOptimizedContext()
54
+ * @param {string} format - 'text' | 'markdown' | 'json'
55
+ * @param {string|null} agentId
56
+ * @returns {string}
57
+ */
58
+ function formatSystemPrompt(contextData, format, agentId) {
59
+ const { memories, suggested_actions } = contextData;
60
+ const now = new Date().toLocaleString('en-US', { hour12: false }).replace(',', '');
61
+ const count = memories.length;
62
+
63
+ if (format === 'json') {
64
+ return JSON.stringify({ ...contextData, generated_at: new Date().toISOString() }, null, 2);
65
+ }
66
+
67
+ // Group memories by category prefix
68
+ const groups = {
69
+ 'Rules & Conventions': [],
70
+ 'Architecture & Stack': [],
71
+ 'Decisions': [],
72
+ 'Preferences': [],
73
+ 'Context': []
74
+ };
75
+
76
+ for (const m of memories) {
77
+ const c = m.content;
78
+ if (/^(?:Rule|Config):/i.test(c)) groups['Rules & Conventions'].push(c);
79
+ else if (/^(?:Stack|Architecture):/i.test(c)) groups['Architecture & Stack'].push(c);
80
+ else if (/^Decision:/i.test(c)) groups['Decisions'].push(c);
81
+ else if (/^Preference:/i.test(c)) groups['Preferences'].push(c);
82
+ else groups['Context'].push(c);
83
+ }
84
+
85
+ if (format === 'markdown') {
86
+ let md = `# Persyst Memory Context\n`;
87
+ md += `> ${count} memories | Updated: ${now}`;
88
+ if (agentId) md += ` | Agent: \`${agentId}\``;
89
+ md += '\n\n';
90
+
91
+ for (const [section, items] of Object.entries(groups)) {
92
+ if (items.length === 0) continue;
93
+ md += `## ${section}\n`;
94
+ for (const item of items) md += `- ${item}\n`;
95
+ md += '\n';
96
+ }
97
+
98
+ if (suggested_actions.length > 0) {
99
+ md += `## Suggested Actions\n`;
100
+ for (const a of suggested_actions) md += `- ${a}\n`;
101
+ md += '\n';
102
+ }
103
+
104
+ md += `---\n*Refresh: \`curl http://127.0.0.1:4321/system-prompt?format=markdown\`*\n`;
105
+ return md;
106
+ }
107
+
108
+ // Plain text (default) safe to paste into any IDE custom instructions
109
+ let text = `=== PERSYST MEMORY CONTEXT ===\n`;
110
+ text += `Updated: ${now} | ${count} memories`;
111
+ if (agentId) text += ` | Agent: ${agentId}`;
112
+ text += '\n\n';
113
+
114
+ for (const [section, items] of Object.entries(groups)) {
115
+ if (items.length === 0) continue;
116
+ text += `[${section.toUpperCase()}]\n`;
117
+ for (const item of items) text += `• ${item}\n`;
118
+ text += '\n';
119
+ }
120
+
121
+ if (suggested_actions.length > 0) {
122
+ text += `[SUGGESTED ACTIONS]\n`;
123
+ for (const a of suggested_actions) text += `• ${a}\n`;
124
+ text += '\n';
125
+ }
126
+
127
+ text += `=== END MEMORY CONTEXT ===\n`;
128
+ text += `Refresh: curl http://127.0.0.1:${process.env.PORT || '4321'}/system-prompt\n`;
129
+ return text;
130
+ }
131
+
132
+ // ============================================================
133
+ // REQUEST HANDLERS
134
+ // ============================================================
135
+
136
+ async function handleGetRequest(req, res, url) {
137
+ const path = url.pathname;
138
+
139
+ // ----------------------------------------------------------
140
+ // GET /health — server liveness check for orchestrators
141
+ // ----------------------------------------------------------
142
+ if (path === '/health') {
143
+ const uptime = Math.floor((Date.now() - SERVER_START_TIME) / 1000);
144
+ let memories = 0;
145
+ try { memories = getActiveMemoryCount(); } catch (_) {}
146
+ res.writeHead(200, { 'Content-Type': 'application/json' });
147
+ res.end(JSON.stringify({
148
+ ok: true,
149
+ version: '2.2.6',
150
+ uptime_seconds: uptime,
151
+ memories,
152
+ sse_clients: sseClients.size
153
+ }));
154
+ return;
155
+ }
156
+
157
+ // ----------------------------------------------------------
158
+ // GET /stats — memory and agent statistics
159
+ // ----------------------------------------------------------
160
+ if (path === '/stats') {
161
+ try {
162
+ const namespaces = getNamespaceStats();
163
+ const agents = getAllAgentStats();
164
+ const uptime = Math.floor((Date.now() - SERVER_START_TIME) / 1000);
165
+ res.writeHead(200, { 'Content-Type': 'application/json' });
166
+ res.end(JSON.stringify({ uptime_seconds: uptime, namespaces, agents }));
167
+ } catch (err) {
168
+ res.writeHead(500, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({ error: err.message }));
170
+ }
171
+ return;
172
+ }
173
+
174
+ // ----------------------------------------------------------
175
+ // GET /compliance/export — cryptographic audit log export
176
+ //
177
+ // Query params:
178
+ // start ISO timestamp or Unix epoch (default: beginning of time)
179
+ // end ISO timestamp or Unix epoch (default: current time)
180
+ // format — 'json' (default) | 'markdown'
181
+ // ----------------------------------------------------------
182
+ if (path === '/compliance/export') {
183
+ try {
184
+ const startParam = url.searchParams.get('start');
185
+ const endParam = url.searchParams.get('end');
186
+ const format = url.searchParams.get('format') || 'json';
187
+
188
+ // Parse start and end
189
+ let startDate = '0000-01-01T00:00:00.000Z';
190
+ let endDate = new Date().toISOString();
191
+
192
+ if (startParam) {
193
+ if (!isNaN(startParam)) {
194
+ startDate = new Date(parseInt(startParam, 10)).toISOString();
195
+ } else {
196
+ startDate = new Date(startParam).toISOString();
197
+ }
198
+ }
199
+ if (endParam) {
200
+ if (!isNaN(endParam)) {
201
+ endDate = new Date(parseInt(endParam, 10)).toISOString();
202
+ } else {
203
+ endDate = new Date(endParam).toISOString();
204
+ }
205
+ }
206
+
207
+ const attestations = getAttestationsByDateRange(startDate, endDate);
208
+ const agents = getAllAgentStats();
209
+ const summary = {
210
+ exported_at: new Date().toISOString(),
211
+ start_date: startDate,
212
+ end_date: endDate,
213
+ total_attestations: attestations.length,
214
+ system_integrity: 'SECURE'
215
+ };
216
+
217
+ if (format === 'markdown') {
218
+ let md = `# Persyst Cryptographic Compliance Export\n\n`;
219
+ md += `Exported at: \`${summary.exported_at}\` \n`;
220
+ md += `Period: \`${summary.start_date}\` to \`${summary.end_date}\` \n`;
221
+ md += `Total audit records: **${summary.total_attestations}** \n`;
222
+ md += `System cryptographic status: **${summary.system_integrity}** \n\n`;
223
+
224
+ md += `## Agent Trust Reputation Ledger\n\n`;
225
+ md += `| Agent ID | Created | Confirmed | Contradicted | Trust Score |\n`;
226
+ md += `|---|---|---|---|---|\n`;
227
+ for (const a of agents) {
228
+ md += `| \`${a.agent_id}\` | ${a.memories_created} | ${a.memories_confirmed} | ${a.memories_contradicted} | **${parseFloat(a.reputation_score).toFixed(2)}** |\n`;
229
+ }
230
+ md += `\n`;
231
+
232
+ md += `## Attestation Audit Trail\n\n`;
233
+ if (attestations.length === 0) {
234
+ md += `*No attestations found in the specified range.*\n`;
235
+ } else {
236
+ for (const att of attestations) {
237
+ md += `### Attestation \`${att.attestation_id}\`\n`;
238
+ md += `- **Timestamp:** \`${att.timestamp}\`\n`;
239
+ md += `- **Agent namespace:** \`${att.agent_id || 'shared'}\`\n`;
240
+ md += `- **Query:** *"${att.query}"*\n`;
241
+ md += `- **Previous Attestation Hash:** \`${att.previous_hash || 'GENESIS'}\`\n`;
242
+ md += `- **Current Signature Hash:** \`${att.hash}\`\n`;
243
+ md += `- **Signature:** \`${att.signature.substring(0, 32)}...\`\n`;
244
+
245
+ let retrieved = [];
246
+ try {
247
+ retrieved = JSON.parse(att.memories_retrieved);
248
+ } catch (_) {}
249
+
250
+ if (retrieved.length > 0) {
251
+ md += `- **Memories retrieved:**\n`;
252
+ for (const m of retrieved) {
253
+ md += ` - ID: \`${m.id}\`, Hash: \`${m.content_hash}\`, Score: \`${m.score}\`\n`;
254
+ }
255
+ } else {
256
+ md += `- **Memories retrieved:** None\n`;
257
+ }
258
+ md += `\n---\n`;
259
+ }
260
+ }
261
+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
262
+ res.end(md);
263
+ } else {
264
+ res.writeHead(200, { 'Content-Type': 'application/json' });
265
+ res.end(JSON.stringify({
266
+ summary,
267
+ agent_stats: agents,
268
+ attestations: attestations.map(att => ({
269
+ ...att,
270
+ memories_retrieved: (() => {
271
+ try { return JSON.parse(att.memories_retrieved); } catch (_) { return []; }
272
+ })()
273
+ }))
274
+ }, null, 2));
275
+ }
276
+ } catch (err) {
277
+ res.writeHead(500, { 'Content-Type': 'application/json' });
278
+ res.end(JSON.stringify({ error: err.message }));
279
+ }
280
+ return;
281
+ }
282
+
283
+ // ----------------------------------------------------------
284
+ // GET /system-prompt — formatted memory context for IDE injection
285
+ //
286
+ // Query params:
287
+ // query — search query (default: broad project context)
288
+ // max_tokens token budget (default: 1500)
289
+ // agent_id — restrict to this agent's namespace
290
+ // format — 'text' (default) | 'markdown' | 'json'
291
+ // ----------------------------------------------------------
292
+ if (path === '/system-prompt') {
293
+ try {
294
+ const query = url.searchParams.get('query') ||
295
+ 'project conventions architecture preferences rules stack decisions';
296
+ const maxTokens = Math.max(100, parseInt(url.searchParams.get('max_tokens') || '1500', 10));
297
+ const agentId = url.searchParams.get('agent_id') || null;
298
+ const format = url.searchParams.get('format') || 'text';
299
+
300
+ const contextData = await getOptimizedContext(
301
+ query, maxTokens, agentId, null, agentId || null, null
302
+ );
303
+
304
+ const output = formatSystemPrompt(contextData, format, agentId);
305
+
306
+ const contentTypeMap = {
307
+ json: 'application/json',
308
+ markdown: 'text/markdown; charset=utf-8',
309
+ text: 'text/plain; charset=utf-8'
310
+ };
311
+ res.writeHead(200, {
312
+ 'Content-Type': contentTypeMap[format] || 'text/plain; charset=utf-8',
313
+ 'Cache-Control': 'no-cache'
314
+ });
315
+ res.end(output);
316
+ } catch (err) {
317
+ res.writeHead(500, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ error: err.message }));
319
+ }
320
+ return;
321
+ }
322
+
323
+ // ----------------------------------------------------------
324
+ // GET /events — Server-Sent Events stream of memory changes
325
+ //
326
+ // Clients subscribe once and receive real-time push notifications
327
+ // for memory_added, memory_deleted, memories_consolidated events.
328
+ //
329
+ // Example (Python):
330
+ // import sseclient, requests
331
+ // for event in sseclient.SSEClient('http://127.0.0.1:4321/events'):
332
+ // print(event.event, event.data)
333
+ //
334
+ // Example (Node.js):
335
+ // const es = new EventSource('http://127.0.0.1:4321/events');
336
+ // es.addEventListener('memory_added', e => console.log(JSON.parse(e.data)));
337
+ // ----------------------------------------------------------
338
+ if (path === '/events') {
339
+ res.writeHead(200, {
340
+ 'Content-Type': 'text/event-stream',
341
+ 'Cache-Control': 'no-cache',
342
+ 'Connection': 'keep-alive',
343
+ 'Access-Control-Allow-Origin': '*',
344
+ 'X-Accel-Buffering': 'no' // Prevents nginx from buffering SSE
345
+ });
346
+
347
+ // Send initial connected event
348
+ res.write(`event: connected\ndata: ${JSON.stringify({
349
+ ok: true,
350
+ timestamp: new Date().toISOString(),
351
+ server_version: '2.2.6'
352
+ })}\n\n`);
353
+
354
+ sseClients.add(res);
355
+
356
+ // Heartbeat every 15s to keep connection alive through proxies
357
+ const heartbeat = setInterval(() => {
358
+ try { res.write(': heartbeat\n\n'); } catch (_) { clearInterval(heartbeat); }
359
+ }, 15000);
360
+
361
+ const onAdded = (data) => {
362
+ try { res.write(`event: memory_added\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
363
+ };
364
+ const onDeleted = (data) => {
365
+ try { res.write(`event: memory_deleted\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
366
+ };
367
+ const onConsolidated = (data) => {
368
+ try { res.write(`event: memories_consolidated\ndata: ${JSON.stringify(data)}\n\n`); } catch (_) {}
369
+ };
370
+
371
+ memoryEventBus.on('memory_added', onAdded);
372
+ memoryEventBus.on('memory_deleted', onDeleted);
373
+ memoryEventBus.on('memories_consolidated', onConsolidated);
374
+
375
+ req.on('close', () => {
376
+ clearInterval(heartbeat);
377
+ memoryEventBus.off('memory_added', onAdded);
378
+ memoryEventBus.off('memory_deleted', onDeleted);
379
+ memoryEventBus.off('memories_consolidated', onConsolidated);
380
+ sseClients.delete(res);
381
+ console.error(`[persyst-sse] Client disconnected. Active: ${sseClients.size}`);
382
+ });
383
+
384
+ console.error(`[persyst-sse] Client connected. Active: ${sseClients.size}`);
385
+ return; // Keep connection alive — do NOT end response
386
+ }
387
+
388
+ res.writeHead(404, { 'Content-Type': 'application/json' });
389
+ res.end(JSON.stringify({ error: 'Not Found' }));
390
+ }
391
+
392
+ async function handlePostRequest(req, res, payload) {
393
+ const path = new URL(req.url, 'http://127.0.0.1').pathname;
394
+
395
+ // ----------------------------------------------------------
396
+ // POST /remember — quick one-liner memory save
397
+ //
398
+ // The user explicitly wants to save something. No extraction,
399
+ // no filtering, no pattern matching. Just store it.
400
+ //
401
+ // Body: { content: string, importance?: number, namespace?: string }
402
+ // OR: plain text body (e.g. from curl --data "don't forget X")
403
+ //
404
+ // Example:
405
+ // curl -X POST http://127.0.0.1:4321/remember \
406
+ // -H 'Content-Type: text/plain' \
407
+ // --data 'SSL cert expires March 15'
408
+ // ----------------------------------------------------------
409
+ if (path === '/remember') {
410
+ // Support both plain text and JSON bodies
411
+ let content, importance, namespace;
412
+ if (typeof payload === 'string') {
413
+ content = payload.trim();
414
+ importance = 1.0;
415
+ namespace = 'shared';
416
+ } else {
417
+ content = payload.content || payload.text || payload.note || payload.message;
418
+ importance = payload.importance || 1.0;
419
+ namespace = payload.namespace || 'shared';
420
+ }
421
+
422
+ if (!content) {
423
+ res.writeHead(400, { 'Content-Type': 'application/json' });
424
+ res.end(JSON.stringify({ error: 'No content provided. Pass plain text or { content: "..." }' }));
425
+ return;
426
+ }
427
+
428
+ // Prefix with Note: if not already categorized
429
+ const normalizedContent = /^(?:Note|Reminder|Rule|Decision|Preference|Stack|Architecture|Config|Warning|FYI):/i.test(content.trim())
430
+ ? content.trim()
431
+ : `Note: ${content.trim()}`;
432
+
433
+ const result = await addMemoryInternal({
434
+ content: normalizedContent,
435
+ importance,
436
+ agent_id: payload.agent_id || null,
437
+ session_id: payload.session_id || null,
438
+ shared: payload.shared !== false
439
+ });
440
+
441
+ if (!result.error) {
442
+ memoryEventBus.emit('memory_added', {
443
+ id: result.id,
444
+ content: normalizedContent,
445
+ namespace: result.namespace || namespace,
446
+ source: 'user-explicit'
447
+ });
448
+ }
449
+
450
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
451
+ res.end(JSON.stringify(result));
452
+ return;
453
+ }
454
+
455
+ // ----------------------------------------------------------
456
+ // POST /search
457
+ // ----------------------------------------------------------
458
+ if (path === '/search') {
459
+ const { query, limit = 5, agent_id, session_id } = payload;
460
+ if (!query) {
461
+ res.writeHead(400, { 'Content-Type': 'application/json' });
462
+ res.end(JSON.stringify({ error: 'Missing required field: query' }));
463
+ return;
464
+ }
465
+ const results = await searchHybrid(query, limit, agent_id, session_id, agent_id || null);
466
+ res.writeHead(200, { 'Content-Type': 'application/json' });
467
+ res.end(JSON.stringify({ success: true, results }));
468
+ return;
469
+ }
470
+
471
+ // ----------------------------------------------------------
472
+ // POST /add
473
+ // ----------------------------------------------------------
474
+ if (path === '/add') {
475
+ const { content, importance = 1.0, agent_id, session_id, shared = true } = payload;
476
+ if (!content) {
477
+ res.writeHead(400, { 'Content-Type': 'application/json' });
478
+ res.end(JSON.stringify({ error: 'Missing required field: content' }));
479
+ return;
480
+ }
481
+ const result = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
482
+ if (result.error) {
483
+ res.writeHead(400, { 'Content-Type': 'application/json' });
484
+ } else {
485
+ res.writeHead(200, { 'Content-Type': 'application/json' });
486
+ // Broadcast to SSE subscribers
487
+ memoryEventBus.emit('memory_added', {
488
+ id: result.id,
489
+ content,
490
+ namespace: result.namespace,
491
+ source: agent_id || 'http'
492
+ });
493
+ }
494
+ res.end(JSON.stringify(result));
495
+ return;
496
+ }
497
+
498
+ // ----------------------------------------------------------
499
+ // POST /context
500
+ // ----------------------------------------------------------
501
+ if (path === '/context') {
502
+ const { query, max_tokens = 2000, agent_id, session_id, intent } = payload;
503
+ if (!query) {
504
+ res.writeHead(400, { 'Content-Type': 'application/json' });
505
+ res.end(JSON.stringify({ error: 'Missing required field: query' }));
506
+ return;
507
+ }
508
+ const context = await getOptimizedContext(query, max_tokens, agent_id, session_id, agent_id || null, intent);
509
+ res.writeHead(200, { 'Content-Type': 'application/json' });
510
+ res.end(JSON.stringify(context));
511
+ return;
512
+ }
513
+
514
+ // ----------------------------------------------------------
515
+ // POST /tool generic MCP tool invocation
516
+ // ----------------------------------------------------------
517
+ if (path === '/tool') {
518
+ const { name, arguments: args } = payload;
519
+ if (!name) {
520
+ res.writeHead(400, { 'Content-Type': 'application/json' });
521
+ res.end(JSON.stringify({ error: 'Missing required field: name' }));
522
+ return;
523
+ }
524
+ let result;
525
+ try {
526
+ result = await executeToolInternal(name, args || {});
527
+ } catch (err) {
528
+ res.writeHead(400, { 'Content-Type': 'application/json' });
529
+ res.end(JSON.stringify({ error: err.message }));
530
+ return;
531
+ }
532
+ res.writeHead(200, { 'Content-Type': 'application/json' });
533
+ res.end(JSON.stringify(result));
534
+ return;
535
+ }
536
+
537
+ // ----------------------------------------------------------
538
+ // POST /verify — chain integrity check
539
+ // ----------------------------------------------------------
540
+ if (path === '/verify') {
541
+ const attestationId = payload?.attestation_id;
542
+ const result = verifyChainIntegrity(attestationId);
543
+ res.writeHead(200, { 'Content-Type': 'application/json' });
544
+ res.end(JSON.stringify(result));
545
+ return;
546
+ }
547
+
548
+ // ----------------------------------------------------------
549
+ // POST /batch/add — store multiple memories in one round trip
550
+ //
551
+ // Body: { memories: [{ content, importance?, agent_id?, shared? }, ...] }
552
+ // Returns: { success, results: [...], stored, skipped, errors }
553
+ //
554
+ // Designed for:
555
+ // - Swarm agents ingesting session summaries in bulk
556
+ // - Migration tools
557
+ // - CI pipelines storing build/test results
558
+ // ----------------------------------------------------------
559
+ if (path === '/batch/add') {
560
+ const { memories } = payload;
561
+ if (!Array.isArray(memories) || memories.length === 0) {
562
+ res.writeHead(400, { 'Content-Type': 'application/json' });
563
+ res.end(JSON.stringify({ error: 'memories must be a non-empty array' }));
564
+ return;
565
+ }
566
+
567
+ // Hard cap: prevent abuse
568
+ if (memories.length > 200) {
569
+ res.writeHead(400, { 'Content-Type': 'application/json' });
570
+ res.end(JSON.stringify({ error: 'Batch size exceeds maximum of 200' }));
571
+ return;
572
+ }
573
+
574
+ const results = [];
575
+ let stored = 0;
576
+ let skipped = 0;
577
+ let errors = 0;
578
+
579
+ for (const mem of memories) {
580
+ const { content, importance = 1.0, agent_id, session_id, shared = true } = mem;
581
+ if (!content) {
582
+ results.push({ error: 'Missing content', input: mem });
583
+ errors++;
584
+ continue;
585
+ }
586
+ try {
587
+ const result = await addMemoryInternal({ content, importance, agent_id, session_id, shared });
588
+ results.push(result);
589
+ if (result.error) {
590
+ errors++;
591
+ } else if (result.message && result.message.includes('already exists')) {
592
+ skipped++;
593
+ } else {
594
+ stored++;
595
+ memoryEventBus.emit('memory_added', {
596
+ id: result.id,
597
+ content,
598
+ namespace: result.namespace,
599
+ source: agent_id || 'batch'
600
+ });
601
+ }
602
+ } catch (err) {
603
+ results.push({ error: err.message, input: mem });
604
+ errors++;
605
+ }
606
+ }
607
+
608
+ res.writeHead(200, { 'Content-Type': 'application/json' });
609
+ res.end(JSON.stringify({ success: true, results, stored, skipped, errors }));
610
+ return;
611
+ }
612
+
613
+ // ----------------------------------------------------------
614
+ // POST /batch/search run multiple queries in one round trip
615
+ //
616
+ // Body: { queries: string[] | Array<{query, limit?, agent_id?}>, limit?: number }
617
+ // Returns: { results: { "<query>": [...memories] } }
618
+ //
619
+ // Designed for:
620
+ // - Swarm agents loading context for multiple topics at once
621
+ // - Parallel memory retrieval without sequential round trips
622
+ // ----------------------------------------------------------
623
+ if (path === '/batch/search') {
624
+ const { queries, limit = 5 } = payload;
625
+ if (!Array.isArray(queries) || queries.length === 0) {
626
+ res.writeHead(400, { 'Content-Type': 'application/json' });
627
+ res.end(JSON.stringify({ error: 'queries must be a non-empty array' }));
628
+ return;
629
+ }
630
+
631
+ if (queries.length > 50) {
632
+ res.writeHead(400, { 'Content-Type': 'application/json' });
633
+ res.end(JSON.stringify({ error: 'Batch query size exceeds maximum of 50' }));
634
+ return;
635
+ }
636
+
637
+ // Run all searches in parallel for speed
638
+ const searchPromises = queries.map(async (q) => {
639
+ if (typeof q === 'string') {
640
+ return { key: q, results: await searchHybrid(q, limit, null, null, null) };
641
+ } else if (q && typeof q === 'object' && q.query) {
642
+ return {
643
+ key: q.query,
644
+ results: await searchHybrid(q.query, q.limit || limit, q.agent_id || null, null, q.agent_id || null)
645
+ };
646
+ }
647
+ return { key: String(q), results: [] };
648
+ });
649
+
650
+ const settled = await Promise.allSettled(searchPromises);
651
+ const results = {};
652
+ for (const s of settled) {
653
+ if (s.status === 'fulfilled') {
654
+ results[s.value.key] = s.value.results;
655
+ }
656
+ }
657
+
658
+ res.writeHead(200, { 'Content-Type': 'application/json' });
659
+ res.end(JSON.stringify({ success: true, results }));
660
+ return;
661
+ }
662
+
663
+ res.writeHead(404, { 'Content-Type': 'application/json' });
664
+ res.end(JSON.stringify({ error: 'Endpoint Not Found' }));
665
+ }
666
+
667
+ // ============================================================
668
+ // MAIN SERVER STARTUP
669
+ // ============================================================
670
+
671
+ /**
672
+ * Start the Persyst MCP server & HTTP Gateway.
673
+ */
674
+ export async function startServer() {
675
+ // --- Create MCP server ---
676
+ const server = new McpServer({
677
+ name: 'persyst',
678
+ version: '2.2.5'
679
+ });
680
+
681
+ // --- Register all tools ---
682
+ const registeredCount = registerTools(server);
683
+ console.error(`[persyst] ${registeredCount} tools registered ✓`);
684
+
685
+ // --- Start background log watcher daemon (skip in test mode) ---
686
+ if (process.env.NODE_ENV !== 'test') {
687
+ startWatcher();
688
+ }
689
+
690
+ // --- Gateway configuration ---
691
+ const httpPort = parseInt(process.env.PORT || '4321', 10);
692
+ const httpHost = process.env.PERSYST_HOST || '127.0.0.1';
693
+ const configuredApiKey = process.env.PERSYST_API_KEY || null;
694
+
695
+ if (configuredApiKey) {
696
+ console.error(`[persyst] API key auth enabled — endpoints require Authorization: Bearer <key>`);
697
+ }
698
+ if (httpHost !== '127.0.0.1') {
699
+ console.error(`[persyst] ⚠️ Gateway bound to ${httpHost} — ensure PERSYST_API_KEY is set for security`);
700
+ }
701
+
702
+ // --- Start local HTTP Gateway ---
703
+ const httpServer = http.createServer((req, res) => {
704
+ // CORS headers
705
+ res.setHeader('Access-Control-Allow-Origin', '*');
706
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
707
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
708
+
709
+ if (req.method === 'OPTIONS') {
710
+ res.writeHead(204);
711
+ res.end();
712
+ return;
713
+ }
714
+
715
+ // API key authentication middleware
716
+ // /health is always public (for orchestrators / Docker health checks)
717
+ if (configuredApiKey) {
718
+ const urlPath = new URL(req.url || '/', 'http://127.0.0.1').pathname;
719
+ if (urlPath !== '/health') {
720
+ const authHeader = req.headers['authorization'] || '';
721
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
722
+ if (token !== configuredApiKey) {
723
+ res.writeHead(401, { 'Content-Type': 'application/json' });
724
+ res.end(JSON.stringify({
725
+ error: 'Unauthorized. Set header: Authorization: Bearer <PERSYST_API_KEY>'
726
+ }));
727
+ return;
728
+ }
729
+ }
730
+ }
731
+
732
+ // Route GET requests (no body reading needed)
733
+ if (req.method === 'GET') {
734
+ try {
735
+ const url = new URL(req.url || '/', `http://${httpHost}`);
736
+ handleGetRequest(req, res, url).catch(err => {
737
+ try {
738
+ res.writeHead(500, { 'Content-Type': 'application/json' });
739
+ res.end(JSON.stringify({ error: err.message }));
740
+ } catch (_) {}
741
+ });
742
+ } catch (err) {
743
+ res.writeHead(400, { 'Content-Type': 'application/json' });
744
+ res.end(JSON.stringify({ error: 'Bad request URL' }));
745
+ }
746
+ return;
747
+ }
748
+
749
+ // Route POST requests
750
+ if (req.method !== 'POST') {
751
+ res.writeHead(405, { 'Content-Type': 'application/json' });
752
+ res.end(JSON.stringify({ error: 'Method Not Allowed. Use POST or GET.' }));
753
+ return;
754
+ }
755
+
756
+ let body = '';
757
+ req.on('data', chunk => { body += chunk; });
758
+ req.on('end', async () => {
759
+ try {
760
+ // Handle both JSON and plain-text bodies (plain text used by /remember)
761
+ const contentType = req.headers['content-type'] || '';
762
+ let payload;
763
+ if (contentType.includes('text/plain')) {
764
+ payload = body.trim(); // Will be handled as string in /remember
765
+ } else {
766
+ payload = JSON.parse(body || '{}');
767
+ }
768
+ await handlePostRequest(req, res, payload);
769
+ } catch (err) {
770
+ try {
771
+ res.writeHead(500, { 'Content-Type': 'application/json' });
772
+ res.end(JSON.stringify({ error: err.message }));
773
+ } catch (_) {}
774
+ }
775
+ });
776
+ });
777
+
778
+ httpServer.on('error', (err) => {
779
+ if (err.code === 'EADDRINUSE') {
780
+ console.error(`[persyst] HTTP Gateway port ${httpPort} already in use. Stdio MCP server will continue.`);
781
+ } else {
782
+ console.error('[persyst] HTTP Gateway error:', err.message);
783
+ }
784
+ });
785
+
786
+ httpServer.listen(httpPort, httpHost, () => {
787
+ console.error(`[persyst] HTTP Gateway listening on http://${httpHost}:${httpPort} ✓`);
788
+ console.error(`[persyst] Endpoints: /health /stats /system-prompt /events /remember /search /add /context /tool /verify /batch/add /batch/search`);
789
+ });
790
+
791
+ // --- Start temporal decay timer (every hour) ---
792
+ const decayTimer = setInterval(applyTemporalDecay, 3600000);
793
+
794
+ // --- Start daily consolidation sweep ---
795
+ const consolidationTimer = setInterval(async () => {
796
+ console.error('[persyst] Running scheduled daily memory consolidation sweep...');
797
+ try {
798
+ const report = await consolidateMemories();
799
+ console.error(`[persyst] Consolidation sweep: consolidated ${report.consolidated_groups} duplicate groups.`);
800
+ if (report.consolidated_groups > 0) {
801
+ memoryEventBus.emit('memories_consolidated', {
802
+ consolidated_groups: report.consolidated_groups,
803
+ details: report.details
804
+ });
805
+ }
806
+ } catch (err) {
807
+ console.error('[persyst] Daily consolidation sweep failed:', err.message);
808
+ }
809
+ }, 86400000);
810
+
811
+ // --- SSE client health-check every 30 seconds (removes stale connections) ---
812
+ const sseHealthCheck = setInterval(() => {
813
+ for (const client of sseClients) {
814
+ try {
815
+ client.write(': health-check\n\n');
816
+ } catch (_) {
817
+ // Client is stale — remove it
818
+ try { client.end(); } catch (_) {}
819
+ sseClients.delete(client);
820
+ }
821
+ }
822
+ }, 30000);
823
+
824
+ // --- Graceful shutdown ---
825
+ const shutdown = () => {
826
+ console.error('[persyst] Shutting down...');
827
+ clearInterval(decayTimer);
828
+ clearInterval(consolidationTimer);
829
+ clearInterval(sseHealthCheck);
830
+ stopWatcher();
831
+ cleanupWatchers();
832
+
833
+ // Close all SSE connections gracefully
834
+ for (const client of sseClients) {
835
+ try {
836
+ client.write(`event: server_shutdown\ndata: ${JSON.stringify({ message: 'Server shutting down' })}\n\n`);
837
+ client.end();
838
+ } catch (_) {}
839
+ }
840
+ sseClients.clear();
841
+
842
+ httpServer.close();
843
+ closeDatabase();
844
+ // Let the process exit naturally after all handles are closed
845
+ // process.exit(0) removed — Node exits on its own when event loop is empty
846
+ };
847
+ process.on('SIGINT', shutdown);
848
+ process.on('SIGTERM', shutdown);
849
+
850
+ // --- Connect via stdio ---
851
+ const transport = new StdioServerTransport();
852
+ await server.connect(transport);
853
+
854
+ console.error('[persyst] MCP server running on stdio ✓');
855
+ console.error('[persyst] Ready to receive tool calls');
856
+ }