@jungjaehoon/mama-os 0.1.1 → 0.1.4

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.
Files changed (2) hide show
  1. package/dist/api/graph-api.js +1675 -0
  2. package/package.json +2 -2
@@ -0,0 +1,1675 @@
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 os = require('os');
18
+ const yaml = require('js-yaml');
19
+ const { getAdapter, initDB, vectorSearch } = require('@jungjaehoon/mama-core/memory-store');
20
+ const { generateEmbedding } = require('@jungjaehoon/mama-core/embeddings');
21
+ const mama = require('@jungjaehoon/mama-core/mama-api');
22
+
23
+ // Config paths
24
+ const MAMA_CONFIG_PATH = path.join(os.homedir(), '.mama', 'config.yaml');
25
+
26
+ // Paths to viewer files (now in public/viewer/)
27
+ const VIEWER_DIR = path.join(__dirname, '../../public/viewer');
28
+ const VIEWER_HTML_PATH = path.join(VIEWER_DIR, 'viewer.html');
29
+ const VIEWER_CSS_PATH = path.join(VIEWER_DIR, 'viewer.css');
30
+ const VIEWER_JS_PATH = path.join(VIEWER_DIR, 'viewer.js');
31
+ const SW_JS_PATH = path.join(VIEWER_DIR, 'sw.js');
32
+ const MANIFEST_JSON_PATH = path.join(VIEWER_DIR, 'manifest.json');
33
+
34
+ /**
35
+ * Get all decisions as graph nodes
36
+ *
37
+ * @returns {Promise<Array>} Array of node objects
38
+ */
39
+ async function getAllNodes() {
40
+ const adapter = getAdapter();
41
+
42
+ const stmt = adapter.prepare(`
43
+ SELECT
44
+ id,
45
+ topic,
46
+ decision,
47
+ reasoning,
48
+ outcome,
49
+ confidence,
50
+ created_at
51
+ FROM decisions
52
+ ORDER BY created_at DESC
53
+ `);
54
+
55
+ const rows = stmt.all();
56
+
57
+ return rows.map((row) => ({
58
+ id: row.id,
59
+ topic: row.topic,
60
+ decision: row.decision,
61
+ reasoning: row.reasoning,
62
+ outcome: row.outcome,
63
+ confidence: row.confidence,
64
+ created_at: row.created_at,
65
+ }));
66
+ }
67
+
68
+ /**
69
+ * Get all decision edges
70
+ *
71
+ * @returns {Promise<Array>} Array of edge objects
72
+ */
73
+ async function getAllEdges() {
74
+ const adapter = getAdapter();
75
+
76
+ const stmt = adapter.prepare(`
77
+ SELECT
78
+ from_id,
79
+ to_id,
80
+ relationship,
81
+ reason
82
+ FROM decision_edges
83
+ ORDER BY created_at DESC
84
+ `);
85
+
86
+ const rows = stmt.all();
87
+
88
+ return rows.map((row) => ({
89
+ from: row.from_id,
90
+ to: row.to_id,
91
+ relationship: row.relationship,
92
+ reason: row.reason,
93
+ }));
94
+ }
95
+
96
+ /**
97
+ * Get all checkpoints
98
+ *
99
+ * @returns {Promise<Array>} Array of checkpoint objects
100
+ */
101
+ async function getAllCheckpoints() {
102
+ const adapter = getAdapter();
103
+
104
+ const stmt = adapter.prepare(`
105
+ SELECT
106
+ id,
107
+ timestamp,
108
+ summary,
109
+ open_files,
110
+ next_steps,
111
+ status
112
+ FROM checkpoints
113
+ ORDER BY timestamp DESC
114
+ LIMIT 50
115
+ `);
116
+
117
+ const rows = stmt.all();
118
+
119
+ return rows.map((row) => ({
120
+ id: row.id,
121
+ timestamp: row.timestamp,
122
+ summary: row.summary,
123
+ open_files: row.open_files ? JSON.parse(row.open_files) : [],
124
+ next_steps: row.next_steps,
125
+ status: row.status,
126
+ }));
127
+ }
128
+
129
+ /**
130
+ * Get unique topics from nodes
131
+ *
132
+ * @param {Array} nodes - Array of node objects
133
+ * @returns {Array<string>} Unique topics
134
+ */
135
+ function getUniqueTopics(nodes) {
136
+ const topicSet = new Set(nodes.map((n) => n.topic));
137
+ return Array.from(topicSet).sort();
138
+ }
139
+
140
+ /**
141
+ * Filter nodes by topic
142
+ *
143
+ * @param {Array} nodes - Array of node objects
144
+ * @param {string} topic - Topic to filter by
145
+ * @returns {Array} Filtered nodes
146
+ */
147
+ function filterNodesByTopic(nodes, topic) {
148
+ return nodes.filter((n) => n.topic === topic);
149
+ }
150
+
151
+ /**
152
+ * Filter edges to only include those connected to given nodes
153
+ *
154
+ * @param {Array} edges - Array of edge objects
155
+ * @param {Array} nodes - Array of node objects (filtered)
156
+ * @returns {Array} Filtered edges
157
+ */
158
+ function filterEdgesByNodes(edges, nodes) {
159
+ const nodeIds = new Set(nodes.map((n) => n.id));
160
+ return edges.filter((e) => nodeIds.has(e.from) || nodeIds.has(e.to));
161
+ }
162
+
163
+ /**
164
+ * Serve static file with appropriate content type
165
+ *
166
+ * @param {Object} res - HTTP response
167
+ * @param {string} filePath - Path to file
168
+ * @param {string} contentType - MIME type
169
+ */
170
+ function serveStaticFile(res, filePath, contentType) {
171
+ try {
172
+ const content = fs.readFileSync(filePath, 'utf8');
173
+ const etag = `"${Date.now()}"`; // Force browser to reload
174
+ res.writeHead(200, {
175
+ 'Content-Type': `${contentType}; charset=utf-8`,
176
+ 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
177
+ Pragma: 'no-cache',
178
+ Expires: '0',
179
+ ETag: etag,
180
+ });
181
+ res.end(content);
182
+ } catch (error) {
183
+ console.error(`[GraphAPI] Static file error: ${error.message}`);
184
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
185
+ res.end('Error loading file: ' + error.message);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Handle GET /viewer request - serve HTML viewer
191
+ *
192
+ * @param {Object} req - HTTP request
193
+ * @param {Object} res - HTTP response
194
+ */
195
+ function handleViewerRequest(req, res) {
196
+ serveStaticFile(res, VIEWER_HTML_PATH, 'text/html');
197
+ }
198
+
199
+ /**
200
+ * Handle GET /viewer.css request
201
+ *
202
+ * @param {Object} req - HTTP request
203
+ * @param {Object} res - HTTP response
204
+ */
205
+ function handleCssRequest(req, res) {
206
+ serveStaticFile(res, VIEWER_CSS_PATH, 'text/css');
207
+ }
208
+
209
+ /**
210
+ * Handle GET /viewer.js request
211
+ *
212
+ * @param {Object} req - HTTP request
213
+ * @param {Object} res - HTTP response
214
+ */
215
+ function handleJsRequest(req, res) {
216
+ serveStaticFile(res, VIEWER_JS_PATH, 'application/javascript');
217
+ }
218
+
219
+ /**
220
+ * Handle GET /graph request
221
+ *
222
+ * @param {Object} req - HTTP request
223
+ * @param {Object} res - HTTP response
224
+ * @param {URLSearchParams} params - Query parameters
225
+ */
226
+ async function handleGraphRequest(req, res, params) {
227
+ const startTime = Date.now();
228
+
229
+ try {
230
+ // Ensure DB is initialized
231
+ await initDB();
232
+
233
+ // Get all data
234
+ let nodes = await getAllNodes();
235
+ let edges = await getAllEdges();
236
+
237
+ // Apply topic filter if provided
238
+ const topicFilter = params.get('topic');
239
+ if (topicFilter) {
240
+ nodes = filterNodesByTopic(nodes, topicFilter);
241
+ edges = filterEdgesByNodes(edges, nodes);
242
+ }
243
+
244
+ // Add similarity edges for clustering if requested
245
+ const includeCluster = params.get('cluster') === 'true';
246
+ let similarityEdges = [];
247
+ if (includeCluster) {
248
+ similarityEdges = await getSimilarityEdges();
249
+ // Filter to only include edges for visible nodes
250
+ const nodeIds = new Set(nodes.map((n) => n.id));
251
+ similarityEdges = similarityEdges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to));
252
+ }
253
+
254
+ // Build meta object
255
+ const allTopics = topicFilter ? [topicFilter] : getUniqueTopics(nodes);
256
+ const meta = {
257
+ total_nodes: nodes.length,
258
+ total_edges: edges.length,
259
+ similarity_edges: similarityEdges.length,
260
+ topics: allTopics,
261
+ };
262
+
263
+ const latency = Date.now() - startTime;
264
+
265
+ // Send response
266
+ res.writeHead(200);
267
+ res.end(
268
+ JSON.stringify({
269
+ nodes,
270
+ edges,
271
+ similarityEdges,
272
+ meta,
273
+ latency,
274
+ })
275
+ );
276
+ } catch (error) {
277
+ console.error(`[GraphAPI] Error: ${error.message}`);
278
+ res.writeHead(500);
279
+ res.end(
280
+ JSON.stringify({
281
+ error: true,
282
+ code: 'INTERNAL_ERROR',
283
+ message: error.message,
284
+ })
285
+ );
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Read request body as JSON
291
+ */
292
+ function readBody(req) {
293
+ return new Promise((resolve, reject) => {
294
+ let data = '';
295
+ req.on('data', (chunk) => (data += chunk));
296
+ req.on('end', () => {
297
+ try {
298
+ resolve(JSON.parse(data));
299
+ } catch (e) {
300
+ reject(new Error('Invalid JSON'));
301
+ }
302
+ });
303
+ req.on('error', reject);
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Handle POST /graph/update request - update decision outcome
309
+ *
310
+ * @param {Object} req - HTTP request
311
+ * @param {Object} res - HTTP response
312
+ */
313
+ async function handleUpdateRequest(req, res) {
314
+ try {
315
+ const body = await readBody(req);
316
+
317
+ if (!body.id || !body.outcome) {
318
+ res.writeHead(400);
319
+ res.end(
320
+ JSON.stringify({
321
+ error: true,
322
+ code: 'INVALID_REQUEST',
323
+ message: 'Missing required fields: id, outcome',
324
+ })
325
+ );
326
+ return;
327
+ }
328
+
329
+ // Ensure DB is initialized
330
+ await initDB();
331
+
332
+ // Update outcome using mama-api
333
+ await mama.updateOutcome(body.id, {
334
+ outcome: body.outcome,
335
+ failure_reason: body.reason,
336
+ });
337
+
338
+ res.writeHead(200);
339
+ res.end(
340
+ JSON.stringify({
341
+ success: true,
342
+ id: body.id,
343
+ outcome: body.outcome.toUpperCase(),
344
+ })
345
+ );
346
+ } catch (error) {
347
+ console.error(`[GraphAPI] Update error: ${error.message}`);
348
+ res.writeHead(500);
349
+ res.end(
350
+ JSON.stringify({
351
+ error: true,
352
+ code: 'UPDATE_FAILED',
353
+ message: error.message,
354
+ })
355
+ );
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Get similarity edges for layout clustering
361
+ * Returns edges between highly similar decisions (threshold > 0.7)
362
+ *
363
+ * @returns {Promise<Array>} Array of similarity edge objects
364
+ */
365
+ async function getSimilarityEdges() {
366
+ const adapter = getAdapter();
367
+
368
+ // Get all decisions (embeddings stored in vss_memories table)
369
+ const stmt = adapter.prepare(`
370
+ SELECT id, topic, decision FROM decisions
371
+ ORDER BY created_at DESC
372
+ LIMIT 100
373
+ `);
374
+ const decisions = stmt.all(); // better-sqlite3 is synchronous
375
+
376
+ if (decisions.length < 2) {
377
+ return [];
378
+ }
379
+
380
+ const similarityEdges = [];
381
+ const similarityEdgeKeys = new Set(); // O(1) duplicate checking
382
+
383
+ // For each decision, find its most similar peers
384
+ for (const decision of decisions.slice(0, 50)) {
385
+ // Limit to first 50 for performance
386
+ try {
387
+ const query = `${decision.topic} ${decision.decision}`;
388
+ const embedding = await generateEmbedding(query);
389
+ const similar = await vectorSearch(embedding, 3, 0.7); // Top 3 with >70% similarity
390
+
391
+ for (const s of similar) {
392
+ if (s.id !== decision.id && s.similarity > 0.7) {
393
+ // Avoid duplicates (A->B and B->A) using Set for O(1) lookup
394
+ const edgeKey = [decision.id, s.id].sort().join('|');
395
+ if (!similarityEdgeKeys.has(edgeKey)) {
396
+ similarityEdges.push({
397
+ from: decision.id,
398
+ to: s.id,
399
+ relationship: 'similar',
400
+ similarity: s.similarity,
401
+ });
402
+ similarityEdgeKeys.add(edgeKey);
403
+ }
404
+ }
405
+ }
406
+ } catch (e) {
407
+ console.error(`[GraphAPI] Similarity search error for ${decision.id}:`, e.message);
408
+ }
409
+ }
410
+
411
+ return similarityEdges;
412
+ }
413
+
414
+ /**
415
+ * Handle GET /graph/similar request - find similar decisions
416
+ *
417
+ * @param {Object} req - HTTP request
418
+ * @param {Object} res - HTTP response
419
+ * @param {URLSearchParams} params - Query parameters (id required)
420
+ */
421
+ async function handleSimilarRequest(req, res, params) {
422
+ const startTime = Date.now();
423
+ try {
424
+ const decisionId = params.get('id');
425
+ console.log(`[GraphAPI] Similar request for decision: ${decisionId}`);
426
+
427
+ if (!decisionId) {
428
+ res.writeHead(400);
429
+ res.end(
430
+ JSON.stringify({
431
+ error: true,
432
+ code: 'MISSING_ID',
433
+ message: 'Missing required parameter: id',
434
+ })
435
+ );
436
+ return;
437
+ }
438
+
439
+ // Ensure DB is initialized
440
+ console.log(`[GraphAPI] Initializing DB...`);
441
+ await initDB();
442
+
443
+ // Get the decision by ID
444
+ console.log(`[GraphAPI] Fetching decision ${decisionId}...`);
445
+ const adapter = getAdapter();
446
+ const stmt = adapter.prepare(`
447
+ SELECT topic, decision, reasoning FROM decisions WHERE id = ?
448
+ `);
449
+ const decision = stmt.get(decisionId);
450
+
451
+ if (!decision) {
452
+ console.log(`[GraphAPI] Decision ${decisionId} not found`);
453
+ res.writeHead(404);
454
+ res.end(
455
+ JSON.stringify({
456
+ error: true,
457
+ code: 'NOT_FOUND',
458
+ message: 'Decision not found',
459
+ })
460
+ );
461
+ return;
462
+ }
463
+
464
+ // Build search query from decision content
465
+ const searchQuery = `${decision.topic} ${decision.decision}`;
466
+ console.log(
467
+ `[GraphAPI] Searching for similar decisions with query: "${searchQuery.substring(0, 50)}..."`
468
+ );
469
+
470
+ // Use mama.suggest for semantic search
471
+ const searchStart = Date.now();
472
+ const results = await mama.suggest(searchQuery, {
473
+ limit: 6, // Get 6 to filter out self
474
+ threshold: 0.5,
475
+ });
476
+ console.log(`[GraphAPI] Semantic search completed in ${Date.now() - searchStart}ms`);
477
+
478
+ // Filter out the current decision and format results
479
+ let similar = [];
480
+ if (results && results.results) {
481
+ similar = results.results
482
+ .filter((r) => r.id !== decisionId)
483
+ .slice(0, 5)
484
+ .map((r) => ({
485
+ id: r.id,
486
+ topic: r.topic,
487
+ decision: r.decision,
488
+ similarity: r.similarity || r.final_score || 0.5,
489
+ outcome: r.outcome,
490
+ }));
491
+ }
492
+
493
+ console.log(
494
+ `[GraphAPI] Found ${similar.length} similar decisions (total time: ${Date.now() - startTime}ms)`
495
+ );
496
+
497
+ res.writeHead(200, {
498
+ 'Content-Type': 'application/json',
499
+ 'Access-Control-Allow-Origin': '*',
500
+ });
501
+ res.end(
502
+ JSON.stringify({
503
+ id: decisionId,
504
+ similar,
505
+ count: similar.length,
506
+ })
507
+ );
508
+ console.log(`[GraphAPI] Response sent for ${decisionId}`);
509
+ } catch (error) {
510
+ console.error(`[GraphAPI] Similar error: ${error.message}`);
511
+ console.error(`[GraphAPI] Similar error stack:`, error.stack);
512
+ res.writeHead(500, { 'Content-Type': 'application/json' });
513
+ res.end(
514
+ JSON.stringify({
515
+ error: true,
516
+ code: 'SEARCH_FAILED',
517
+ message: error.message,
518
+ })
519
+ );
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Handle GET /api/mama/search request - semantic search for decisions
525
+ * Story 4-1: Memory tab search for mobile chat
526
+ *
527
+ * @param {Object} req - HTTP request
528
+ * @param {Object} res - HTTP response
529
+ * @param {URLSearchParams} params - Query parameters (q required, limit optional)
530
+ */
531
+ async function handleMamaSearchRequest(req, res, params) {
532
+ try {
533
+ const query = params.get('q');
534
+ const limit = Math.min(parseInt(params.get('limit') || '10', 10), 20);
535
+
536
+ if (!query) {
537
+ res.writeHead(400);
538
+ res.end(
539
+ JSON.stringify({
540
+ error: true,
541
+ code: 'MISSING_QUERY',
542
+ message: 'Missing required parameter: q',
543
+ })
544
+ );
545
+ return;
546
+ }
547
+
548
+ // Ensure DB is initialized
549
+ await initDB();
550
+
551
+ // Use mama.suggest for semantic search
552
+ const searchResults = await mama.suggest(query, {
553
+ limit: limit,
554
+ threshold: 0.3, // Lower threshold to show more results
555
+ });
556
+
557
+ // Format results for mobile display
558
+ let results = [];
559
+ if (searchResults && searchResults.results) {
560
+ results = searchResults.results.map((r) => ({
561
+ id: r.id,
562
+ topic: r.topic,
563
+ decision: r.decision,
564
+ reasoning: r.reasoning,
565
+ outcome: r.outcome,
566
+ confidence: r.confidence,
567
+ similarity: r.similarity || r.final_score || 0.5,
568
+ created_at: r.created_at,
569
+ }));
570
+ }
571
+
572
+ res.writeHead(200);
573
+ res.end(
574
+ JSON.stringify({
575
+ query,
576
+ results,
577
+ count: results.length,
578
+ })
579
+ );
580
+ } catch (error) {
581
+ console.error(`[GraphAPI] MAMA search error: ${error.message}`);
582
+ res.writeHead(500);
583
+ res.end(
584
+ JSON.stringify({
585
+ error: true,
586
+ code: 'SEARCH_FAILED',
587
+ message: error.message,
588
+ })
589
+ );
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Handle POST /api/mama/save request - save a new decision
595
+ * Story 4-2: Save decisions from mobile chat UI
596
+ *
597
+ * @param {Object} req - HTTP request
598
+ * @param {Object} res - HTTP response
599
+ */
600
+ async function handleMamaSaveRequest(req, res) {
601
+ try {
602
+ const body = await readBody(req);
603
+
604
+ // Validate required fields
605
+ if (!body.topic || !body.decision || !body.reasoning) {
606
+ res.writeHead(400);
607
+ res.end(
608
+ JSON.stringify({
609
+ error: true,
610
+ code: 'MISSING_FIELDS',
611
+ message: 'Missing required fields: topic, decision, reasoning',
612
+ })
613
+ );
614
+ return;
615
+ }
616
+
617
+ // Ensure DB is initialized
618
+ await initDB();
619
+
620
+ // Save decision using mama.saveDecision
621
+ const result = await mama.saveDecision({
622
+ topic: body.topic,
623
+ decision: body.decision,
624
+ reasoning: body.reasoning,
625
+ confidence: body.confidence || 0.8,
626
+ });
627
+
628
+ res.writeHead(200);
629
+ res.end(
630
+ JSON.stringify({
631
+ success: true,
632
+ id: result.id,
633
+ message: 'Decision saved successfully',
634
+ })
635
+ );
636
+ } catch (error) {
637
+ console.error(`[GraphAPI] MAMA save error: ${error.message}`);
638
+ res.writeHead(500);
639
+ res.end(
640
+ JSON.stringify({
641
+ error: true,
642
+ code: 'SAVE_FAILED',
643
+ message: error.message,
644
+ })
645
+ );
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Handle POST /api/checkpoint/save request - save session checkpoint
651
+ * Story 4-3: Auto checkpoint feature for mobile chat
652
+ *
653
+ * @param {Object} req - HTTP request
654
+ * @param {Object} res - HTTP response
655
+ */
656
+ async function handleCheckpointSaveRequest(req, res) {
657
+ try {
658
+ const body = await readBody(req);
659
+
660
+ // Validate required fields
661
+ if (!body.summary) {
662
+ res.writeHead(400);
663
+ res.end(
664
+ JSON.stringify({
665
+ error: true,
666
+ code: 'MISSING_FIELDS',
667
+ message: 'Missing required field: summary',
668
+ })
669
+ );
670
+ return;
671
+ }
672
+
673
+ // Ensure DB is initialized
674
+ await initDB();
675
+
676
+ // Save checkpoint using mama.saveCheckpoint
677
+ const checkpointId = await mama.saveCheckpoint(
678
+ body.summary,
679
+ body.open_files || [],
680
+ body.next_steps || ''
681
+ );
682
+
683
+ res.writeHead(200);
684
+ res.end(
685
+ JSON.stringify({
686
+ success: true,
687
+ id: checkpointId,
688
+ message: 'Checkpoint saved successfully',
689
+ })
690
+ );
691
+ } catch (error) {
692
+ console.error(`[GraphAPI] Checkpoint save error: ${error.message}`);
693
+ res.writeHead(500);
694
+ res.end(
695
+ JSON.stringify({
696
+ error: true,
697
+ code: 'SAVE_FAILED',
698
+ message: error.message,
699
+ })
700
+ );
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Handle GET /api/checkpoint/load request - load latest checkpoint
706
+ * Story 4-3: Session resume feature for mobile chat
707
+ *
708
+ * @param {Object} req - HTTP request
709
+ * @param {Object} res - HTTP response
710
+ */
711
+ async function handleCheckpointLoadRequest(req, res) {
712
+ try {
713
+ // Ensure DB is initialized
714
+ await initDB();
715
+
716
+ // Load latest checkpoint using mama.loadCheckpoint
717
+ const checkpoint = await mama.loadCheckpoint();
718
+
719
+ if (!checkpoint) {
720
+ res.writeHead(404);
721
+ res.end(
722
+ JSON.stringify({
723
+ error: true,
724
+ code: 'NO_CHECKPOINT',
725
+ message: 'No checkpoint found',
726
+ })
727
+ );
728
+ return;
729
+ }
730
+
731
+ res.writeHead(200);
732
+ res.end(
733
+ JSON.stringify({
734
+ success: true,
735
+ checkpoint,
736
+ })
737
+ );
738
+ } catch (error) {
739
+ console.error(`[GraphAPI] Checkpoint load error: ${error.message}`);
740
+ res.writeHead(500);
741
+ res.end(
742
+ JSON.stringify({
743
+ error: true,
744
+ code: 'LOAD_FAILED',
745
+ message: error.message,
746
+ })
747
+ );
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Handle GET /checkpoints request - list all checkpoints
753
+ *
754
+ * @param {Object} req - HTTP request
755
+ * @param {Object} res - HTTP response
756
+ */
757
+ async function handleCheckpointsRequest(req, res) {
758
+ try {
759
+ // Ensure DB is initialized
760
+ await initDB();
761
+
762
+ const checkpoints = await getAllCheckpoints();
763
+
764
+ res.writeHead(200);
765
+ res.end(
766
+ JSON.stringify({
767
+ checkpoints,
768
+ count: checkpoints.length,
769
+ })
770
+ );
771
+ } catch (error) {
772
+ console.error(`[GraphAPI] Checkpoints error: ${error.message}`);
773
+ res.writeHead(500);
774
+ res.end(
775
+ JSON.stringify({
776
+ error: true,
777
+ code: 'CHECKPOINTS_FAILED',
778
+ message: error.message,
779
+ })
780
+ );
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Create route handler for graph API
786
+ *
787
+ * Returns a function that handles /graph and /viewer requests within the existing
788
+ * embedding-http-server request handler.
789
+ *
790
+ * @returns {Function} Route handler function
791
+ */
792
+ function createGraphHandler() {
793
+ return async function graphHandler(req, res) {
794
+ // Parse URL
795
+ const url = new URL(req.url, `http://${req.headers.host}`);
796
+ const pathname = url.pathname;
797
+ const params = url.searchParams;
798
+
799
+ console.log('[GraphHandler] Request:', req.method, pathname);
800
+
801
+ // Route: GET / - redirect to /viewer
802
+ if (pathname === '/' && req.method === 'GET') {
803
+ console.log('[GraphHandler] Redirecting / to /viewer');
804
+ res.writeHead(302, { Location: '/viewer' });
805
+ res.end();
806
+ return true; // Request handled
807
+ }
808
+
809
+ // Route: GET /viewer - serve HTML viewer
810
+ if (pathname === '/viewer' && req.method === 'GET') {
811
+ console.log('[GraphHandler] Serving viewer.html');
812
+ handleViewerRequest(req, res);
813
+ return true; // Request handled
814
+ }
815
+
816
+ // Route: GET/HEAD /viewer/viewer.css - serve stylesheet
817
+ if (pathname === '/viewer/viewer.css' && (req.method === 'GET' || req.method === 'HEAD')) {
818
+ handleCssRequest(req, res);
819
+ return true; // Request handled
820
+ }
821
+
822
+ // Route: GET/HEAD /viewer.css - serve stylesheet (legacy path)
823
+ if (pathname === '/viewer.css' && (req.method === 'GET' || req.method === 'HEAD')) {
824
+ handleCssRequest(req, res);
825
+ return true; // Request handled
826
+ }
827
+
828
+ // Route: GET/HEAD /viewer.js - serve JavaScript
829
+ if (pathname === '/viewer.js' && (req.method === 'GET' || req.method === 'HEAD')) {
830
+ handleJsRequest(req, res);
831
+ return true; // Request handled
832
+ }
833
+
834
+ // Route: GET/HEAD /sw.js - serve Service Worker
835
+ if (pathname === '/sw.js' && (req.method === 'GET' || req.method === 'HEAD')) {
836
+ serveStaticFile(res, SW_JS_PATH, 'application/javascript');
837
+ return true; // Request handled
838
+ }
839
+
840
+ // Route: GET/HEAD /viewer/sw.js - serve Service Worker (alternative path)
841
+ if (pathname === '/viewer/sw.js' && (req.method === 'GET' || req.method === 'HEAD')) {
842
+ serveStaticFile(res, SW_JS_PATH, 'application/javascript');
843
+ return true; // Request handled
844
+ }
845
+
846
+ // Route: GET/HEAD /viewer/manifest.json - serve PWA manifest
847
+ if (pathname === '/viewer/manifest.json' && (req.method === 'GET' || req.method === 'HEAD')) {
848
+ serveStaticFile(res, MANIFEST_JSON_PATH, 'application/json');
849
+ return true; // Request handled
850
+ }
851
+
852
+ // Route: GET/HEAD /viewer/icons/*.png - serve PWA icons
853
+ if (
854
+ pathname.startsWith('/viewer/icons/') &&
855
+ pathname.endsWith('.png') &&
856
+ (req.method === 'GET' || req.method === 'HEAD')
857
+ ) {
858
+ const fileName = pathname.split('/').pop();
859
+ const filePath = path.join(__dirname, '../../public/viewer/icons', fileName);
860
+ serveStaticFile(res, filePath, 'image/png');
861
+ return true; // Request handled
862
+ }
863
+
864
+ // Route: GET/HEAD /viewer/js/utils/*.js - serve utility modules
865
+ if (
866
+ pathname.startsWith('/viewer/js/utils/') &&
867
+ pathname.endsWith('.js') &&
868
+ (req.method === 'GET' || req.method === 'HEAD')
869
+ ) {
870
+ const fileName = pathname.split('/').pop();
871
+ const filePath = path.join(VIEWER_DIR, 'js', 'utils', fileName);
872
+ serveStaticFile(res, filePath, 'application/javascript');
873
+ return true; // Request handled
874
+ }
875
+
876
+ // Route: GET/HEAD /viewer/js/modules/*.js - serve feature modules
877
+ if (
878
+ pathname.startsWith('/viewer/js/modules/') &&
879
+ pathname.endsWith('.js') &&
880
+ (req.method === 'GET' || req.method === 'HEAD')
881
+ ) {
882
+ const fileName = pathname.split('/').pop();
883
+ const filePath = path.join(VIEWER_DIR, 'js', 'modules', fileName);
884
+ serveStaticFile(res, filePath, 'application/javascript');
885
+ return true; // Request handled
886
+ }
887
+
888
+ // Route: GET/HEAD /js/utils/*.js - serve utility modules (legacy path)
889
+ if (
890
+ pathname.startsWith('/js/utils/') &&
891
+ pathname.endsWith('.js') &&
892
+ (req.method === 'GET' || req.method === 'HEAD')
893
+ ) {
894
+ const fileName = pathname.split('/').pop();
895
+ const filePath = path.join(VIEWER_DIR, 'js', 'utils', fileName);
896
+ serveStaticFile(res, filePath, 'application/javascript');
897
+ return true; // Request handled
898
+ }
899
+
900
+ // Route: GET/HEAD /js/modules/*.js - serve feature modules (legacy path)
901
+ if (
902
+ pathname.startsWith('/js/modules/') &&
903
+ pathname.endsWith('.js') &&
904
+ (req.method === 'GET' || req.method === 'HEAD')
905
+ ) {
906
+ const fileName = pathname.split('/').pop();
907
+ const filePath = path.join(VIEWER_DIR, 'js', 'modules', fileName);
908
+ serveStaticFile(res, filePath, 'application/javascript');
909
+ return true; // Request handled
910
+ }
911
+
912
+ // Route: GET /viewer.css - serve stylesheet (legacy path)
913
+ if (pathname === '/viewer.css' && req.method === 'GET') {
914
+ handleCssRequest(req, res);
915
+ return true; // Request handled
916
+ }
917
+
918
+ // Route: GET /viewer.js - serve JavaScript
919
+ if (pathname === '/viewer.js' && req.method === 'GET') {
920
+ handleJsRequest(req, res);
921
+ return true; // Request handled
922
+ }
923
+
924
+ // Route: GET /sw.js - serve Service Worker
925
+ if (pathname === '/sw.js' && req.method === 'GET') {
926
+ serveStaticFile(res, SW_JS_PATH, 'application/javascript');
927
+ return true; // Request handled
928
+ }
929
+
930
+ // Route: GET /viewer/sw.js - serve Service Worker (alternative path)
931
+ if (pathname === '/viewer/sw.js' && req.method === 'GET') {
932
+ serveStaticFile(res, SW_JS_PATH, 'application/javascript');
933
+ return true; // Request handled
934
+ }
935
+
936
+ // Route: GET /viewer/manifest.json - serve PWA manifest
937
+ if (pathname === '/viewer/manifest.json' && req.method === 'GET') {
938
+ serveStaticFile(res, MANIFEST_JSON_PATH, 'application/json');
939
+ return true; // Request handled
940
+ }
941
+
942
+ // Route: GET /viewer/icons/*.png - serve PWA icons
943
+ if (
944
+ pathname.startsWith('/viewer/icons/') &&
945
+ pathname.endsWith('.png') &&
946
+ req.method === 'GET'
947
+ ) {
948
+ const fileName = pathname.split('/').pop();
949
+ const filePath = path.join(__dirname, '../../public/viewer/icons', fileName);
950
+ serveStaticFile(res, filePath, 'image/png');
951
+ return true; // Request handled
952
+ }
953
+
954
+ // Route: GET /viewer/js/utils/*.js - serve utility modules
955
+ if (
956
+ pathname.startsWith('/viewer/js/utils/') &&
957
+ pathname.endsWith('.js') &&
958
+ req.method === 'GET'
959
+ ) {
960
+ const fileName = pathname.split('/').pop();
961
+ const filePath = path.join(VIEWER_DIR, 'js', 'utils', fileName);
962
+ serveStaticFile(res, filePath, 'application/javascript');
963
+ return true; // Request handled
964
+ }
965
+
966
+ // Route: GET /viewer/js/modules/*.js - serve feature modules
967
+ if (
968
+ pathname.startsWith('/viewer/js/modules/') &&
969
+ pathname.endsWith('.js') &&
970
+ req.method === 'GET'
971
+ ) {
972
+ const fileName = pathname.split('/').pop();
973
+ const filePath = path.join(VIEWER_DIR, 'js', 'modules', fileName);
974
+ serveStaticFile(res, filePath, 'application/javascript');
975
+ return true; // Request handled
976
+ }
977
+
978
+ // Route: GET /js/utils/*.js - serve utility modules (legacy path)
979
+ if (pathname.startsWith('/js/utils/') && pathname.endsWith('.js') && req.method === 'GET') {
980
+ const fileName = pathname.split('/').pop();
981
+ const filePath = path.join(VIEWER_DIR, 'js', 'utils', fileName);
982
+ serveStaticFile(res, filePath, 'application/javascript');
983
+ return true; // Request handled
984
+ }
985
+
986
+ // Route: GET /js/modules/*.js - serve feature modules (legacy path)
987
+ if (pathname.startsWith('/js/modules/') && pathname.endsWith('.js') && req.method === 'GET') {
988
+ const fileName = pathname.split('/').pop();
989
+ const filePath = path.join(VIEWER_DIR, 'js', 'modules', fileName);
990
+ serveStaticFile(res, filePath, 'application/javascript');
991
+ return true; // Request handled
992
+ }
993
+
994
+ // Route: GET /graph/similar - find similar decisions (check before /graph)
995
+ if (pathname === '/graph/similar' && req.method === 'GET') {
996
+ console.log('[GraphHandler] Routing to handleSimilarRequest');
997
+ await handleSimilarRequest(req, res, params);
998
+ return true; // Request handled
999
+ }
1000
+
1001
+ // Route: POST /graph/update - update decision outcome (Story 3.3)
1002
+ if (pathname === '/graph/update' && req.method === 'POST') {
1003
+ await handleUpdateRequest(req, res);
1004
+ return true; // Request handled
1005
+ }
1006
+
1007
+ // Route: GET /graph - API endpoint
1008
+ if (pathname === '/graph' && req.method === 'GET') {
1009
+ await handleGraphRequest(req, res, params);
1010
+ return true;
1011
+ }
1012
+
1013
+ // Alias: GET /api/graph → /graph
1014
+ if (pathname === '/api/graph' && req.method === 'GET') {
1015
+ await handleGraphRequest(req, res, params);
1016
+ return true;
1017
+ }
1018
+
1019
+ // Route: GET /checkpoints - list all checkpoints
1020
+ if (pathname === '/checkpoints' && req.method === 'GET') {
1021
+ await handleCheckpointsRequest(req, res);
1022
+ return true;
1023
+ }
1024
+
1025
+ // Alias: GET /api/checkpoints → /checkpoints
1026
+ if (pathname === '/api/checkpoints' && req.method === 'GET') {
1027
+ await handleCheckpointsRequest(req, res);
1028
+ return true;
1029
+ }
1030
+
1031
+ // Route: GET /api/mama/search - semantic search for decisions (Story 4-1)
1032
+ if (pathname === '/api/mama/search' && req.method === 'GET') {
1033
+ await handleMamaSearchRequest(req, res, params);
1034
+ return true;
1035
+ }
1036
+
1037
+ // Alias: GET /api/search → /api/mama/search (with query param conversion)
1038
+ if (pathname === '/api/search' && req.method === 'GET') {
1039
+ const query = params.get('query');
1040
+ if (query) {
1041
+ params.set('q', query);
1042
+ }
1043
+ await handleMamaSearchRequest(req, res, params);
1044
+ return true;
1045
+ }
1046
+
1047
+ // Route: POST /api/mama/save - save a new decision (Story 4-2)
1048
+ if (pathname === '/api/mama/save' && req.method === 'POST') {
1049
+ await handleMamaSaveRequest(req, res);
1050
+ return true;
1051
+ }
1052
+
1053
+ // Alias: POST /api/save → /api/mama/save
1054
+ if (pathname === '/api/save' && req.method === 'POST') {
1055
+ await handleMamaSaveRequest(req, res);
1056
+ return true;
1057
+ }
1058
+
1059
+ // Alias: POST /api/update → /graph/update
1060
+ if (pathname === '/api/update' && req.method === 'POST') {
1061
+ await handleUpdateRequest(req, res);
1062
+ return true;
1063
+ }
1064
+
1065
+ // Route: POST /api/checkpoint/save - save session checkpoint (Story 4-3)
1066
+ if (pathname === '/api/checkpoint/save' && req.method === 'POST') {
1067
+ await handleCheckpointSaveRequest(req, res);
1068
+ return true;
1069
+ }
1070
+
1071
+ // Route: GET /api/checkpoint/load - load latest checkpoint (Story 4-3)
1072
+ if (pathname === '/api/checkpoint/load' && req.method === 'GET') {
1073
+ await handleCheckpointLoadRequest(req, res);
1074
+ return true;
1075
+ }
1076
+
1077
+ // Route: GET /api/health - health check
1078
+ if (pathname === '/api/health' && req.method === 'GET') {
1079
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1080
+ res.end(JSON.stringify({ status: 'ok', service: 'MAMA Graph API' }));
1081
+ return true;
1082
+ }
1083
+
1084
+ // Route: GET /api/dashboard/status - dashboard status (Phase 4)
1085
+ if (pathname === '/api/dashboard/status' && req.method === 'GET') {
1086
+ await handleDashboardStatusRequest(req, res);
1087
+ return true;
1088
+ }
1089
+
1090
+ // Route: GET /api/config - get current config (Phase 5)
1091
+ if (pathname === '/api/config' && req.method === 'GET') {
1092
+ await handleGetConfigRequest(req, res);
1093
+ return true;
1094
+ }
1095
+
1096
+ // Route: PUT /api/config - update config (Phase 5)
1097
+ if (pathname === '/api/config' && req.method === 'PUT') {
1098
+ await handleUpdateConfigRequest(req, res);
1099
+ return true;
1100
+ }
1101
+
1102
+ // Route: GET /api/memory/export - export decisions (Phase 6)
1103
+ if (pathname === '/api/memory/export' && req.method === 'GET') {
1104
+ await handleExportRequest(req, res, params);
1105
+ return true;
1106
+ }
1107
+
1108
+ return false; // Request not handled
1109
+ };
1110
+ }
1111
+
1112
+ /**
1113
+ * Handle GET /api/dashboard/status - get system status for dashboard
1114
+ * Phase 4: Simplified config-based status (no process monitoring)
1115
+ *
1116
+ * @param {Object} req - HTTP request
1117
+ * @param {Object} res - HTTP response
1118
+ */
1119
+ async function handleDashboardStatusRequest(req, res) {
1120
+ try {
1121
+ // Load config
1122
+ const config = loadMAMAConfig();
1123
+
1124
+ // Get memory stats from database
1125
+ await initDB();
1126
+ const memoryStats = await getMemoryStats();
1127
+
1128
+ // Build gateway status from config (simplified: config exists = configured)
1129
+ const gateways = {
1130
+ discord: {
1131
+ configured: !!config.discord?.token,
1132
+ enabled: config.discord?.enabled ?? false,
1133
+ },
1134
+ slack: {
1135
+ configured: !!config.slack?.bot_token,
1136
+ enabled: config.slack?.enabled ?? false,
1137
+ },
1138
+ telegram: {
1139
+ configured: !!config.telegram?.token,
1140
+ enabled: config.telegram?.enabled ?? false,
1141
+ },
1142
+ chatwork: {
1143
+ configured: !!config.chatwork?.api_token,
1144
+ enabled: config.chatwork?.enabled ?? false,
1145
+ },
1146
+ };
1147
+
1148
+ // Heartbeat status
1149
+ const heartbeat = {
1150
+ enabled: config.heartbeat?.enabled ?? false,
1151
+ interval: config.heartbeat?.interval ?? 1800000,
1152
+ quietStart: config.heartbeat?.quiet_start ?? 23,
1153
+ quietEnd: config.heartbeat?.quiet_end ?? 8,
1154
+ };
1155
+
1156
+ // Agent config
1157
+ const agent = {
1158
+ model: config.agent?.model ?? 'claude-sonnet-4-20250514',
1159
+ maxTurns: config.agent?.max_turns ?? 10,
1160
+ timeout: config.agent?.timeout ?? 300000,
1161
+ };
1162
+
1163
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1164
+ res.end(
1165
+ JSON.stringify({
1166
+ timestamp: new Date().toISOString(),
1167
+ gateways,
1168
+ heartbeat,
1169
+ agent,
1170
+ memory: memoryStats,
1171
+ database: {
1172
+ path: config.database?.path ?? '~/.claude/mama-memory.db',
1173
+ },
1174
+ })
1175
+ );
1176
+ } catch (error) {
1177
+ console.error(`[GraphAPI] Dashboard status error: ${error.message}`);
1178
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1179
+ res.end(
1180
+ JSON.stringify({
1181
+ error: true,
1182
+ code: 'DASHBOARD_ERROR',
1183
+ message: error.message,
1184
+ })
1185
+ );
1186
+ }
1187
+ }
1188
+
1189
+ /**
1190
+ * Load MAMA config from ~/.mama/config.yaml
1191
+ * Returns empty object if file doesn't exist
1192
+ */
1193
+ function loadMAMAConfig() {
1194
+ try {
1195
+ if (!fs.existsSync(MAMA_CONFIG_PATH)) {
1196
+ console.log('[GraphAPI] Config file not found:', MAMA_CONFIG_PATH);
1197
+ return {};
1198
+ }
1199
+ const content = fs.readFileSync(MAMA_CONFIG_PATH, 'utf8');
1200
+ return yaml.load(content) || {};
1201
+ } catch (error) {
1202
+ console.error('[GraphAPI] Config load error:', error.message);
1203
+ return {};
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * Get memory statistics from database
1209
+ */
1210
+ async function getMemoryStats() {
1211
+ try {
1212
+ const adapter = getAdapter();
1213
+
1214
+ const now = Date.now();
1215
+ const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
1216
+ const monthAgo = now - 30 * 24 * 60 * 60 * 1000;
1217
+
1218
+ // Total decisions
1219
+ const totalResult = adapter.prepare('SELECT COUNT(*) as count FROM decisions').get();
1220
+ const total = totalResult?.count ?? 0;
1221
+
1222
+ // This week
1223
+ const weekResult = adapter
1224
+ .prepare('SELECT COUNT(*) as count FROM decisions WHERE created_at > ?')
1225
+ .get(weekAgo);
1226
+ const thisWeek = weekResult?.count ?? 0;
1227
+
1228
+ // This month
1229
+ const monthResult = adapter
1230
+ .prepare('SELECT COUNT(*) as count FROM decisions WHERE created_at > ?')
1231
+ .get(monthAgo);
1232
+ const thisMonth = monthResult?.count ?? 0;
1233
+
1234
+ // Outcomes
1235
+ const outcomeResults = adapter
1236
+ .prepare(
1237
+ `
1238
+ SELECT outcome, COUNT(*) as count
1239
+ FROM decisions
1240
+ WHERE outcome IS NOT NULL
1241
+ GROUP BY outcome
1242
+ `
1243
+ )
1244
+ .all();
1245
+
1246
+ const outcomes = {};
1247
+ for (const row of outcomeResults) {
1248
+ outcomes[row.outcome?.toLowerCase() ?? 'unknown'] = row.count;
1249
+ }
1250
+
1251
+ // Top topics
1252
+ const topicResults = adapter
1253
+ .prepare(
1254
+ `
1255
+ SELECT topic, COUNT(*) as count
1256
+ FROM decisions
1257
+ WHERE topic IS NOT NULL
1258
+ GROUP BY topic
1259
+ ORDER BY count DESC
1260
+ LIMIT 5
1261
+ `
1262
+ )
1263
+ .all();
1264
+
1265
+ // Total checkpoints
1266
+ const checkpointResult = adapter.prepare('SELECT COUNT(*) as count FROM checkpoints').get();
1267
+ const checkpoints = checkpointResult?.count ?? 0;
1268
+
1269
+ return {
1270
+ total,
1271
+ thisWeek,
1272
+ thisMonth,
1273
+ checkpoints,
1274
+ outcomes,
1275
+ topTopics: topicResults,
1276
+ };
1277
+ } catch (error) {
1278
+ console.error('[GraphAPI] Memory stats error:', error.message);
1279
+ return {
1280
+ total: 0,
1281
+ thisWeek: 0,
1282
+ thisMonth: 0,
1283
+ checkpoints: 0,
1284
+ outcomes: {},
1285
+ topTopics: [],
1286
+ };
1287
+ }
1288
+ }
1289
+
1290
+ /**
1291
+ * Handle GET /api/config - get current configuration
1292
+ * Phase 5: Settings Management
1293
+ */
1294
+ async function handleGetConfigRequest(req, res) {
1295
+ try {
1296
+ const config = loadMAMAConfig();
1297
+
1298
+ // Mask sensitive tokens (show only last 4 chars)
1299
+ const maskedConfig = {
1300
+ version: config.version || 1,
1301
+ agent: config.agent || {},
1302
+ database: config.database || {},
1303
+ logging: config.logging || {},
1304
+ discord: config.discord
1305
+ ? {
1306
+ enabled: config.discord.enabled || false,
1307
+ token: config.discord.token ? maskToken(config.discord.token) : '',
1308
+ default_channel_id: config.discord.default_channel_id || '',
1309
+ }
1310
+ : { enabled: false, token: '', default_channel_id: '' },
1311
+ slack: config.slack
1312
+ ? {
1313
+ enabled: config.slack.enabled || false,
1314
+ bot_token: config.slack.bot_token ? maskToken(config.slack.bot_token) : '',
1315
+ app_token: config.slack.app_token ? maskToken(config.slack.app_token) : '',
1316
+ }
1317
+ : { enabled: false, bot_token: '', app_token: '' },
1318
+ telegram: config.telegram
1319
+ ? {
1320
+ enabled: config.telegram.enabled || false,
1321
+ token: config.telegram.token ? maskToken(config.telegram.token) : '',
1322
+ }
1323
+ : { enabled: false, token: '' },
1324
+ chatwork: config.chatwork
1325
+ ? {
1326
+ enabled: config.chatwork.enabled || false,
1327
+ api_token: config.chatwork.api_token ? maskToken(config.chatwork.api_token) : '',
1328
+ }
1329
+ : { enabled: false, api_token: '' },
1330
+ heartbeat: config.heartbeat || {
1331
+ enabled: false,
1332
+ interval: 1800000,
1333
+ quiet_start: 23,
1334
+ quiet_end: 8,
1335
+ },
1336
+ };
1337
+
1338
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1339
+ res.end(JSON.stringify(maskedConfig));
1340
+ } catch (error) {
1341
+ console.error('[GraphAPI] Get config error:', error.message);
1342
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1343
+ res.end(
1344
+ JSON.stringify({
1345
+ error: true,
1346
+ code: 'CONFIG_ERROR',
1347
+ message: error.message,
1348
+ })
1349
+ );
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * Handle PUT /api/config - update configuration
1355
+ * Phase 5: Settings Management
1356
+ */
1357
+ async function handleUpdateConfigRequest(req, res) {
1358
+ try {
1359
+ const body = await readBody(req);
1360
+
1361
+ // Load current config
1362
+ const currentConfig = loadMAMAConfig();
1363
+
1364
+ // Merge updates (preserve existing tokens if masked value sent)
1365
+ const updatedConfig = mergeConfigUpdates(currentConfig, body);
1366
+
1367
+ // Validate
1368
+ const errors = validateConfigUpdate(updatedConfig);
1369
+ if (errors.length > 0) {
1370
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1371
+ res.end(
1372
+ JSON.stringify({
1373
+ error: true,
1374
+ code: 'VALIDATION_ERROR',
1375
+ message: errors.join(', '),
1376
+ })
1377
+ );
1378
+ return;
1379
+ }
1380
+
1381
+ // Save config
1382
+ saveMAMAConfig(updatedConfig);
1383
+
1384
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1385
+ res.end(
1386
+ JSON.stringify({
1387
+ success: true,
1388
+ message: 'Configuration saved successfully',
1389
+ })
1390
+ );
1391
+ } catch (error) {
1392
+ console.error('[GraphAPI] Update config error:', error.message);
1393
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1394
+ res.end(
1395
+ JSON.stringify({
1396
+ error: true,
1397
+ code: 'CONFIG_ERROR',
1398
+ message: error.message,
1399
+ })
1400
+ );
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * Mask a token to show only last 4 characters
1406
+ */
1407
+ function maskToken(token) {
1408
+ if (!token || token.length < 8) {
1409
+ return '****';
1410
+ }
1411
+ return '****' + token.slice(-4);
1412
+ }
1413
+
1414
+ /**
1415
+ * Merge config updates, preserving existing tokens if masked value sent
1416
+ */
1417
+ function mergeConfigUpdates(current, updates) {
1418
+ const merged = { ...current };
1419
+
1420
+ // Agent config
1421
+ if (updates.agent) {
1422
+ merged.agent = {
1423
+ ...current.agent,
1424
+ ...updates.agent,
1425
+ };
1426
+ }
1427
+
1428
+ // Heartbeat config
1429
+ if (updates.heartbeat) {
1430
+ merged.heartbeat = {
1431
+ ...current.heartbeat,
1432
+ ...updates.heartbeat,
1433
+ };
1434
+ }
1435
+
1436
+ // Discord config
1437
+ if (updates.discord) {
1438
+ merged.discord = {
1439
+ ...current.discord,
1440
+ enabled: updates.discord.enabled,
1441
+ default_channel_id: updates.discord.default_channel_id || current.discord?.default_channel_id,
1442
+ };
1443
+ // Only update token if not masked
1444
+ if (updates.discord.token && !updates.discord.token.startsWith('****')) {
1445
+ merged.discord.token = updates.discord.token;
1446
+ }
1447
+ }
1448
+
1449
+ // Slack config
1450
+ if (updates.slack) {
1451
+ merged.slack = {
1452
+ ...current.slack,
1453
+ enabled: updates.slack.enabled,
1454
+ };
1455
+ if (updates.slack.bot_token && !updates.slack.bot_token.startsWith('****')) {
1456
+ merged.slack.bot_token = updates.slack.bot_token;
1457
+ }
1458
+ if (updates.slack.app_token && !updates.slack.app_token.startsWith('****')) {
1459
+ merged.slack.app_token = updates.slack.app_token;
1460
+ }
1461
+ }
1462
+
1463
+ // Telegram config
1464
+ if (updates.telegram) {
1465
+ merged.telegram = {
1466
+ ...current.telegram,
1467
+ enabled: updates.telegram.enabled,
1468
+ };
1469
+ if (updates.telegram.token && !updates.telegram.token.startsWith('****')) {
1470
+ merged.telegram.token = updates.telegram.token;
1471
+ }
1472
+ }
1473
+
1474
+ // Chatwork config
1475
+ if (updates.chatwork) {
1476
+ merged.chatwork = {
1477
+ ...current.chatwork,
1478
+ enabled: updates.chatwork.enabled,
1479
+ };
1480
+ if (updates.chatwork.api_token && !updates.chatwork.api_token.startsWith('****')) {
1481
+ merged.chatwork.api_token = updates.chatwork.api_token;
1482
+ }
1483
+ }
1484
+
1485
+ return merged;
1486
+ }
1487
+
1488
+ /**
1489
+ * Validate config update
1490
+ */
1491
+ function validateConfigUpdate(config) {
1492
+ const errors = [];
1493
+
1494
+ if (config.agent) {
1495
+ if (config.agent.max_turns && (config.agent.max_turns < 1 || config.agent.max_turns > 100)) {
1496
+ errors.push('max_turns must be between 1 and 100');
1497
+ }
1498
+ if (config.agent.timeout && config.agent.timeout < 1000) {
1499
+ errors.push('timeout must be at least 1000ms');
1500
+ }
1501
+ }
1502
+
1503
+ if (config.heartbeat) {
1504
+ if (config.heartbeat.interval && config.heartbeat.interval < 60000) {
1505
+ errors.push('heartbeat interval must be at least 60000ms (1 minute)');
1506
+ }
1507
+ }
1508
+
1509
+ return errors;
1510
+ }
1511
+
1512
+ /**
1513
+ * Save MAMA config to ~/.mama/config.yaml
1514
+ */
1515
+ function saveMAMAConfig(config) {
1516
+ const configDir = path.dirname(MAMA_CONFIG_PATH);
1517
+
1518
+ // Ensure directory exists
1519
+ if (!fs.existsSync(configDir)) {
1520
+ fs.mkdirSync(configDir, { recursive: true });
1521
+ }
1522
+
1523
+ const content = yaml.dump(config, {
1524
+ indent: 2,
1525
+ lineWidth: 120,
1526
+ noRefs: true,
1527
+ });
1528
+
1529
+ const fileContent = `# MAMA Configuration
1530
+ # Updated: ${new Date().toISOString()}
1531
+ # Documentation: https://github.com/jungjaehoon-lifegamez/MAMA
1532
+
1533
+ ${content}`;
1534
+
1535
+ fs.writeFileSync(MAMA_CONFIG_PATH, fileContent, 'utf8');
1536
+ console.log('[GraphAPI] Config saved to:', MAMA_CONFIG_PATH);
1537
+ }
1538
+
1539
+ /**
1540
+ * Handle GET /api/memory/export - export decisions
1541
+ * Phase 6: Memory Analytics
1542
+ * Supports formats: json, markdown, csv
1543
+ */
1544
+ async function handleExportRequest(req, res, params) {
1545
+ try {
1546
+ const format = params.get('format') || 'json';
1547
+
1548
+ // Ensure DB is initialized
1549
+ await initDB();
1550
+
1551
+ // Get all decisions
1552
+ const decisions = await getAllNodes();
1553
+
1554
+ let content, contentType, filename;
1555
+
1556
+ switch (format) {
1557
+ case 'markdown':
1558
+ content = exportToMarkdown(decisions);
1559
+ contentType = 'text/markdown';
1560
+ filename = `mama-decisions-${new Date().toISOString().split('T')[0]}.md`;
1561
+ break;
1562
+ case 'csv':
1563
+ content = exportToCSV(decisions);
1564
+ contentType = 'text/csv';
1565
+ filename = `mama-decisions-${new Date().toISOString().split('T')[0]}.csv`;
1566
+ break;
1567
+ case 'json':
1568
+ default:
1569
+ content = JSON.stringify({ decisions, exported_at: new Date().toISOString() }, null, 2);
1570
+ contentType = 'application/json';
1571
+ filename = `mama-decisions-${new Date().toISOString().split('T')[0]}.json`;
1572
+ break;
1573
+ }
1574
+
1575
+ res.writeHead(200, {
1576
+ 'Content-Type': contentType,
1577
+ 'Content-Disposition': `attachment; filename="${filename}"`,
1578
+ });
1579
+ res.end(content);
1580
+ } catch (error) {
1581
+ console.error('[GraphAPI] Export error:', error.message);
1582
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1583
+ res.end(
1584
+ JSON.stringify({
1585
+ error: true,
1586
+ code: 'EXPORT_ERROR',
1587
+ message: error.message,
1588
+ })
1589
+ );
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Export decisions to Markdown format
1595
+ */
1596
+ function exportToMarkdown(decisions) {
1597
+ const lines = [
1598
+ '# MAMA Decisions Export',
1599
+ '',
1600
+ `Exported: ${new Date().toISOString()}`,
1601
+ `Total Decisions: ${decisions.length}`,
1602
+ '',
1603
+ '---',
1604
+ '',
1605
+ ];
1606
+
1607
+ for (const d of decisions) {
1608
+ lines.push(`## ${d.topic || 'Untitled'}`);
1609
+ lines.push('');
1610
+ lines.push(`**Decision:** ${d.decision || 'N/A'}`);
1611
+ lines.push('');
1612
+ if (d.reasoning) {
1613
+ lines.push(`**Reasoning:**`);
1614
+ lines.push('');
1615
+ lines.push(d.reasoning);
1616
+ lines.push('');
1617
+ }
1618
+ lines.push(`- **Outcome:** ${d.outcome || 'Pending'}`);
1619
+ lines.push(`- **Confidence:** ${d.confidence || 'N/A'}`);
1620
+ lines.push(`- **Created:** ${d.created_at ? new Date(d.created_at).toISOString() : 'N/A'}`);
1621
+ lines.push(`- **ID:** \`${d.id}\``);
1622
+ lines.push('');
1623
+ lines.push('---');
1624
+ lines.push('');
1625
+ }
1626
+
1627
+ return lines.join('\n');
1628
+ }
1629
+
1630
+ /**
1631
+ * Export decisions to CSV format
1632
+ */
1633
+ function exportToCSV(decisions) {
1634
+ const escapeCSV = (str) => {
1635
+ if (!str) {
1636
+ return '';
1637
+ }
1638
+ const escaped = String(str).replace(/"/g, '""');
1639
+ return escaped.includes(',') || escaped.includes('\n') || escaped.includes('"')
1640
+ ? `"${escaped}"`
1641
+ : escaped;
1642
+ };
1643
+
1644
+ const headers = ['id', 'topic', 'decision', 'reasoning', 'outcome', 'confidence', 'created_at'];
1645
+ const lines = [headers.join(',')];
1646
+
1647
+ for (const d of decisions) {
1648
+ const row = [
1649
+ escapeCSV(d.id),
1650
+ escapeCSV(d.topic),
1651
+ escapeCSV(d.decision),
1652
+ escapeCSV(d.reasoning),
1653
+ escapeCSV(d.outcome),
1654
+ d.confidence ?? '',
1655
+ d.created_at ? new Date(d.created_at).toISOString() : '',
1656
+ ];
1657
+ lines.push(row.join(','));
1658
+ }
1659
+
1660
+ return lines.join('\n');
1661
+ }
1662
+
1663
+ module.exports = {
1664
+ createGraphHandler,
1665
+ // Exported for testing
1666
+ getAllNodes,
1667
+ getAllEdges,
1668
+ getAllCheckpoints,
1669
+ getUniqueTopics,
1670
+ filterNodesByTopic,
1671
+ filterEdgesByNodes,
1672
+ VIEWER_HTML_PATH,
1673
+ VIEWER_CSS_PATH,
1674
+ VIEWER_JS_PATH,
1675
+ };