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