@jungjaehoon/mama-os 0.8.3 → 0.9.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 (106) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent/agent-loop.d.ts +1 -8
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +44 -159
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/claude-cli-wrapper.d.ts +6 -0
  7. package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
  8. package/dist/agent/claude-cli-wrapper.js +6 -0
  9. package/dist/agent/claude-cli-wrapper.js.map +1 -1
  10. package/dist/agent/codex-mcp-process.d.ts +85 -0
  11. package/dist/agent/codex-mcp-process.d.ts.map +1 -0
  12. package/dist/agent/codex-mcp-process.js +357 -0
  13. package/dist/agent/codex-mcp-process.js.map +1 -0
  14. package/dist/agent/session-pool.d.ts +17 -2
  15. package/dist/agent/session-pool.d.ts.map +1 -1
  16. package/dist/agent/session-pool.js +51 -26
  17. package/dist/agent/session-pool.js.map +1 -1
  18. package/dist/agent/types.d.ts +9 -24
  19. package/dist/agent/types.d.ts.map +1 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/api/graph-api.d.ts.map +1 -1
  22. package/dist/api/graph-api.js +133 -45
  23. package/dist/api/graph-api.js.map +1 -1
  24. package/dist/cli/commands/init.d.ts +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +14 -25
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/run.d.ts.map +1 -1
  29. package/dist/cli/commands/run.js +3 -10
  30. package/dist/cli/commands/run.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts.map +1 -1
  32. package/dist/cli/commands/start.js +143 -54
  33. package/dist/cli/commands/start.js.map +1 -1
  34. package/dist/cli/commands/status.d.ts.map +1 -1
  35. package/dist/cli/commands/status.js +2 -7
  36. package/dist/cli/commands/status.js.map +1 -1
  37. package/dist/cli/config/config-manager.d.ts.map +1 -1
  38. package/dist/cli/config/config-manager.js +9 -17
  39. package/dist/cli/config/config-manager.js.map +1 -1
  40. package/dist/cli/config/types.d.ts +19 -25
  41. package/dist/cli/config/types.d.ts.map +1 -1
  42. package/dist/cli/config/types.js.map +1 -1
  43. package/dist/cli/index.js +2 -2
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/gateways/context-injector.d.ts.map +1 -1
  46. package/dist/gateways/context-injector.js +6 -3
  47. package/dist/gateways/context-injector.js.map +1 -1
  48. package/dist/gateways/discord.d.ts +4 -0
  49. package/dist/gateways/discord.d.ts.map +1 -1
  50. package/dist/gateways/discord.js +39 -16
  51. package/dist/gateways/discord.js.map +1 -1
  52. package/dist/gateways/message-router.d.ts +6 -1
  53. package/dist/gateways/message-router.d.ts.map +1 -1
  54. package/dist/gateways/message-router.js +92 -7
  55. package/dist/gateways/message-router.js.map +1 -1
  56. package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
  57. package/dist/multi-agent/agent-process-manager.js +36 -9
  58. package/dist/multi-agent/agent-process-manager.js.map +1 -1
  59. package/dist/multi-agent/runtime-process.d.ts +4 -4
  60. package/dist/multi-agent/runtime-process.d.ts.map +1 -1
  61. package/dist/multi-agent/runtime-process.js +9 -20
  62. package/dist/multi-agent/runtime-process.js.map +1 -1
  63. package/dist/multi-agent/types.d.ts +13 -8
  64. package/dist/multi-agent/types.d.ts.map +1 -1
  65. package/dist/multi-agent/types.js.map +1 -1
  66. package/dist/setup/setup-prompt.d.ts +1 -1
  67. package/dist/setup/setup-prompt.d.ts.map +1 -1
  68. package/dist/setup/setup-prompt.js +19 -0
  69. package/dist/setup/setup-prompt.js.map +1 -1
  70. package/dist/setup/setup-server.d.ts.map +1 -1
  71. package/dist/setup/setup-server.js +39 -16
  72. package/dist/setup/setup-server.js.map +1 -1
  73. package/dist/skills/skill-registry.d.ts.map +1 -1
  74. package/dist/skills/skill-registry.js +5 -2
  75. package/dist/skills/skill-registry.js.map +1 -1
  76. package/package.json +5 -3
  77. package/public/setup.html +12 -1
  78. package/public/viewer/js/modules/chat.js +1760 -1976
  79. package/public/viewer/js/modules/dashboard.js +613 -695
  80. package/public/viewer/js/modules/graph.js +857 -970
  81. package/public/viewer/js/modules/memory.js +357 -312
  82. package/public/viewer/js/modules/settings.js +1009 -1026
  83. package/public/viewer/js/modules/skills.js +336 -355
  84. package/public/viewer/js/utils/api.js +255 -255
  85. package/public/viewer/js/utils/debug-logger.js +20 -26
  86. package/public/viewer/js/utils/dom.js +73 -60
  87. package/public/viewer/js/utils/format.js +182 -228
  88. package/public/viewer/js/utils/markdown.js +40 -0
  89. package/public/viewer/src/modules/chat.ts +2258 -0
  90. package/public/viewer/src/modules/dashboard.ts +1052 -0
  91. package/public/viewer/src/modules/graph.ts +1080 -0
  92. package/public/viewer/src/modules/memory.ts +453 -0
  93. package/public/viewer/src/modules/settings.ts +1398 -0
  94. package/public/viewer/src/modules/skills.ts +457 -0
  95. package/public/viewer/src/types/global.d.ts +168 -0
  96. package/public/viewer/src/utils/api.ts +650 -0
  97. package/public/viewer/src/utils/debug-logger.ts +36 -0
  98. package/public/viewer/src/utils/dom.ts +138 -0
  99. package/public/viewer/src/utils/format.ts +331 -0
  100. package/public/viewer/src/utils/markdown.ts +46 -0
  101. package/public/viewer/tsconfig.viewer.json +18 -0
  102. package/public/viewer/viewer.html +214 -311
  103. package/dist/agent/codex-cli-wrapper.d.ts +0 -85
  104. package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
  105. package/dist/agent/codex-cli-wrapper.js +0 -295
  106. package/dist/agent/codex-cli-wrapper.js.map +0 -1
