@jungjaehoon/mama-server 1.3.1 → 1.4.1

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/README.md CHANGED
@@ -207,4 +207,4 @@ MAMA was inspired by [mem0](https://github.com/mem0ai/mem0) (Apache 2.0). While
207
207
  ---
208
208
 
209
209
  **Author:** SpineLift Team
210
- **Version:** 1.3.0
210
+ **Version:** 1.3.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungjaehoon/mama-server",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "MAMA MCP Server - Memory-Augmented MCP Assistant for Claude Code & Desktop",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -47,6 +47,8 @@
47
47
  },
48
48
  "files": [
49
49
  "src/**/*.js",
50
+ "src/**/*.css",
51
+ "src/**/*.html",
50
52
  "src/db/migrations/*.sql",
51
53
  "README.md",
52
54
  "LICENSE"
@@ -20,6 +20,13 @@ const fs = require('fs');
20
20
  // Import embedding functions from mama module
21
21
  const { generateEmbedding } = require('./mama/embeddings.js');
22
22
  const { getModelName, getEmbeddingDim } = require('./mama/config-loader.js');
23
+ const { initDB } = require('./mama/memory-store.js');
24
+
25
+ // Import Graph API handler
26
+ const { createGraphHandler } = require('./viewer/graph-api.js');
27
+
28
+ // Create graph handler instance
29
+ const graphHandler = createGraphHandler();
23
30
 
24
31
  // Configuration
25
32
  const DEFAULT_PORT = 3847;
@@ -54,6 +61,10 @@ function readBody(req) {
54
61
  */
55
62
  async function handleRequest(req, res) {
56
63
  // CORS headers for local requests
64
+ // Security Note: CORS '*' is safe here because:
65
+ // 1. Server binds to localhost only (127.0.0.1)
66
+ // 2. No sensitive data exposed (user's own decisions)
67
+ // 3. Required for browser-based Graph Viewer
57
68
  res.setHeader('Content-Type', 'application/json');
58
69
  res.setHeader('Access-Control-Allow-Origin', '*');
59
70
 
@@ -137,6 +148,19 @@ async function handleRequest(req, res) {
137
148
  return;
138
149
  }
139
150
 
151
+ // Graph API routes
152
+ try {
153
+ const handled = await graphHandler(req, res);
154
+ if (handled) {
155
+ return;
156
+ }
157
+ } catch (error) {
158
+ console.error(`[EmbeddingHTTP] Graph API error: ${error.message}`);
159
+ res.writeHead(500);
160
+ res.end(JSON.stringify({ error: error.message }));
161
+ return;
162
+ }
163
+
140
164
  // 404 for unknown routes
141
165
  res.writeHead(404);
142
166
  res.end(JSON.stringify({ error: 'Not found' }));
@@ -174,6 +198,9 @@ function cleanupPortFile() {
174
198
  * @returns {Promise<http.Server>} HTTP server instance
175
199
  */
176
200
  async function startEmbeddingServer(port = DEFAULT_PORT) {
201
+ // Initialize database for graph API
202
+ await initDB();
203
+
177
204
  return new Promise((resolve, reject) => {
178
205
  const server = http.createServer(handleRequest);
179
206
 
@@ -248,35 +248,39 @@ function parseReasoningForRelationships(reasoning) {
248
248
 
249
249
  const relationships = [];
250
250
 
251
- // Pattern 1: builds_on: <id> or builds_on: decision_xxx
252
- const buildsOnMatch = reasoning.match(/builds_on:\s*(decision_[a-z0-9_]+)/gi);
251
+ // Pattern 1: builds_on: <id> (allows optional markdown **bold**)
252
+ const buildsOnMatch = reasoning.match(
253
+ /\*{0,2}builds_on\*{0,2}:\*{0,2}\s*(decision_[a-z0-9_]+)/gi
254
+ );
253
255
  if (buildsOnMatch) {
254
256
  buildsOnMatch.forEach((match) => {
255
- const id = match.replace(/builds_on:\s*/i, '').trim();
257
+ const id = match.replace(/\*{0,2}builds_on\*{0,2}:\*{0,2}\s*/i, '').trim();
256
258
  if (id) {
257
259
  relationships.push({ type: 'builds_on', targetIds: [id] });
258
260
  }
259
261
  });
260
262
  }
261
263
 
262
- // Pattern 2: debates: <id> or debates: decision_xxx
263
- const debatesMatch = reasoning.match(/debates:\s*(decision_[a-z0-9_]+)/gi);
264
+ // Pattern 2: debates: <id> (allows optional markdown **bold**)
265
+ const debatesMatch = reasoning.match(/\*{0,2}debates\*{0,2}:\*{0,2}\s*(decision_[a-z0-9_]+)/gi);
264
266
  if (debatesMatch) {
265
267
  debatesMatch.forEach((match) => {
266
- const id = match.replace(/debates:\s*/i, '').trim();
268
+ const id = match.replace(/\*{0,2}debates\*{0,2}:\*{0,2}\s*/i, '').trim();
267
269
  if (id) {
268
270
  relationships.push({ type: 'debates', targetIds: [id] });
269
271
  }
270
272
  });
271
273
  }
272
274
 
273
- // Pattern 3: synthesizes: [id1, id2] or synthesizes: decision_xxx, decision_yyy
275
+ // Pattern 3: synthesizes: [id1, id2] (allows optional markdown **bold**)
274
276
  const synthesizesMatch = reasoning.match(
275
- /synthesizes:\s*\[?\s*(decision_[a-z0-9_]+(?:\s*,\s*decision_[a-z0-9_]+)*)\s*\]?/gi
277
+ /\*{0,2}synthesizes\*{0,2}:\*{0,2}\s*\[?\s*(decision_[a-z0-9_]+(?:\s*,\s*decision_[a-z0-9_]+)*)\s*\]?/gi
276
278
  );
277
279
  if (synthesizesMatch) {
278
280
  synthesizesMatch.forEach((match) => {
279
- const idsStr = match.replace(/synthesizes:\s*\[?\s*/i, '').replace(/\s*\]?\s*$/, '');
281
+ const idsStr = match
282
+ .replace(/\*{0,2}synthesizes\*{0,2}:\*{0,2}\s*\[?\s*/i, '')
283
+ .replace(/\s*\]?\s*$/, '');
280
284
  const ids = idsStr.split(/\s*,\s*/).filter((id) => id.startsWith('decision_'));
281
285
  if (ids.length > 0) {
282
286
  relationships.push({ type: 'synthesizes', targetIds: ids });
package/src/server.js CHANGED
@@ -158,10 +158,15 @@ class MAMAServer {
158
158
  • Architectural choice made
159
159
  • Session ending → use type='checkpoint'
160
160
 
161
- 🔗 BEFORE SAVING (Don't create orphans!):
162
- 1. Call 'search' first to find related decisions
161
+ 🔗 REQUIRED WORKFLOW (Don't create orphans!):
162
+ 1. Call 'search' FIRST to find related decisions
163
163
  2. Check if same topic exists (yours will supersede it)
164
- 3. Note related decision IDs for reasoning field
164
+ 3. MUST include link in reasoning field (see format below)
165
+
166
+ 📎 LINKING FORMAT (at least ONE required):
167
+ • builds_on: decision_xxx (extends prior work)
168
+ • debates: decision_xxx (alternative view)
169
+ • synthesizes: [decision_a, decision_b] (combines multiple)
165
170
 
166
171
  type='decision': choices & lessons (same topic = evolution chain)
167
172
  type='checkpoint': session state for resumption`,
@@ -186,7 +191,7 @@ type='checkpoint': session state for resumption`,
186
191
  reasoning: {
187
192
  type: 'string',
188
193
  description:
189
- "[Decision] Why this decision was made. Include 5-layer narrative: (1) Context - what problem/situation; (2) Evidence - what proves this works (tests, benchmarks, prior experience); (3) Alternatives - what other options were considered and why rejected; (4) Risks - known limitations or failure modes; (5) Rationale - final reasoning for this choice. 💡 TIP: Include 'builds_on: <id>' or 'debates: <id>' to link related decisions.",
194
+ "[Decision] Why this decision was made. Include 5-layer narrative: (1) Context - what problem/situation; (2) Evidence - what proves this works (tests, benchmarks, prior experience); (3) Alternatives - what other options were considered and why rejected; (4) Risks - known limitations or failure modes; (5) Rationale - final reasoning for this choice. ⚠️ REQUIRED: End with 'builds_on: <id>' or 'debates: <id>' or 'synthesizes: [id1, id2]' to link related decisions.",
190
195
  },
191
196
  confidence: {
192
197
  type: 'number',
@@ -198,7 +203,7 @@ type='checkpoint': session state for resumption`,
198
203
  summary: {
199
204
  type: 'string',
200
205
  description:
201
- '[Checkpoint] Session state summary. Use 4-section format: (1) 🎯 Goal & Progress - what was the goal, where did you stop; (2) ✅ Evidence - mark each item as Verified/Not run/Assumed with proof; (3) ⏳ Unfinished & Risks - incomplete work, blockers, unknowns; (4) 🚦 Next Agent Briefing - Definition of Done, quick health checks to run first.',
206
+ "[Checkpoint] Session state summary. Use 4-section format: (1) 🎯 Goal & Progress - what was the goal, where did you stop; (2) ✅ Evidence - mark each item as Verified/Not run/Assumed with proof; (3) ⏳ Unfinished & Risks - incomplete work, blockers, unknowns; (4) 🚦 Next Agent Briefing - Definition of Done, quick health checks to run first. ⚠️ Include 'Related decisions: decision_xxx, decision_yyy' to link context.",
202
207
  },
203
208
  next_steps: {
204
209
  type: 'string',
@@ -220,7 +225,7 @@ type='checkpoint': session state for resumption`,
220
225
  description: `🔍 Search the reasoning graph before acting.
221
226
 
222
227
  ⚡ TRIGGERS - Call this BEFORE:
223
- Saving a new decision (find connections!)
228
+ ⚠️ REQUIRED before 'save' (find links first!)
224
229
  • Making architectural choices (check prior art)
225
230
  • Debugging (find past failures on similar issues)
226
231
  • Starting work on a topic (load context)
@@ -232,7 +237,7 @@ type='checkpoint': session state for resumption`,
232
237
  • Understand decision evolution (time-ordered results)
233
238
 
234
239
  Cross-lingual: Works in Korean and English.
235
- 💡 TIP: High similarity (>0.8) = likely related, consider linking.`,
240
+ ⚠️ High similarity (>0.8) = MUST link with builds_on/debates/synthesizes.`,
236
241
  inputSchema: {
237
242
  type: 'object',
238
243
  properties: {
@@ -0,0 +1,524 @@
1
+ /**
2
+ * MAMA Graph API
3
+ *
4
+ * HTTP API endpoints for Graph Viewer.
5
+ * Provides /graph endpoint for fetching decisions and edges data.
6
+ * Provides /viewer endpoint for serving HTML viewer.
7
+ *
8
+ * Story 1.1: Graph API 엔드포인트 추가
9
+ * Story 1.3: Viewer HTML 서빙
10
+ *
11
+ * @module graph-api
12
+ * @version 1.1.0
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { getAdapter, initDB, vectorSearch } = require('../mama/memory-store');
18
+ const { generateEmbedding } = require('../mama/embeddings');
19
+ const mama = require('../mama/mama-api.js');
20
+
21
+ // Paths to viewer files
22
+ const VIEWER_HTML_PATH = path.join(__dirname, 'viewer.html');
23
+ const VIEWER_CSS_PATH = path.join(__dirname, 'viewer.css');
24
+ const VIEWER_JS_PATH = path.join(__dirname, 'viewer.js');
25
+
26
+ /**
27
+ * Get all decisions as graph nodes
28
+ *
29
+ * @returns {Promise<Array>} Array of node objects
30
+ */
31
+ async function getAllNodes() {
32
+ const adapter = getAdapter();
33
+
34
+ const stmt = adapter.prepare(`
35
+ SELECT
36
+ id,
37
+ topic,
38
+ decision,
39
+ reasoning,
40
+ outcome,
41
+ confidence,
42
+ created_at
43
+ FROM decisions
44
+ ORDER BY created_at DESC
45
+ `);
46
+
47
+ const rows = stmt.all();
48
+
49
+ return rows.map((row) => ({
50
+ id: row.id,
51
+ topic: row.topic,
52
+ decision: row.decision,
53
+ reasoning: row.reasoning,
54
+ outcome: row.outcome,
55
+ confidence: row.confidence,
56
+ created_at: row.created_at,
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Get all decision edges
62
+ *
63
+ * @returns {Promise<Array>} Array of edge objects
64
+ */
65
+ async function getAllEdges() {
66
+ const adapter = getAdapter();
67
+
68
+ const stmt = adapter.prepare(`
69
+ SELECT
70
+ from_id,
71
+ to_id,
72
+ relationship,
73
+ reason
74
+ FROM decision_edges
75
+ ORDER BY created_at DESC
76
+ `);
77
+
78
+ const rows = stmt.all();
79
+
80
+ return rows.map((row) => ({
81
+ from: row.from_id,
82
+ to: row.to_id,
83
+ relationship: row.relationship,
84
+ reason: row.reason,
85
+ }));
86
+ }
87
+
88
+ /**
89
+ * Get unique topics from nodes
90
+ *
91
+ * @param {Array} nodes - Array of node objects
92
+ * @returns {Array<string>} Unique topics
93
+ */
94
+ function getUniqueTopics(nodes) {
95
+ const topicSet = new Set(nodes.map((n) => n.topic));
96
+ return Array.from(topicSet).sort();
97
+ }
98
+
99
+ /**
100
+ * Filter nodes by topic
101
+ *
102
+ * @param {Array} nodes - Array of node objects
103
+ * @param {string} topic - Topic to filter by
104
+ * @returns {Array} Filtered nodes
105
+ */
106
+ function filterNodesByTopic(nodes, topic) {
107
+ return nodes.filter((n) => n.topic === topic);
108
+ }
109
+
110
+ /**
111
+ * Filter edges to only include those connected to given nodes
112
+ *
113
+ * @param {Array} edges - Array of edge objects
114
+ * @param {Array} nodes - Array of node objects (filtered)
115
+ * @returns {Array} Filtered edges
116
+ */
117
+ function filterEdgesByNodes(edges, nodes) {
118
+ const nodeIds = new Set(nodes.map((n) => n.id));
119
+ return edges.filter((e) => nodeIds.has(e.from) || nodeIds.has(e.to));
120
+ }
121
+
122
+ /**
123
+ * Serve static file with appropriate content type
124
+ *
125
+ * @param {Object} res - HTTP response
126
+ * @param {string} filePath - Path to file
127
+ * @param {string} contentType - MIME type
128
+ */
129
+ function serveStaticFile(res, filePath, contentType) {
130
+ try {
131
+ const content = fs.readFileSync(filePath, 'utf8');
132
+ res.writeHead(200, {
133
+ 'Content-Type': `${contentType}; charset=utf-8`,
134
+ 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
135
+ });
136
+ res.end(content);
137
+ } catch (error) {
138
+ console.error(`[GraphAPI] Static file error: ${error.message}`);
139
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
140
+ res.end('Error loading file: ' + error.message);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Handle GET /viewer request - serve HTML viewer
146
+ *
147
+ * @param {Object} req - HTTP request
148
+ * @param {Object} res - HTTP response
149
+ */
150
+ function handleViewerRequest(req, res) {
151
+ serveStaticFile(res, VIEWER_HTML_PATH, 'text/html');
152
+ }
153
+
154
+ /**
155
+ * Handle GET /viewer.css request
156
+ *
157
+ * @param {Object} req - HTTP request
158
+ * @param {Object} res - HTTP response
159
+ */
160
+ function handleCssRequest(req, res) {
161
+ serveStaticFile(res, VIEWER_CSS_PATH, 'text/css');
162
+ }
163
+
164
+ /**
165
+ * Handle GET /viewer.js request
166
+ *
167
+ * @param {Object} req - HTTP request
168
+ * @param {Object} res - HTTP response
169
+ */
170
+ function handleJsRequest(req, res) {
171
+ serveStaticFile(res, VIEWER_JS_PATH, 'application/javascript');
172
+ }
173
+
174
+ /**
175
+ * Handle GET /graph request
176
+ *
177
+ * @param {Object} req - HTTP request
178
+ * @param {Object} res - HTTP response
179
+ * @param {URLSearchParams} params - Query parameters
180
+ */
181
+ async function handleGraphRequest(req, res, params) {
182
+ const startTime = Date.now();
183
+
184
+ try {
185
+ // Ensure DB is initialized
186
+ await initDB();
187
+
188
+ // Get all data
189
+ let nodes = await getAllNodes();
190
+ let edges = await getAllEdges();
191
+
192
+ // Apply topic filter if provided
193
+ const topicFilter = params.get('topic');
194
+ if (topicFilter) {
195
+ nodes = filterNodesByTopic(nodes, topicFilter);
196
+ edges = filterEdgesByNodes(edges, nodes);
197
+ }
198
+
199
+ // Add similarity edges for clustering if requested
200
+ const includeCluster = params.get('cluster') === 'true';
201
+ let similarityEdges = [];
202
+ if (includeCluster) {
203
+ similarityEdges = await getSimilarityEdges();
204
+ // Filter to only include edges for visible nodes
205
+ const nodeIds = new Set(nodes.map((n) => n.id));
206
+ similarityEdges = similarityEdges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to));
207
+ }
208
+
209
+ // Build meta object
210
+ const allTopics = topicFilter ? [topicFilter] : getUniqueTopics(nodes);
211
+ const meta = {
212
+ total_nodes: nodes.length,
213
+ total_edges: edges.length,
214
+ similarity_edges: similarityEdges.length,
215
+ topics: allTopics,
216
+ };
217
+
218
+ const latency = Date.now() - startTime;
219
+
220
+ // Send response
221
+ res.writeHead(200);
222
+ res.end(
223
+ JSON.stringify({
224
+ nodes,
225
+ edges,
226
+ similarityEdges,
227
+ meta,
228
+ latency,
229
+ })
230
+ );
231
+ } catch (error) {
232
+ console.error(`[GraphAPI] Error: ${error.message}`);
233
+ res.writeHead(500);
234
+ res.end(
235
+ JSON.stringify({
236
+ error: true,
237
+ code: 'INTERNAL_ERROR',
238
+ message: error.message,
239
+ })
240
+ );
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Read request body as JSON
246
+ */
247
+ function readBody(req) {
248
+ return new Promise((resolve, reject) => {
249
+ let data = '';
250
+ req.on('data', (chunk) => (data += chunk));
251
+ req.on('end', () => {
252
+ try {
253
+ resolve(JSON.parse(data));
254
+ } catch (e) {
255
+ reject(new Error('Invalid JSON'));
256
+ }
257
+ });
258
+ req.on('error', reject);
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Handle POST /graph/update request - update decision outcome
264
+ *
265
+ * @param {Object} req - HTTP request
266
+ * @param {Object} res - HTTP response
267
+ */
268
+ async function handleUpdateRequest(req, res) {
269
+ try {
270
+ const body = await readBody(req);
271
+
272
+ if (!body.id || !body.outcome) {
273
+ res.writeHead(400);
274
+ res.end(
275
+ JSON.stringify({
276
+ error: true,
277
+ code: 'INVALID_REQUEST',
278
+ message: 'Missing required fields: id, outcome',
279
+ })
280
+ );
281
+ return;
282
+ }
283
+
284
+ // Ensure DB is initialized
285
+ await initDB();
286
+
287
+ // Update outcome using mama-api
288
+ await mama.updateOutcome(body.id, {
289
+ outcome: body.outcome,
290
+ failure_reason: body.reason,
291
+ });
292
+
293
+ res.writeHead(200);
294
+ res.end(
295
+ JSON.stringify({
296
+ success: true,
297
+ id: body.id,
298
+ outcome: body.outcome.toUpperCase(),
299
+ })
300
+ );
301
+ } catch (error) {
302
+ console.error(`[GraphAPI] Update error: ${error.message}`);
303
+ res.writeHead(500);
304
+ res.end(
305
+ JSON.stringify({
306
+ error: true,
307
+ code: 'UPDATE_FAILED',
308
+ message: error.message,
309
+ })
310
+ );
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Get similarity edges for layout clustering
316
+ * Returns edges between highly similar decisions (threshold > 0.7)
317
+ *
318
+ * @returns {Promise<Array>} Array of similarity edge objects
319
+ */
320
+ async function getSimilarityEdges() {
321
+ const adapter = getAdapter();
322
+
323
+ // Get all decisions (embeddings stored in vss_memories table)
324
+ const stmt = adapter.prepare(`
325
+ SELECT id, topic, decision FROM decisions
326
+ ORDER BY created_at DESC
327
+ LIMIT 100
328
+ `);
329
+ const decisions = stmt.all(); // better-sqlite3 is synchronous
330
+
331
+ if (decisions.length < 2) {
332
+ return [];
333
+ }
334
+
335
+ const similarityEdges = [];
336
+ const similarityEdgeKeys = new Set(); // O(1) duplicate checking
337
+
338
+ // For each decision, find its most similar peers
339
+ for (const decision of decisions.slice(0, 50)) {
340
+ // Limit to first 50 for performance
341
+ try {
342
+ const query = `${decision.topic} ${decision.decision}`;
343
+ const embedding = await generateEmbedding(query);
344
+ const similar = await vectorSearch(embedding, 3, 0.7); // Top 3 with >70% similarity
345
+
346
+ for (const s of similar) {
347
+ if (s.id !== decision.id && s.similarity > 0.7) {
348
+ // Avoid duplicates (A->B and B->A) using Set for O(1) lookup
349
+ const edgeKey = [decision.id, s.id].sort().join('|');
350
+ if (!similarityEdgeKeys.has(edgeKey)) {
351
+ similarityEdges.push({
352
+ from: decision.id,
353
+ to: s.id,
354
+ relationship: 'similar',
355
+ similarity: s.similarity,
356
+ });
357
+ similarityEdgeKeys.add(edgeKey);
358
+ }
359
+ }
360
+ }
361
+ } catch (e) {
362
+ console.error(`[GraphAPI] Similarity search error for ${decision.id}:`, e.message);
363
+ }
364
+ }
365
+
366
+ return similarityEdges;
367
+ }
368
+
369
+ /**
370
+ * Handle GET /graph/similar request - find similar decisions
371
+ *
372
+ * @param {Object} req - HTTP request
373
+ * @param {Object} res - HTTP response
374
+ * @param {URLSearchParams} params - Query parameters (id required)
375
+ */
376
+ async function handleSimilarRequest(req, res, params) {
377
+ try {
378
+ const decisionId = params.get('id');
379
+ if (!decisionId) {
380
+ res.writeHead(400);
381
+ res.end(
382
+ JSON.stringify({
383
+ error: true,
384
+ code: 'MISSING_ID',
385
+ message: 'Missing required parameter: id',
386
+ })
387
+ );
388
+ return;
389
+ }
390
+
391
+ // Ensure DB is initialized
392
+ await initDB();
393
+
394
+ // Get the decision by ID
395
+ const adapter = getAdapter();
396
+ const stmt = adapter.prepare(`
397
+ SELECT topic, decision, reasoning FROM decisions WHERE id = ?
398
+ `);
399
+ const decision = stmt.get(decisionId);
400
+
401
+ if (!decision) {
402
+ res.writeHead(404);
403
+ res.end(
404
+ JSON.stringify({
405
+ error: true,
406
+ code: 'NOT_FOUND',
407
+ message: 'Decision not found',
408
+ })
409
+ );
410
+ return;
411
+ }
412
+
413
+ // Build search query from decision content
414
+ const searchQuery = `${decision.topic} ${decision.decision}`;
415
+
416
+ // Use mama.suggest for semantic search
417
+ const results = await mama.suggest(searchQuery, {
418
+ limit: 6, // Get 6 to filter out self
419
+ threshold: 0.5,
420
+ });
421
+
422
+ // Filter out the current decision and format results
423
+ let similar = [];
424
+ if (results && results.results) {
425
+ similar = results.results
426
+ .filter((r) => r.id !== decisionId)
427
+ .slice(0, 5)
428
+ .map((r) => ({
429
+ id: r.id,
430
+ topic: r.topic,
431
+ decision: r.decision,
432
+ similarity: r.similarity || r.final_score || 0.5,
433
+ outcome: r.outcome,
434
+ }));
435
+ }
436
+
437
+ res.writeHead(200);
438
+ res.end(
439
+ JSON.stringify({
440
+ id: decisionId,
441
+ similar,
442
+ count: similar.length,
443
+ })
444
+ );
445
+ } catch (error) {
446
+ console.error(`[GraphAPI] Similar error: ${error.message}`);
447
+ res.writeHead(500);
448
+ res.end(
449
+ JSON.stringify({
450
+ error: true,
451
+ code: 'SEARCH_FAILED',
452
+ message: error.message,
453
+ })
454
+ );
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Create route handler for graph API
460
+ *
461
+ * Returns a function that handles /graph and /viewer requests within the existing
462
+ * embedding-http-server request handler.
463
+ *
464
+ * @returns {Function} Route handler function
465
+ */
466
+ function createGraphHandler() {
467
+ return async function graphHandler(req, res) {
468
+ // Parse URL
469
+ const url = new URL(req.url, `http://${req.headers.host}`);
470
+ const pathname = url.pathname;
471
+ const params = url.searchParams;
472
+
473
+ // Route: GET /viewer - serve HTML viewer
474
+ if (pathname === '/viewer' && req.method === 'GET') {
475
+ handleViewerRequest(req, res);
476
+ return true; // Request handled
477
+ }
478
+
479
+ // Route: GET /viewer.css - serve stylesheet
480
+ if (pathname === '/viewer.css' && req.method === 'GET') {
481
+ handleCssRequest(req, res);
482
+ return true; // Request handled
483
+ }
484
+
485
+ // Route: GET /viewer.js - serve JavaScript
486
+ if (pathname === '/viewer.js' && req.method === 'GET') {
487
+ handleJsRequest(req, res);
488
+ return true; // Request handled
489
+ }
490
+
491
+ // Route: GET /graph - API endpoint
492
+ if (pathname === '/graph' && req.method === 'GET') {
493
+ await handleGraphRequest(req, res, params);
494
+ return true; // Request handled
495
+ }
496
+
497
+ // Route: POST /graph/update - update decision outcome (Story 3.3)
498
+ if (pathname === '/graph/update' && req.method === 'POST') {
499
+ await handleUpdateRequest(req, res);
500
+ return true; // Request handled
501
+ }
502
+
503
+ // Route: GET /graph/similar - find similar decisions
504
+ if (pathname === '/graph/similar' && req.method === 'GET') {
505
+ await handleSimilarRequest(req, res, params);
506
+ return true; // Request handled
507
+ }
508
+
509
+ return false; // Request not handled
510
+ };
511
+ }
512
+
513
+ module.exports = {
514
+ createGraphHandler,
515
+ // Exported for testing
516
+ getAllNodes,
517
+ getAllEdges,
518
+ getUniqueTopics,
519
+ filterNodesByTopic,
520
+ filterEdgesByNodes,
521
+ VIEWER_HTML_PATH,
522
+ VIEWER_CSS_PATH,
523
+ VIEWER_JS_PATH,
524
+ };