@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.
- package/dist/api/graph-api.js +1675 -0
- 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
|
+
};
|