@@ -0,0 +1,1080 @@
1
+ /**
2
+ * Graph Module - Decision Graph Visualization
3
+ * @module modules/graph
4
+ * @version 1.0.0
5
+ *
6
+ * Handles Graph visualization using vis.js:
7
+ * - Network initialization and rendering
8
+ * - Node/edge styling and clustering
9
+ * - Search and filter functionality
10
+ * - Detail panel for node information
11
+ * - BFS traversal for connected nodes
12
+ */
13
+
14
+ /* eslint-env browser */
15
+ /* global vis */
16
+
17
+ import {
18
+ escapeHtml,
19
+ escapeAttr,
20
+ debounce,
21
+ showToast,
22
+ getElementByIdOrNull,
23
+ getErrorMessage,
24
+ } from '../utils/dom.js';
25
+ import { DebugLogger } from '../utils/debug-logger.js';
26
+ import { API, type GraphNode, type GraphEdge, type SimilarDecision } from '../utils/api.js';
27
+ import { renderSafeMarkdown } from '../utils/markdown.js';
28
+
29
+ type GraphNodeRecord = GraphNode & {
30
+ topic?: string;
31
+ outcome?: string;
32
+ decision?: string;
33
+ reasoning?: string;
34
+ confidence?: number;
35
+ created_at?: string;
36
+ };
37
+
38
+ type GraphEdgeRecord = GraphEdge & {
39
+ from: GraphNodeRecord['id'];
40
+ to: GraphNodeRecord['id'];
41
+ };
42
+
43
+ type EdgeStyle = {
44
+ color: string;
45
+ dashes: boolean | number[];
46
+ width: number;
47
+ };
48
+
49
+ type ConnectedEdges = {
50
+ outgoing: GraphEdgeRecord[];
51
+ incoming: GraphEdgeRecord[];
52
+ all: GraphEdgeRecord[];
53
+ };
54
+
55
+ type GraphInput = {
56
+ nodes: GraphNodeRecord[];
57
+ edges: GraphEdgeRecord[];
58
+ meta?: Record<string, unknown>;
59
+ };
60
+
61
+ const logger = new DebugLogger('Graph');
62
+
63
+ /**
64
+ * Graph Module Class
65
+ */
66
+ export class GraphModule {
67
+ network: VisNetwork | null = null;
68
+ graphData: GraphInput = { nodes: [], edges: [], meta: {} };
69
+ currentNodeId: string | null = null;
70
+ adjacencyList: Map<string, string[]> = new Map();
71
+ searchMatches: GraphNodeRecord[] = [];
72
+ currentSearchIndex = 0;
73
+ debouncedSearch = debounce(() => this.search(), 300);
74
+ private similarDecisionClickHandler = (event: Event): void => {
75
+ const target = event.target as HTMLElement;
76
+ const btn = target.closest('.similar-decision-btn') as HTMLElement | null;
77
+ if (!btn || !btn.dataset.nodeId) {
78
+ return;
79
+ }
80
+ this.navigateToNode(btn.dataset.nodeId);
81
+ };
82
+ topicColors: Record<string, string> = {};
83
+ colorPalette = [
84
+ '#FFCE00', // mama yellow (primary)
85
+ '#E6B800', // mama yellow-hover
86
+ '#FF9999', // mama blush
87
+ '#D4C4E0', // mama lavender-dark
88
+ '#22c55e', // success green
89
+ '#f97316', // warning orange
90
+ '#06b6d4', // info cyan
91
+ '#8b5cf6', // purple accent
92
+ '#ec4899', // pink accent
93
+ '#f59e0b', // amber
94
+ '#10b981', // teal
95
+ '#0ea5e9', // sky blue
96
+ ];
97
+ colorIndex = 0;
98
+ edgeStyles: Record<string, EdgeStyle> = {
99
+ supersedes: { color: '#666666', dashes: false, width: 2 },
100
+ builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 }, // dark goldenrod
101
+ debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 }, // crimson
102
+ synthesizes: { color: '#6B4C9A', width: 3, dashes: false }, // dark purple
103
+ };
104
+
105
+ constructor() {
106
+ // Network state
107
+ }
108
+
109
+ // =============================================
110
+ // Data Loading
111
+ // =============================================
112
+
113
+ /**
114
+ * Fetch graph data from API
115
+ */
116
+ async fetchData(): Promise<GraphInput> {
117
+ try {
118
+ this.graphData = await API.getGraph();
119
+ logger.info('Graph data loaded:', this.graphData.meta);
120
+ return this.graphData;
121
+ } catch (error) {
122
+ logger.error('Failed to fetch graph:', error);
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ // =============================================
128
+ // Graph Initialization
129
+ // =============================================
130
+
131
+ /**
132
+ * Initialize vis-network
133
+ */
134
+ init(data: GraphInput): void {
135
+ const container = getElementByIdOrNull<HTMLDivElement>('graph-canvas');
136
+ if (!container) {
137
+ logger.error('graph-canvas element not found');
138
+ return;
139
+ }
140
+
141
+ // Ensure container has dimensions for vis-network
142
+ if (container.offsetHeight === 0) {
143
+ container.style.minHeight = '400px';
144
+ }
145
+
146
+ logger.debug('Graph canvas dimensions:', container.offsetWidth, 'x', container.offsetHeight);
147
+
148
+ this.graphData = data;
149
+
150
+ // Build adjacency list for BFS
151
+ this.buildAdjacencyList(data.edges);
152
+
153
+ // Calculate connection counts for sizing
154
+ const connectionCounts = this.calculateConnectionCounts(data.nodes, data.edges);
155
+
156
+ // Map nodes to vis-network format
157
+ const nodes = data.nodes.map((n) => ({
158
+ id: n.id,
159
+ label: n.topic || String(n.id).substring(0, 20),
160
+ title: this.createNodeTooltip(n),
161
+ color: {
162
+ background: this.getTopicColor(n.topic),
163
+ border: this.getOutcomeBorderColor(n.outcome),
164
+ highlight: { background: this.getTopicColor(n.topic), border: '#fff' },
165
+ },
166
+ size: this.getNodeSize(connectionCounts[String(n.id)] || 0),
167
+ font: { color: '#131313', size: 12 },
168
+ borderWidth: 3,
169
+ data: n,
170
+ }));
171
+
172
+ // Map edges to vis-network format
173
+ const edges = data.edges.map((e) => {
174
+ const style = this.getEdgeStyle(e.relationship);
175
+ return {
176
+ from: e.from,
177
+ to: e.to,
178
+ arrows: { to: { enabled: true, scaleFactor: 0.5 } },
179
+ color: style.color,
180
+ dashes: style.dashes,
181
+ width: style.width || 2,
182
+ title: e.relationship,
183
+ };
184
+ });
185
+
186
+ const networkData = {
187
+ nodes: new vis.DataSet(nodes),
188
+ edges: new vis.DataSet(edges),
189
+ };
190
+
191
+ const options = {
192
+ nodes: {
193
+ shape: 'dot',
194
+ scaling: { min: 10, max: 30 },
195
+ },
196
+ edges: {
197
+ smooth: { type: 'continuous', roundness: 0.5 },
198
+ width: 2,
199
+ },
200
+ physics: {
201
+ enabled: true,
202
+ barnesHut: {
203
+ gravitationalConstant: -8000,
204
+ centralGravity: 0.3,
205
+ springLength: 150,
206
+ springConstant: 0.04,
207
+ damping: 0.09,
208
+ avoidOverlap: 0.5,
209
+ },
210
+ stabilization: {
211
+ enabled: true,
212
+ iterations: 200,
213
+ updateInterval: 50,
214
+ },
215
+ },
216
+ interaction: {
217
+ hover: true,
218
+ tooltipDelay: 200,
219
+ zoomView: true,
220
+ dragView: true,
221
+ },
222
+ };
223
+
224
+ this.network = new vis.Network(container, networkData, options);
225
+
226
+ // Event handlers
227
+ this.network.on('click', (params: { nodes: Array<string | number> }) => {
228
+ try {
229
+ if (params.nodes.length > 0) {
230
+ const nodeId = params.nodes[0];
231
+ const targetId = String(nodeId);
232
+ const node = data.nodes.find((n) => String(n.id) === targetId);
233
+ if (node) {
234
+ logger.debug('Node clicked:', nodeId, node);
235
+ this.showDetail(node);
236
+ this.highlightConnectedNodes(targetId);
237
+ }
238
+ } else {
239
+ this.closeDetail();
240
+ this.resetNodeHighlight();
241
+ }
242
+ } catch (error) {
243
+ logger.error('Error handling click:', error);
244
+ if (error instanceof Error && error.stack) {
245
+ logger.error('Error stack:', error.stack);
246
+ }
247
+ }
248
+ });
249
+
250
+ this.network.on('stabilized', () => {
251
+ const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
252
+ if (loadingEl) {
253
+ loadingEl.style.display = 'none';
254
+ }
255
+ logger.info('Graph stabilized');
256
+ });
257
+
258
+ // Backup: hide loading after 3 seconds even if stabilization doesn't complete
259
+ setTimeout(() => {
260
+ const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
261
+ if (loadingEl && loadingEl.style.display !== 'none') {
262
+ loadingEl.style.display = 'none';
263
+ logger.warn('Graph loading hidden by timeout');
264
+ }
265
+ }, 3000);
266
+
267
+ // Populate topic filter
268
+ const topics = [...new Set(data.nodes.map((n) => n.topic || ''))].sort();
269
+ this.populateTopicFilter(topics);
270
+
271
+ logger.info('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
272
+ }
273
+
274
+ // =============================================
275
+ // Styling Utilities
276
+ // =============================================
277
+
278
+ /**
279
+ * Get color for topic
280
+ */
281
+ getTopicColor(topic = ''): string {
282
+ if (!this.topicColors[topic]) {
283
+ this.topicColors[topic] = this.colorPalette[this.colorIndex % this.colorPalette.length];
284
+ this.colorIndex++;
285
+ }
286
+ return this.topicColors[topic];
287
+ }
288
+
289
+ /**
290
+ * Get border color based on outcome
291
+ */
292
+ getOutcomeBorderColor(outcome?: string): string {
293
+ switch (outcome?.toLowerCase()) {
294
+ case 'success':
295
+ return '#22c55e';
296
+ case 'failed':
297
+ return '#ef4444';
298
+ case 'partial':
299
+ return '#f59e0b';
300
+ default:
301
+ return '#4a4a6a';
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Get edge style by relationship type
307
+ */
308
+ getEdgeStyle(relationship?: string): EdgeStyle {
309
+ return (
310
+ this.edgeStyles[relationship || 'default'] || { color: '#4a4a6a', dashes: false, width: 2 }
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Get node size based on connection count
316
+ */
317
+ getNodeSize(connectionCount: number): number {
318
+ if (connectionCount <= 2) {
319
+ return 12;
320
+ }
321
+ if (connectionCount <= 5) {
322
+ return 18;
323
+ }
324
+ if (connectionCount <= 10) {
325
+ return 24;
326
+ }
327
+ return 30;
328
+ }
329
+
330
+ /**
331
+ * Create node tooltip
332
+ */
333
+ createNodeTooltip(node: GraphNodeRecord): string {
334
+ return `
335
+ <strong>${escapeHtml(node.topic || 'Unknown')}</strong><br>
336
+ Decision: ${escapeHtml((node.decision || '').substring(0, 100))}...<br>
337
+ Outcome: ${escapeHtml(node.outcome || 'PENDING')}<br>
338
+ Confidence: ${Math.round((node.confidence || 0) * 100)}%
339
+ `;
340
+ }
341
+
342
+ // =============================================
343
+ // Data Processing
344
+ // =============================================
345
+
346
+ /**
347
+ * Build adjacency list for BFS
348
+ */
349
+ buildAdjacencyList(edges: GraphEdgeRecord[]): void {
350
+ this.adjacencyList = new Map();
351
+
352
+ edges.forEach((edge) => {
353
+ const from = String(edge.from);
354
+ const to = String(edge.to);
355
+
356
+ if (!this.adjacencyList.has(from)) {
357
+ this.adjacencyList.set(from, []);
358
+ }
359
+ if (!this.adjacencyList.has(to)) {
360
+ this.adjacencyList.set(to, []);
361
+ }
362
+ this.adjacencyList.get(from).push(to);
363
+ this.adjacencyList.get(to).push(from);
364
+ });
365
+
366
+ logger.debug('Adjacency list built with', this.adjacencyList.size, 'nodes');
367
+ }
368
+
369
+ /**
370
+ * Calculate connection count for each node
371
+ */
372
+ calculateConnectionCounts(
373
+ nodes: GraphNodeRecord[],
374
+ edges: GraphEdgeRecord[]
375
+ ): Record<string, number> {
376
+ const counts: Record<string, number> = {};
377
+ nodes.forEach((n) => {
378
+ counts[String(n.id)] = 0;
379
+ });
380
+
381
+ edges.forEach((edge) => {
382
+ const from = String(edge.from);
383
+ const to = String(edge.to);
384
+ if (counts[from] !== undefined) {
385
+ counts[from]++;
386
+ }
387
+ if (counts[to] !== undefined) {
388
+ counts[to]++;
389
+ }
390
+ });
391
+
392
+ return counts;
393
+ }
394
+
395
+ // =============================================
396
+ // Graph Traversal & Highlighting
397
+ // =============================================
398
+
399
+ /**
400
+ * Get connected node IDs using BFS
401
+ */
402
+ getConnectedNodeIds(nodeId: string, maxDepth = 3): string[] {
403
+ const visited = new Set<string>();
404
+ const queue: { id: string; depth: number }[] = [{ id: nodeId, depth: 0 }];
405
+ visited.add(nodeId);
406
+
407
+ while (queue.length > 0) {
408
+ const next = queue.shift();
409
+ if (!next) {
410
+ continue;
411
+ }
412
+ const { id, depth } = next;
413
+
414
+ if (depth >= maxDepth) {
415
+ continue;
416
+ }
417
+
418
+ const neighbors = this.adjacencyList.get(id) || [];
419
+ neighbors.forEach((neighborId) => {
420
+ if (!visited.has(neighborId)) {
421
+ visited.add(neighborId);
422
+ queue.push({ id: neighborId, depth: depth + 1 });
423
+ }
424
+ });
425
+ }
426
+
427
+ return Array.from(visited);
428
+ }
429
+
430
+ /**
431
+ * Highlight connected nodes
432
+ */
433
+ highlightConnectedNodes(nodeId: string | number): void {
434
+ const targetId = String(nodeId);
435
+ if (!this.network) {
436
+ return;
437
+ }
438
+
439
+ const connectedIds = this.getConnectedNodeIds(targetId, 3);
440
+ const allNodes = this.network.body.data.nodes.get();
441
+
442
+ allNodes.forEach((node) => {
443
+ const isConnected = connectedIds.includes(String(node.id));
444
+ const opacity = isConnected ? 1.0 : 0.2;
445
+
446
+ this.network.body.data.nodes.update({
447
+ id: node.id,
448
+ opacity: opacity,
449
+ font: { ...node.font, color: isConnected ? '#131313' : '#999' },
450
+ });
451
+ });
452
+
453
+ const allEdges = this.network.body.data.edges.get();
454
+ allEdges.forEach((edge) => {
455
+ const isConnected =
456
+ connectedIds.includes(String(edge.from)) && connectedIds.includes(String(edge.to));
457
+ this.network.body.data.edges.update({
458
+ id: edge.id,
459
+ opacity: isConnected ? 1.0 : 0.1,
460
+ });
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Reset node highlight
466
+ */
467
+ resetNodeHighlight(): void {
468
+ if (!this.network) {
469
+ return;
470
+ }
471
+
472
+ const allNodes = this.network.body.data.nodes.get();
473
+ allNodes.forEach((node) => {
474
+ this.network.body.data.nodes.update({
475
+ id: node.id,
476
+ opacity: 1.0,
477
+ font: { ...node.font, color: '#131313' },
478
+ });
479
+ });
480
+
481
+ const allEdges = this.network.body.data.edges.get();
482
+ allEdges.forEach((edge) => {
483
+ this.network.body.data.edges.update({
484
+ id: edge.id,
485
+ opacity: 1.0,
486
+ });
487
+ });
488
+ }
489
+
490
+ // =============================================
491
+ // Detail Panel
492
+ // =============================================
493
+
494
+ /**
495
+ * Show node detail panel
496
+ */
497
+ async showDetail(node: GraphNodeRecord): Promise<void> {
498
+ try {
499
+ logger.debug('showDetail called with node:', node);
500
+ this.currentNodeId = String(node.id);
501
+ const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
502
+
503
+ if (!panel) {
504
+ logger.error('decision-detail-modal element not found');
505
+ return;
506
+ }
507
+
508
+ // Update existing DOM elements with markdown rendering
509
+ const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
510
+ const decisionEl = getElementByIdOrNull<HTMLElement>('detail-decision');
511
+ const reasoningEl = getElementByIdOrNull<HTMLElement>('detail-reasoning');
512
+ if (!decisionEl || !reasoningEl) {
513
+ logger.error('Required detail elements missing');
514
+ return;
515
+ }
516
+ if (topicEl) {
517
+ topicEl.textContent = node.topic || 'Unknown Topic';
518
+ }
519
+
520
+ decisionEl.innerHTML = renderSafeMarkdown(node.decision || '-');
521
+ reasoningEl.innerHTML = renderSafeMarkdown(node.reasoning || '-');
522
+
523
+ const outcomeSelect = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
524
+ if (outcomeSelect) {
525
+ outcomeSelect.value = (node.outcome || 'PENDING').toUpperCase();
526
+ }
527
+
528
+ // Clear outcome status
529
+ const outcomeStatus = getElementByIdOrNull<HTMLElement>('outcome-status');
530
+ if (outcomeStatus) {
531
+ outcomeStatus.textContent = '';
532
+ outcomeStatus.className = '';
533
+ }
534
+
535
+ const confidenceEl = getElementByIdOrNull<HTMLElement>('detail-confidence');
536
+ if (confidenceEl) {
537
+ confidenceEl.textContent = node.confidence ? `${(node.confidence * 100).toFixed(0)}%` : '-';
538
+ }
539
+
540
+ const createdEl = getElementByIdOrNull<HTMLElement>('detail-created');
541
+ if (createdEl) {
542
+ createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
543
+ }
544
+
545
+ // Reset reasoning toggle
546
+ const reasoningArrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
547
+ if (reasoningArrow) {
548
+ reasoningArrow.textContent = '▶';
549
+ }
550
+ reasoningEl.classList.add('hidden');
551
+
552
+ // Show loading state for similar decisions
553
+ const similarEl = getElementByIdOrNull<HTMLElement>('detail-similar');
554
+ if (similarEl) {
555
+ similarEl.innerHTML = '<span class="loading-similar">Searching...</span>';
556
+ }
557
+
558
+ // Show panel
559
+ panel.classList.add('visible');
560
+
561
+ // Fetch similar decisions
562
+ logger.info('Fetching similar decisions...');
563
+ await this.fetchSimilarDecisions(String(node.id));
564
+ logger.debug('showDetail completed successfully');
565
+ } catch (error) {
566
+ logger.error('Error in showDetail:', error);
567
+ if (error instanceof Error && error.stack) {
568
+ logger.error('Error stack:', error.stack);
569
+ }
570
+ logger.error('Node data:', node);
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Get outcome icon name
576
+ */
577
+ getOutcomeIcon(outcome?: string): string {
578
+ const outcomeMap = {
579
+ pending: 'clock',
580
+ success: 'check-circle',
581
+ failed: 'x-circle',
582
+ partial: 'alert-circle',
583
+ };
584
+ return outcomeMap[(outcome || 'pending').toLowerCase()] || 'clock';
585
+ }
586
+
587
+ /**
588
+ * Toggle reasoning full text
589
+ */
590
+ toggleReasoning(): void {
591
+ const arrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
592
+ const content = getElementByIdOrNull<HTMLElement>('detail-reasoning');
593
+ if (arrow && content) {
594
+ const isHidden = content.classList.contains('hidden');
595
+ content.classList.toggle('hidden');
596
+ arrow.textContent = isHidden ? '▼' : '▶';
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Close detail panel
602
+ */
603
+ closeDetail(): void {
604
+ const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
605
+ if (panel) {
606
+ panel.classList.remove('visible');
607
+ }
608
+ this.currentNodeId = null;
609
+ this.resetNodeHighlight();
610
+ }
611
+
612
+ /**
613
+ * Fetch similar decisions
614
+ */
615
+ async fetchSimilarDecisions(nodeId: string): Promise<void> {
616
+ logger.debug('fetchSimilarDecisions called for node:', nodeId);
617
+ const container = getElementByIdOrNull<HTMLElement>('detail-similar');
618
+
619
+ if (!container) {
620
+ logger.warn('detail-similar element not found');
621
+ return;
622
+ }
623
+
624
+ try {
625
+ logger.info('Calling API.getSimilarDecisions...');
626
+ const data = await API.getSimilarDecisions(nodeId);
627
+ logger.debug('Similar decisions received:', data);
628
+
629
+ const similar = (data.similar || []) as SimilarDecision[];
630
+
631
+ if (similar.length === 0) {
632
+ logger.debug('[MAMA] No similar decisions found');
633
+ container.innerHTML = '<span style="color:#666">No similar decisions found</span>';
634
+ return;
635
+ }
636
+
637
+ logger.debug('[MAMA] Building similar decisions HTML for', similar.length, 'items');
638
+ const html = similar
639
+ .map(
640
+ (s) => `
641
+ <button class="similar-decision-btn w-full text-left p-2 mb-2 bg-gray-100 dark:bg-gray-800 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors" data-node-id="${escapeAttr(String(s.id))}">
642
+ <div class="text-xs font-semibold text-indigo-600 dark:text-indigo-400">${escapeHtml(
643
+ String(s.topic || '')
644
+ )}</div>
645
+ <div class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">${escapeHtml(
646
+ String(s.decision || '').substring(0, 80)
647
+ )}...</div>
648
+ <div class="text-xs text-gray-500 mt-1">${Math.round((s.similarity || 0) * 100)}% match</div>
649
+ </button>
650
+ `
651
+ )
652
+ .join('');
653
+
654
+ logger.debug('[MAMA] Setting similar decisions HTML');
655
+ container.innerHTML = html;
656
+
657
+ // Bind click handler once to avoid duplicate listeners.
658
+ container.removeEventListener('click', this.similarDecisionClickHandler);
659
+ container.addEventListener('click', this.similarDecisionClickHandler);
660
+ logger.debug('[MAMA] fetchSimilarDecisions completed');
661
+ } catch (error) {
662
+ const message = getErrorMessage(error);
663
+ logger.error('[MAMA] Failed to fetch similar decisions:', message, error);
664
+ if (error instanceof Error && error.stack) {
665
+ logger.error('Error stack:', error.stack);
666
+ }
667
+ container.innerHTML = '<span style="color:#f66">Failed to load</span>';
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Save outcome for current node
673
+ */
674
+ async saveOutcome(): Promise<void> {
675
+ const select = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
676
+ if (!select) {
677
+ return;
678
+ }
679
+ const newOutcome = select.value;
680
+
681
+ if (!this.currentNodeId || !newOutcome) {
682
+ return;
683
+ }
684
+
685
+ try {
686
+ await API.updateOutcome(this.currentNodeId, newOutcome);
687
+
688
+ // Update local data
689
+ const node = this.graphData.nodes.find((n) => String(n.id) === this.currentNodeId);
690
+ if (node) {
691
+ node.outcome = newOutcome.toUpperCase();
692
+
693
+ // Update visualization
694
+ this.network.body.data.nodes.update({
695
+ id: this.currentNodeId,
696
+ color: {
697
+ border: this.getOutcomeBorderColor(newOutcome),
698
+ },
699
+ });
700
+
701
+ // Refresh detail panel
702
+ this.showDetail(node);
703
+ }
704
+
705
+ logger.debug('[MAMA] Outcome updated:', this.currentNodeId, newOutcome);
706
+ } catch (error) {
707
+ const message = getErrorMessage(error);
708
+ logger.error('[MAMA] Failed to update outcome:', message);
709
+ alert(`Failed to update outcome: ${message}`);
710
+ }
711
+ }
712
+
713
+ // =============================================
714
+ // Navigation
715
+ // =============================================
716
+
717
+ /**
718
+ * Navigate to specific node
719
+ */
720
+ async navigateToNode(nodeId: string | number): Promise<void> {
721
+ if (!this.network) {
722
+ return;
723
+ }
724
+
725
+ let nodeIdString = String(nodeId);
726
+
727
+ // Try exact match first
728
+ let node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
729
+
730
+ // If not found, try partial match (for short IDs from checkpoints)
731
+ if (!node) {
732
+ logger.debug('[MAMA] Exact match not found, trying partial match for:', nodeIdString);
733
+ node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
734
+
735
+ if (node) {
736
+ logger.debug('[MAMA] Found node via partial match:', node.id);
737
+ nodeIdString = String(node.id); // Update to the full ID
738
+ }
739
+ }
740
+
741
+ // If node not in current graph, reload without filters
742
+ if (!node) {
743
+ logger.warn('[MAMA] Node not in current graph, reloading all nodes...');
744
+
745
+ // Reset topic filter
746
+ const topicFilter = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
747
+ if (topicFilter) {
748
+ topicFilter.value = '';
749
+ }
750
+
751
+ // Reload graph: fetch fresh data and reinitialize
752
+ await this.fetchData();
753
+ this.init(this.graphData);
754
+
755
+ // Try exact match again
756
+ node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
757
+
758
+ // If still not found, try partial match
759
+ if (!node) {
760
+ node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
761
+ if (node) {
762
+ logger.debug('[MAMA] Found node via partial match after reload:', node.id);
763
+ nodeIdString = String(node.id);
764
+ }
765
+ }
766
+
767
+ if (!node) {
768
+ logger.warn('[MAMA] Node not found even after reload:', nodeIdString);
769
+ showToast('⚠️ Decision not found in graph');
770
+ return;
771
+ }
772
+ }
773
+
774
+ // Focus on node (use resolved nodeIdString, not original nodeId)
775
+ this.network.focus(nodeIdString, {
776
+ scale: 1.5,
777
+ animation: { duration: 500, easingFunction: 'easeInOutQuad' },
778
+ });
779
+
780
+ // Select node (triggers click event)
781
+ this.network.selectNodes([nodeIdString]);
782
+
783
+ // Show detail
784
+ this.showDetail(node);
785
+ this.highlightConnectedNodes(nodeIdString);
786
+ }
787
+
788
+ /**
789
+ * Get connected edge types
790
+ */
791
+ getConnectedEdges(nodeId: string): ConnectedEdges {
792
+ const edges = this.graphData.edges.filter(
793
+ (e) => String(e.from) === nodeId || String(e.to) === nodeId
794
+ );
795
+
796
+ const outgoing = edges.filter((e) => String(e.from) === nodeId);
797
+ const incoming = edges.filter((e) => String(e.to) === nodeId);
798
+
799
+ return { outgoing, incoming, all: edges };
800
+ }
801
+
802
+ // =============================================
803
+ // Filtering
804
+ // =============================================
805
+
806
+ /**
807
+ * Populate topic filter dropdown
808
+ */
809
+ populateTopicFilter(topics: string[]): void {
810
+ const select = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
811
+ if (!select) {
812
+ return;
813
+ }
814
+
815
+ select.innerHTML = '<option value="">All Topics</option>';
816
+ topics.forEach((topic) => {
817
+ const option = document.createElement('option');
818
+ option.value = topic;
819
+ option.textContent = topic;
820
+ select.appendChild(option);
821
+ });
822
+ }
823
+
824
+ /**
825
+ * Filter by topic
826
+ */
827
+ filterByTopic(topic: string): void {
828
+ if (!this.network) {
829
+ return;
830
+ }
831
+
832
+ const allNodes = this.network.body.data.nodes.get();
833
+
834
+ if (!topic) {
835
+ // Show all
836
+ allNodes.forEach((node) => {
837
+ this.network.body.data.nodes.update({
838
+ id: node.id,
839
+ hidden: false,
840
+ });
841
+ });
842
+ } else {
843
+ // Filter
844
+ allNodes.forEach((node) => {
845
+ const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
846
+ this.network.body.data.nodes.update({
847
+ id: node.id,
848
+ hidden: nodeData?.topic !== topic,
849
+ });
850
+ });
851
+ }
852
+
853
+ logger.debug('[MAMA] Filtered by topic:', topic || 'all');
854
+ }
855
+
856
+ /**
857
+ * Filter by outcome
858
+ */
859
+ filterByOutcome(outcome: string): void {
860
+ if (!this.network) {
861
+ return;
862
+ }
863
+
864
+ const allNodes = this.network.body.data.nodes.get();
865
+
866
+ if (!outcome) {
867
+ // Show all
868
+ allNodes.forEach((node) => {
869
+ this.network.body.data.nodes.update({
870
+ id: node.id,
871
+ hidden: false,
872
+ });
873
+ });
874
+ } else {
875
+ // Filter by outcome
876
+ allNodes.forEach((node) => {
877
+ const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
878
+ const nodeOutcome = (nodeData?.outcome || 'pending').toLowerCase();
879
+ this.network.body.data.nodes.update({
880
+ id: node.id,
881
+ hidden: nodeOutcome !== outcome.toLowerCase(),
882
+ });
883
+ });
884
+ }
885
+
886
+ logger.debug('[MAMA] Filtered by outcome:', outcome || 'all');
887
+ }
888
+
889
+ /**
890
+ * Clear all filters
891
+ */
892
+ clearFilters(): void {
893
+ if (!this.network) {
894
+ return;
895
+ }
896
+
897
+ // Show all nodes
898
+ const allNodes = this.network.body.data.nodes.get();
899
+ allNodes.forEach((node) => {
900
+ this.network.body.data.nodes.update({
901
+ id: node.id,
902
+ hidden: false,
903
+ opacity: 1.0,
904
+ font: { ...node.font, color: '#131313' },
905
+ });
906
+ });
907
+
908
+ // Show all edges
909
+ const allEdges = this.network.body.data.edges.get();
910
+ allEdges.forEach((edge) => {
911
+ this.network.body.data.edges.update({
912
+ id: edge.id,
913
+ opacity: 1.0,
914
+ });
915
+ });
916
+
917
+ // Clear search state
918
+ this.searchMatches = [];
919
+ this.currentSearchIndex = 0;
920
+
921
+ const countEl = getElementByIdOrNull<HTMLElement>('search-count');
922
+ if (countEl) {
923
+ countEl.style.display = 'none';
924
+ }
925
+
926
+ logger.debug('[MAMA] All filters cleared');
927
+ }
928
+
929
+ // =============================================
930
+ // Search
931
+ // =============================================
932
+
933
+ /**
934
+ * Perform search
935
+ */
936
+ search(): void {
937
+ const queryInput = getElementByIdOrNull<HTMLInputElement>('search-input');
938
+ const query = queryInput ? queryInput.value.trim().toLowerCase() : '';
939
+
940
+ if (!query) {
941
+ this.clearSearch();
942
+ return;
943
+ }
944
+
945
+ // Search in topic, decision, and reasoning
946
+ this.searchMatches = this.graphData.nodes.filter(
947
+ (node) =>
948
+ (node.topic || '').toLowerCase().includes(query) ||
949
+ (node.decision || '').toLowerCase().includes(query) ||
950
+ (node.reasoning || '').toLowerCase().includes(query)
951
+ );
952
+
953
+ this.currentSearchIndex = 0;
954
+ this.updateSearchResults();
955
+
956
+ if (this.searchMatches.length > 0) {
957
+ this.highlightSearchResults();
958
+ this.navigateToNode(String(this.searchMatches[0].id));
959
+ }
960
+
961
+ logger.debug('[MAMA] Search:', query, '- Found', this.searchMatches.length, 'matches');
962
+ }
963
+
964
+ /**
965
+ * Handle search input
966
+ */
967
+ handleSearchInput(event: KeyboardEvent): void {
968
+ if (event.key === 'Enter' && this.searchMatches.length > 0) {
969
+ this.nextSearchResult();
970
+ } else {
971
+ this.debouncedSearch();
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Navigate to next search result
977
+ */
978
+ nextSearchResult(): void {
979
+ if (this.searchMatches.length === 0) {
980
+ return;
981
+ }
982
+
983
+ this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchMatches.length;
984
+ this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
985
+ this.updateSearchResults();
986
+ }
987
+
988
+ /**
989
+ * Navigate to previous search result
990
+ */
991
+ prevSearchResult(): void {
992
+ if (this.searchMatches.length === 0) {
993
+ return;
994
+ }
995
+
996
+ this.currentSearchIndex =
997
+ (this.currentSearchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
998
+ this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
999
+ this.updateSearchResults();
1000
+ }
1001
+
1002
+ /**
1003
+ * Update search count display
1004
+ */
1005
+ updateSearchResults(): void {
1006
+ const countEl = getElementByIdOrNull<HTMLElement>('search-count');
1007
+ if (!countEl) {
1008
+ return;
1009
+ }
1010
+
1011
+ if (this.searchMatches.length > 0) {
1012
+ countEl.textContent = `${this.currentSearchIndex + 1} / ${this.searchMatches.length}`;
1013
+ countEl.style.display = 'inline';
1014
+ } else {
1015
+ countEl.textContent = 'No results';
1016
+ countEl.style.display = 'inline';
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Highlight search results
1022
+ */
1023
+ highlightSearchResults(): void {
1024
+ if (!this.network) {
1025
+ return;
1026
+ }
1027
+
1028
+ const matchIds = this.searchMatches.map((n) => n.id);
1029
+ const allNodes = this.network.body.data.nodes.get();
1030
+
1031
+ allNodes.forEach((node) => {
1032
+ const isMatch = matchIds.includes(String(node.id));
1033
+ this.network.body.data.nodes.update({
1034
+ id: node.id,
1035
+ opacity: isMatch ? 1.0 : 0.2,
1036
+ font: { ...node.font, color: isMatch ? '#131313' : '#999' },
1037
+ });
1038
+ });
1039
+ }
1040
+
1041
+ /**
1042
+ * Clear search
1043
+ */
1044
+ clearSearch(): void {
1045
+ this.searchMatches = [];
1046
+ this.currentSearchIndex = 0;
1047
+ this.resetNodeHighlight();
1048
+
1049
+ const countEl = getElementByIdOrNull<HTMLElement>('search-count');
1050
+ if (countEl) {
1051
+ countEl.style.display = 'none';
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Open search panel
1057
+ */
1058
+ openSearch(): void {
1059
+ const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
1060
+ const searchInput = getElementByIdOrNull<HTMLInputElement>('search-input');
1061
+
1062
+ if (!searchContainer || !searchInput) {
1063
+ return;
1064
+ }
1065
+ searchContainer.style.display = 'flex';
1066
+ searchInput.focus();
1067
+ }
1068
+
1069
+ /**
1070
+ * Close search panel
1071
+ */
1072
+ closeSearch(): void {
1073
+ const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
1074
+ if (!searchContainer) {
1075
+ return;
1076
+ }
1077
+ searchContainer.style.display = 'none';
1078
+ this.clearSearch();
1079
+ }
1080
+ }