@agilsee/mcp-orchestrator 0.5.0

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 (43) hide show
  1. package/bin/cli.js +490 -0
  2. package/dist/index.js +454 -0
  3. package/dist/memory/memory-manager.js +234 -0
  4. package/dist/server/web-server.js +574 -0
  5. package/dist/tools/aggregate-patterns.js +101 -0
  6. package/dist/tools/analyze-history.js +213 -0
  7. package/dist/tools/auto-dispatch.js +199 -0
  8. package/dist/tools/check-energy.js +49 -0
  9. package/dist/tools/cross-search.js +171 -0
  10. package/dist/tools/get-focus.js +7 -0
  11. package/dist/tools/get-identity.js +7 -0
  12. package/dist/tools/get-project-status.js +35 -0
  13. package/dist/tools/list-projects.js +21 -0
  14. package/dist/tools/list-recent-tasks.js +59 -0
  15. package/dist/tools/log-insight.js +43 -0
  16. package/dist/tools/qcc-create.js +82 -0
  17. package/dist/tools/qcc-status.js +164 -0
  18. package/dist/tools/qcc-update.js +188 -0
  19. package/dist/tools/smart-bootstrap.js +255 -0
  20. package/dist/tools/summarize-session.js +161 -0
  21. package/dist/tools/switch-focus.js +40 -0
  22. package/dist/tools/workflow-router.js +438 -0
  23. package/package.json +44 -0
  24. package/templates/index.ts.template +42 -0
  25. package/templates/shared/get-claude-md.ts +12 -0
  26. package/templates/shared/get-current-state.ts +21 -0
  27. package/templates/shared/get-mistakes.ts +18 -0
  28. package/templates/shared/log-task.ts +27 -0
  29. package/templates/shared/predict-impact.ts +67 -0
  30. package/templates/shared/record-mistake.ts +40 -0
  31. package/templates/shared/update-state.ts +83 -0
  32. package/templates/stacks/express/config.json +9 -0
  33. package/templates/stacks/express/list-routes.ts +56 -0
  34. package/templates/stacks/express/symbol-index.ts +70 -0
  35. package/templates/stacks/laravel/config.json +9 -0
  36. package/templates/stacks/laravel/list-routes.ts +19 -0
  37. package/templates/stacks/laravel/symbol-index.ts +64 -0
  38. package/templates/stacks/nextjs/config.json +9 -0
  39. package/templates/stacks/nextjs/list-routes.ts +67 -0
  40. package/templates/stacks/nextjs/symbol-index.ts +78 -0
  41. package/templates/stacks/react/config.json +10 -0
  42. package/templates/stacks/react/list-routes.ts +44 -0
  43. package/templates/stacks/react/symbol-index.ts +81 -0
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Web Server — Memory Dashboard + REST API
3
+ * Port: 37800
4
+ *
5
+ * Features mirrored from claude-mem:
6
+ * - Real-time memory viewer
7
+ * - Timeline sessions
8
+ * - Search endpoint
9
+ * - Stats dashboard
10
+ * - REST API (CRUD)
11
+ */
12
+ import { createServer } from "http";
13
+ import { readFileSync, existsSync } from "fs";
14
+ import { join } from "path";
15
+ import { URL } from "url";
16
+ const PORT = 37800;
17
+ const CLAUDE_HOME = process.env.CLAUDE_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? "", ".claude");
18
+ const MEMORY_DB = join(CLAUDE_HOME, "data", "memory.json");
19
+ const COUNTER_FILE = join(CLAUDE_HOME, "data", ".edit-counter");
20
+ // ─── Helpers ───
21
+ function readDb() {
22
+ if (!existsSync(MEMORY_DB))
23
+ return { entries: [], raw: null };
24
+ try {
25
+ const raw = JSON.parse(readFileSync(MEMORY_DB, "utf8"));
26
+ const col = raw.collections?.find((c) => c.name === "memories");
27
+ return { entries: col?.data || [], raw };
28
+ }
29
+ catch {
30
+ return { entries: [], raw: null };
31
+ }
32
+ }
33
+ function readRegistry() {
34
+ const regPath = join(CLAUDE_HOME, "project-registry.json");
35
+ if (!existsSync(regPath))
36
+ return {};
37
+ try {
38
+ return JSON.parse(readFileSync(regPath, "utf8"));
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ }
44
+ function readEditCounter() {
45
+ if (!existsSync(COUNTER_FILE))
46
+ return 0;
47
+ try {
48
+ const data = JSON.parse(readFileSync(COUNTER_FILE, "utf8"));
49
+ return data.count || 0;
50
+ }
51
+ catch {
52
+ return 0;
53
+ }
54
+ }
55
+ function readProjectDocs(slug, file) {
56
+ const p = join(CLAUDE_HOME, "project-docs", slug, file);
57
+ if (!existsSync(p))
58
+ return "";
59
+ try {
60
+ return readFileSync(p, "utf8");
61
+ }
62
+ catch {
63
+ return "";
64
+ }
65
+ }
66
+ function cors(res) {
67
+ res.setHeader("Access-Control-Allow-Origin", "*");
68
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
69
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
70
+ }
71
+ function json(res, data, status = 200) {
72
+ cors(res);
73
+ res.writeHead(status, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify(data));
75
+ }
76
+ function html(res, content) {
77
+ cors(res);
78
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
79
+ res.end(content);
80
+ }
81
+ // ─── REST API Routes ───
82
+ function handleApi(req, res, url) {
83
+ const path = url.pathname;
84
+ // GET /api/stats
85
+ if (path === "/api/stats" && req.method === "GET") {
86
+ const { entries } = readDb();
87
+ const reg = readRegistry();
88
+ const projects = Object.keys(reg.projects || {});
89
+ const activeProjects = Object.entries(reg.projects || {}).filter(([_, p]) => p.status === "active").length;
90
+ const byType = {};
91
+ const byProject = {};
92
+ entries.forEach((e) => {
93
+ byType[e.type] = (byType[e.type] || 0) + 1;
94
+ if (e.project)
95
+ byProject[e.project] = (byProject[e.project] || 0) + 1;
96
+ });
97
+ const lastEntry = entries.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0];
98
+ return json(res, {
99
+ total_memories: entries.length,
100
+ by_type: byType,
101
+ by_project: byProject,
102
+ total_projects: projects.length,
103
+ active_projects: activeProjects,
104
+ last_updated: lastEntry?.created_at || null,
105
+ current_session_edits: readEditCounter(),
106
+ });
107
+ }
108
+ // GET /api/memories?type=&project=&limit=&offset=
109
+ if (path === "/api/memories" && req.method === "GET") {
110
+ const { entries } = readDb();
111
+ let filtered = [...entries];
112
+ const type = url.searchParams.get("type");
113
+ const project = url.searchParams.get("project");
114
+ if (type)
115
+ filtered = filtered.filter((e) => e.type === type);
116
+ if (project)
117
+ filtered = filtered.filter((e) => e.project === project);
118
+ filtered.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
119
+ const limit = parseInt(url.searchParams.get("limit") || "50");
120
+ const offset = parseInt(url.searchParams.get("offset") || "0");
121
+ return json(res, {
122
+ memories: filtered.slice(offset, offset + limit),
123
+ total: filtered.length,
124
+ limit,
125
+ offset,
126
+ });
127
+ }
128
+ // GET /api/search?q=&type=&project=
129
+ if (path === "/api/search" && req.method === "GET") {
130
+ const q = (url.searchParams.get("q") || "").toLowerCase();
131
+ if (!q)
132
+ return json(res, { results: [], total: 0 });
133
+ const { entries } = readDb();
134
+ const keywords = q.split(/\s+/).filter((w) => w.length > 1);
135
+ const scored = entries.map((e) => {
136
+ const text = `${e.title} ${e.content} ${(e.tags || []).join(" ")}`.toLowerCase();
137
+ let score = 0;
138
+ keywords.forEach((kw) => {
139
+ if (text.includes(kw))
140
+ score += 1;
141
+ if (e.title?.toLowerCase().includes(kw))
142
+ score += 2; // title boost
143
+ if ((e.tags || []).some((t) => t.toLowerCase().includes(kw)))
144
+ score += 1.5; // tag boost
145
+ });
146
+ return { ...e, score };
147
+ }).filter((e) => e.score > 0);
148
+ scored.sort((a, b) => b.score - a.score);
149
+ const type = url.searchParams.get("type");
150
+ const project = url.searchParams.get("project");
151
+ let results = scored;
152
+ if (type)
153
+ results = results.filter((e) => e.type === type);
154
+ if (project)
155
+ results = results.filter((e) => e.project === project);
156
+ return json(res, {
157
+ results: results.slice(0, 20),
158
+ total: results.length,
159
+ query: q,
160
+ });
161
+ }
162
+ // GET /api/timeline
163
+ if (path === "/api/timeline" && req.method === "GET") {
164
+ const { entries } = readDb();
165
+ const sorted = [...entries].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
166
+ // Group by date
167
+ const grouped = {};
168
+ sorted.forEach((e) => {
169
+ const date = e.created_at?.substring(0, 10) || "unknown";
170
+ if (!grouped[date])
171
+ grouped[date] = [];
172
+ grouped[date].push({
173
+ id: e.id,
174
+ type: e.type,
175
+ project: e.project,
176
+ title: e.title,
177
+ tags: e.tags,
178
+ created_at: e.created_at,
179
+ });
180
+ });
181
+ return json(res, { timeline: grouped });
182
+ }
183
+ // GET /api/projects
184
+ if (path === "/api/projects" && req.method === "GET") {
185
+ const reg = readRegistry();
186
+ const projects = Object.entries(reg.projects || {}).map(([slug, p]) => ({
187
+ slug,
188
+ name: p.name,
189
+ status: p.status,
190
+ group: p.group,
191
+ stack: p.stack,
192
+ mistakes_count: readProjectDocs(slug, "MISTAKES.md").split("\n## [").length - 1,
193
+ }));
194
+ return json(res, { projects });
195
+ }
196
+ // GET /api/memory/:id
197
+ const memMatch = path.match(/^\/api\/memory\/(.+)$/);
198
+ if (memMatch && req.method === "GET") {
199
+ const id = memMatch[1];
200
+ const { entries } = readDb();
201
+ const entry = entries.find((e) => e.id === id);
202
+ if (!entry)
203
+ return json(res, { error: "Not found" }, 404);
204
+ return json(res, entry);
205
+ }
206
+ return json(res, { error: "Not found" }, 404);
207
+ }
208
+ // ─── Dashboard HTML ───
209
+ function getDashboardHtml() {
210
+ return `<!DOCTYPE html>
211
+ <html lang="en">
212
+ <head>
213
+ <meta charset="UTF-8">
214
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
215
+ <title>MCP Memory Dashboard — ASAP Orchestrator</title>
216
+ <style>
217
+ * { margin: 0; padding: 0; box-sizing: border-box; }
218
+ :root {
219
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
220
+ --text: #c9d1d9; --text-muted: #8b949e; --accent: #58a6ff;
221
+ --green: #3fb950; --orange: #d29922; --red: #f85149; --purple: #bc8cff;
222
+ }
223
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
224
+
225
+ .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
226
+ .header h1 { font-size: 18px; color: var(--accent); }
227
+ .header .subtitle { color: var(--text-muted); font-size: 13px; }
228
+ .header .live-dot { width: 8px; height: 8px; background: var(--green); border-radius: 50%; display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
229
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
230
+
231
+ .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
232
+
233
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
234
+ .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
235
+ .stat-card .label { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
236
+ .stat-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
237
+ .stat-card .value.accent { color: var(--accent); }
238
+ .stat-card .value.green { color: var(--green); }
239
+ .stat-card .value.orange { color: var(--orange); }
240
+ .stat-card .value.purple { color: var(--purple); }
241
+
242
+ .tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
243
+ .tab { padding: 10px 16px; cursor: pointer; color: var(--text-muted); border-bottom: 2px solid transparent; font-size: 14px; transition: all 0.2s; }
244
+ .tab:hover { color: var(--text); }
245
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
246
+
247
+ .search-bar { margin-bottom: 16px; display: flex; gap: 8px; }
248
+ .search-bar input { flex: 1; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; outline: none; }
249
+ .search-bar input:focus { border-color: var(--accent); }
250
+ .search-bar select { padding: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
251
+
252
+ .panel { display: none; }
253
+ .panel.active { display: block; }
254
+
255
+ .memory-list { display: flex; flex-direction: column; gap: 8px; }
256
+ .memory-item { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; cursor: pointer; transition: border-color 0.2s; }
257
+ .memory-item:hover { border-color: var(--accent); }
258
+ .memory-item .top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
259
+ .memory-item .title { font-weight: 600; font-size: 14px; }
260
+ .memory-item .meta { color: var(--text-muted); font-size: 12px; }
261
+ .memory-item .content { color: var(--text-muted); font-size: 13px; margin-top: 6px; line-height: 1.5; }
262
+ .memory-item .tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
263
+ .memory-item .tag { background: rgba(88,166,255,0.1); color: var(--accent); padding: 2px 8px; border-radius: 12px; font-size: 11px; }
264
+
265
+ .type-badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
266
+ .type-session_summary { background: rgba(63,185,80,0.15); color: var(--green); }
267
+ .type-decision { background: rgba(188,140,255,0.15); color: var(--purple); }
268
+ .type-bug { background: rgba(248,81,73,0.15); color: var(--red); }
269
+ .type-context { background: rgba(88,166,255,0.15); color: var(--accent); }
270
+ .type-insight { background: rgba(210,153,34,0.15); color: var(--orange); }
271
+ .type-user_note { background: rgba(201,209,217,0.1); color: var(--text-muted); }
272
+
273
+ .timeline-date { color: var(--accent); font-size: 14px; font-weight: 600; margin: 16px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
274
+ .timeline-item { display: flex; gap: 12px; padding: 8px 0 8px 16px; border-left: 2px solid var(--border); margin-left: 8px; }
275
+ .timeline-item .dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
276
+ .timeline-item .info .title { font-size: 13px; }
277
+ .timeline-item .info .meta { color: var(--text-muted); font-size: 11px; }
278
+
279
+ .project-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 8px; }
280
+ .project-card .name { font-weight: 600; font-size: 15px; }
281
+ .project-card .details { color: var(--text-muted); font-size: 12px; margin-top: 4px; }
282
+ .project-card .stack-tags { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }
283
+ .project-card .stack-tag { background: rgba(210,153,34,0.1); color: var(--orange); padding: 2px 8px; border-radius: 12px; font-size: 11px; }
284
+
285
+ .status-active { color: var(--green); }
286
+ .status-maintenance { color: var(--orange); }
287
+
288
+ .detail-modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; }
289
+ .detail-modal.show { display: flex; }
290
+ .detail-content { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; }
291
+ .detail-content .close { float: right; cursor: pointer; color: var(--text-muted); font-size: 20px; }
292
+ .detail-content h2 { font-size: 18px; margin-bottom: 12px; }
293
+ .detail-content pre { background: var(--bg); padding: 12px; border-radius: 6px; font-size: 13px; white-space: pre-wrap; word-break: break-word; margin-top: 12px; line-height: 1.6; }
294
+
295
+ .empty { text-align: center; padding: 40px; color: var(--text-muted); }
296
+
297
+ footer { text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 40px; }
298
+ </style>
299
+ </head>
300
+ <body>
301
+
302
+ <div class="header">
303
+ <div>
304
+ <h1><span class="live-dot"></span>MCP Memory Dashboard</h1>
305
+ <div class="subtitle">MCP Orchestrator — Memory Layer for Claude Code</div>
306
+ </div>
307
+ <div style="text-align:right">
308
+ <div style="color:var(--text-muted);font-size:12px" id="lastUpdate"></div>
309
+ <div style="color:var(--green);font-size:13px;font-weight:600" id="statusLine">Loading...</div>
310
+ </div>
311
+ </div>
312
+
313
+ <div class="container">
314
+ <div class="stats-grid" id="statsGrid"></div>
315
+
316
+ <div class="tabs">
317
+ <div class="tab active" data-tab="memories">Memories</div>
318
+ <div class="tab" data-tab="timeline">Timeline</div>
319
+ <div class="tab" data-tab="projects">Projects</div>
320
+ <div class="tab" data-tab="search">Search</div>
321
+ </div>
322
+
323
+ <div class="panel active" id="panel-memories">
324
+ <div class="search-bar">
325
+ <select id="filterType" onchange="loadMemories()">
326
+ <option value="">All Types</option>
327
+ <option value="session_summary">Session Summary</option>
328
+ <option value="decision">Decision</option>
329
+ <option value="bug">Bug</option>
330
+ <option value="context">Context</option>
331
+ <option value="insight">Insight</option>
332
+ <option value="user_note">User Note</option>
333
+ </select>
334
+ <select id="filterProject" onchange="loadMemories()">
335
+ <option value="">All Projects</option>
336
+ </select>
337
+ </div>
338
+ <div id="memoryList" class="memory-list"></div>
339
+ </div>
340
+
341
+ <div class="panel" id="panel-timeline">
342
+ <div id="timelineList"></div>
343
+ </div>
344
+
345
+ <div class="panel" id="panel-projects">
346
+ <div id="projectList"></div>
347
+ </div>
348
+
349
+ <div class="panel" id="panel-search">
350
+ <div class="search-bar">
351
+ <input type="text" id="searchInput" placeholder="Search memories... (supports typo/fuzzy)" onkeyup="if(event.key==='Enter')doSearch()">
352
+ <button onclick="doSearch()" style="padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:600">Search</button>
353
+ </div>
354
+ <div id="searchResults" class="memory-list"></div>
355
+ </div>
356
+ </div>
357
+
358
+ <div class="detail-modal" id="detailModal" onclick="if(event.target===this)closeDetail()">
359
+ <div class="detail-content">
360
+ <span class="close" onclick="closeDetail()">&times;</span>
361
+ <div id="detailBody"></div>
362
+ </div>
363
+ </div>
364
+
365
+ <footer>
366
+ @agilsee/mcp-orchestrator v0.5.0 | Memory Layer: Loki.js + MiniSearch | Port ${PORT}
367
+ </footer>
368
+
369
+ <script>
370
+ const API = '';
371
+
372
+ async function fetchJson(url) {
373
+ const r = await fetch(url);
374
+ return r.json();
375
+ }
376
+
377
+ // ─── Stats ───
378
+ async function loadStats() {
379
+ const s = await fetchJson(API + '/api/stats');
380
+ document.getElementById('statusLine').textContent = s.total_memories + ' memories stored';
381
+ document.getElementById('lastUpdate').textContent = s.last_updated ? 'Last: ' + new Date(s.last_updated).toLocaleString() : '';
382
+
383
+ const grid = document.getElementById('statsGrid');
384
+ grid.innerHTML = \`
385
+ <div class="stat-card"><div class="label">Total Memories</div><div class="value accent">\${s.total_memories}</div></div>
386
+ <div class="stat-card"><div class="label">Session Summaries</div><div class="value green">\${s.by_type?.session_summary || 0}</div></div>
387
+ <div class="stat-card"><div class="label">Bugs Recorded</div><div class="value" style="color:var(--red)">\${s.by_type?.bug || 0}</div></div>
388
+ <div class="stat-card"><div class="label">Decisions</div><div class="value purple">\${s.by_type?.decision || 0}</div></div>
389
+ <div class="stat-card"><div class="label">Active Projects</div><div class="value orange">\${s.active_projects}</div></div>
390
+ <div class="stat-card"><div class="label">Session Edits</div><div class="value">\${s.current_session_edits}</div></div>
391
+ \`;
392
+
393
+ // Populate project filter
394
+ const sel = document.getElementById('filterProject');
395
+ const existing = sel.options.length;
396
+ if (existing <= 1 && s.by_project) {
397
+ Object.keys(s.by_project).forEach(p => {
398
+ const opt = document.createElement('option');
399
+ opt.value = p; opt.textContent = p + ' (' + s.by_project[p] + ')';
400
+ sel.appendChild(opt);
401
+ });
402
+ }
403
+ }
404
+
405
+ // ─── Memories ───
406
+ async function loadMemories() {
407
+ const type = document.getElementById('filterType').value;
408
+ const project = document.getElementById('filterProject').value;
409
+ let url = API + '/api/memories?limit=50';
410
+ if (type) url += '&type=' + type;
411
+ if (project) url += '&project=' + project;
412
+
413
+ const data = await fetchJson(url);
414
+ const list = document.getElementById('memoryList');
415
+
416
+ if (!data.memories.length) {
417
+ list.innerHTML = '<div class="empty">No memories found. Use save_memory MCP tool to start.</div>';
418
+ return;
419
+ }
420
+
421
+ list.innerHTML = data.memories.map(m => \`
422
+ <div class="memory-item" onclick="showDetail('\${m.id}')">
423
+ <div class="top">
424
+ <span class="title">\${esc(m.title)}</span>
425
+ <span class="type-badge type-\${m.type}">\${m.type.replace('_',' ')}</span>
426
+ </div>
427
+ <div class="meta">\${m.project ? '[' + m.project + '] ' : ''}\${m.created_at ? new Date(m.created_at).toLocaleString() : ''}</div>
428
+ <div class="content">\${esc((m.content || '').substring(0, 200))}\${(m.content||'').length > 200 ? '...' : ''}</div>
429
+ \${(m.tags||[]).length ? '<div class="tags">' + m.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</div>' : ''}
430
+ </div>
431
+ \`).join('');
432
+ }
433
+
434
+ // ─── Timeline ───
435
+ async function loadTimeline() {
436
+ const data = await fetchJson(API + '/api/timeline');
437
+ const list = document.getElementById('timelineList');
438
+
439
+ const dates = Object.keys(data.timeline).sort().reverse();
440
+ if (!dates.length) {
441
+ list.innerHTML = '<div class="empty">No timeline data yet.</div>';
442
+ return;
443
+ }
444
+
445
+ const typeColors = { session_summary: 'var(--green)', decision: 'var(--purple)', bug: 'var(--red)', context: 'var(--accent)', insight: 'var(--orange)', user_note: 'var(--text-muted)' };
446
+
447
+ list.innerHTML = dates.map(date => \`
448
+ <div class="timeline-date">\${date}</div>
449
+ \${data.timeline[date].map(e => \`
450
+ <div class="timeline-item" onclick="showDetail('\${e.id}')" style="cursor:pointer">
451
+ <div class="dot" style="background:\${typeColors[e.type] || 'var(--text-muted)'}"></div>
452
+ <div class="info">
453
+ <div class="title"><span class="type-badge type-\${e.type}">\${e.type.replace('_',' ')}</span> \${esc(e.title)}</div>
454
+ <div class="meta">\${e.project ? e.project + ' · ' : ''}\${e.created_at ? new Date(e.created_at).toLocaleTimeString() : ''}</div>
455
+ </div>
456
+ </div>
457
+ \`).join('')}
458
+ \`).join('');
459
+ }
460
+
461
+ // ─── Projects ───
462
+ async function loadProjects() {
463
+ const data = await fetchJson(API + '/api/projects');
464
+ const list = document.getElementById('projectList');
465
+
466
+ list.innerHTML = data.projects.map(p => \`
467
+ <div class="project-card">
468
+ <div style="display:flex;justify-content:space-between;align-items:center">
469
+ <span class="name">\${esc(p.name)}</span>
470
+ <span class="status-\${p.status}" style="font-size:12px;font-weight:600">\${p.status}</span>
471
+ </div>
472
+ <div class="details">\${p.slug} · Group: \${p.group || '-'} · Bugs recorded: \${p.mistakes_count}</div>
473
+ <div class="stack-tags">\${(p.stack||[]).map(s => '<span class="stack-tag">' + s + '</span>').join('')}</div>
474
+ </div>
475
+ \`).join('');
476
+ }
477
+
478
+ // ─── Search ───
479
+ async function doSearch() {
480
+ const q = document.getElementById('searchInput').value.trim();
481
+ if (!q) return;
482
+
483
+ const data = await fetchJson(API + '/api/search?q=' + encodeURIComponent(q));
484
+ const list = document.getElementById('searchResults');
485
+
486
+ if (!data.results.length) {
487
+ list.innerHTML = '<div class="empty">No results for "' + esc(q) + '"</div>';
488
+ return;
489
+ }
490
+
491
+ list.innerHTML = '<div style="color:var(--text-muted);font-size:13px;margin-bottom:12px">' + data.total + ' results for "' + esc(q) + '"</div>' +
492
+ data.results.map(m => \`
493
+ <div class="memory-item" onclick="showDetail('\${m.id}')">
494
+ <div class="top">
495
+ <span class="title">\${esc(m.title)}</span>
496
+ <div><span style="color:var(--orange);font-size:12px;margin-right:8px">score: \${m.score.toFixed(1)}</span><span class="type-badge type-\${m.type}">\${m.type.replace('_',' ')}</span></div>
497
+ </div>
498
+ <div class="meta">\${m.project ? '[' + m.project + '] ' : ''}\${m.created_at ? new Date(m.created_at).toLocaleString() : ''}</div>
499
+ <div class="content">\${esc((m.content || '').substring(0, 200))}</div>
500
+ </div>
501
+ \`).join('');
502
+ }
503
+
504
+ // ─── Detail Modal ───
505
+ async function showDetail(id) {
506
+ const m = await fetchJson(API + '/api/memory/' + id);
507
+ if (m.error) return;
508
+
509
+ document.getElementById('detailBody').innerHTML = \`
510
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
511
+ <span class="type-badge type-\${m.type}" style="font-size:13px;padding:4px 12px">\${m.type.replace('_',' ')}</span>
512
+ <span style="color:var(--text-muted);font-size:12px">\${m.id}</span>
513
+ </div>
514
+ <h2>\${esc(m.title)}</h2>
515
+ <div style="color:var(--text-muted);font-size:13px;margin-bottom:8px">
516
+ \${m.project ? 'Project: <strong>' + m.project + '</strong> · ' : ''}
517
+ Created: \${m.created_at ? new Date(m.created_at).toLocaleString() : '-'}
518
+ \${m.updated_at && m.updated_at !== m.created_at ? ' · Updated: ' + new Date(m.updated_at).toLocaleString() : ''}
519
+ </div>
520
+ \${(m.tags||[]).length ? '<div class="tags" style="margin-bottom:12px">' + m.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</div>' : ''}
521
+ <pre>\${esc(m.content)}</pre>
522
+ \`;
523
+ document.getElementById('detailModal').classList.add('show');
524
+ }
525
+
526
+ function closeDetail() {
527
+ document.getElementById('detailModal').classList.remove('show');
528
+ }
529
+
530
+ // ─── Tabs ───
531
+ document.querySelectorAll('.tab').forEach(tab => {
532
+ tab.addEventListener('click', () => {
533
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
534
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
535
+ tab.classList.add('active');
536
+ document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
537
+
538
+ if (tab.dataset.tab === 'timeline') loadTimeline();
539
+ if (tab.dataset.tab === 'projects') loadProjects();
540
+ });
541
+ });
542
+
543
+ function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
544
+
545
+ // ─── Init ───
546
+ loadStats();
547
+ loadMemories();
548
+
549
+ // Auto-refresh every 30s
550
+ setInterval(() => { loadStats(); }, 30000);
551
+ </script>
552
+ </body>
553
+ </html>`;
554
+ }
555
+ // ─── Server ───
556
+ const server = createServer((req, res) => {
557
+ const url = new URL(req.url || "/", `http://localhost:${PORT}`);
558
+ // CORS preflight
559
+ if (req.method === "OPTIONS") {
560
+ cors(res);
561
+ res.writeHead(204);
562
+ return res.end();
563
+ }
564
+ // API routes
565
+ if (url.pathname.startsWith("/api/")) {
566
+ return handleApi(req, res, url);
567
+ }
568
+ // Dashboard
569
+ return html(res, getDashboardHtml());
570
+ });
571
+ server.listen(PORT, () => {
572
+ console.log(`[MCP Memory Dashboard] Running at http://localhost:${PORT}`);
573
+ console.log(`[MCP Memory Dashboard] CLAUDE_HOME=${CLAUDE_HOME}`);
574
+ });
@@ -0,0 +1,101 @@
1
+ import { readFile, readdir } from "fs/promises";
2
+ import { join } from "path";
3
+ /**
4
+ * Scan MISTAKES.md dari semua project, cari pattern yang muncul
5
+ * di lebih dari 1 project (cross-project pattern detection).
6
+ *
7
+ * Juga bisa search by keyword untuk cek apakah bug tertentu
8
+ * sudah pernah terjadi di project lain.
9
+ */
10
+ export async function aggregatePatterns(claudeHome, keyword) {
11
+ const docsRoot = join(claudeHome, "project-docs");
12
+ let slugs;
13
+ try {
14
+ const entries = await readdir(docsRoot, { withFileTypes: true });
15
+ slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
16
+ }
17
+ catch {
18
+ return { error: "Cannot read project-docs directory" };
19
+ }
20
+ const allMatches = [];
21
+ const projectMistakes = {};
22
+ for (const slug of slugs) {
23
+ try {
24
+ const path = join(docsRoot, slug, "MISTAKES.md");
25
+ const content = await readFile(path, "utf-8");
26
+ const sections = content.split(/(?=^##\s)/m).filter((s) => s.trim());
27
+ const titles = [];
28
+ for (const section of sections) {
29
+ const titleMatch = section.match(/^##\s+\[.*?\]\s*—\s*(.+)$/m);
30
+ const title = titleMatch ? titleMatch[1].trim() : "";
31
+ titles.push(title);
32
+ // If keyword provided, filter
33
+ if (keyword) {
34
+ const lower = keyword.toLowerCase();
35
+ if (section.toLowerCase().includes(lower)) {
36
+ allMatches.push({
37
+ project: slug,
38
+ title,
39
+ preview: section.slice(0, 500),
40
+ });
41
+ }
42
+ }
43
+ }
44
+ projectMistakes[slug] = titles;
45
+ }
46
+ catch {
47
+ // skip projects without MISTAKES.md
48
+ }
49
+ }
50
+ // Cross-project pattern detection: find similar titles across projects
51
+ const crossPatterns = [];
52
+ if (!keyword) {
53
+ const allTitles = Object.entries(projectMistakes);
54
+ for (let i = 0; i < allTitles.length; i++) {
55
+ for (let j = i + 1; j < allTitles.length; j++) {
56
+ const [slugA, titlesA] = allTitles[i];
57
+ const [slugB, titlesB] = allTitles[j];
58
+ for (const titleA of titlesA) {
59
+ if (!titleA)
60
+ continue;
61
+ for (const titleB of titlesB) {
62
+ if (!titleB)
63
+ continue;
64
+ if (fuzzyMatch(titleA, titleB)) {
65
+ crossPatterns.push({
66
+ pattern: titleA,
67
+ found_in: [slugA, slugB],
68
+ });
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return {
76
+ projects_scanned: slugs.length,
77
+ keyword: keyword ?? null,
78
+ matches: keyword ? allMatches : undefined,
79
+ cross_patterns: crossPatterns.length > 0 ? crossPatterns : undefined,
80
+ projects_with_mistakes: Object.keys(projectMistakes),
81
+ summary: keyword
82
+ ? `Found ${allMatches.length} matches for "${keyword}" across ${slugs.length} projects`
83
+ : `Scanned ${slugs.length} projects, found ${crossPatterns.length} cross-project patterns`,
84
+ };
85
+ }
86
+ /**
87
+ * Simple fuzzy match: check if 50%+ of words overlap
88
+ */
89
+ function fuzzyMatch(a, b) {
90
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 3));
91
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter((w) => w.length > 3));
92
+ if (wordsA.size === 0 || wordsB.size === 0)
93
+ return false;
94
+ let overlap = 0;
95
+ for (const w of wordsA) {
96
+ if (wordsB.has(w))
97
+ overlap++;
98
+ }
99
+ const minSize = Math.min(wordsA.size, wordsB.size);
100
+ return overlap / minSize >= 0.5;
101
+ }