@jungjaehoon/mama-os 0.62.0 → 0.63.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.
@@ -1,43 +1,24 @@
1
1
  /**
2
- * Graph Module - Decision Graph Visualization
2
+ * Graph Module - one memory record and what it links to
3
3
  * @module modules/graph
4
- * @version 1.0.0
5
4
  *
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
5
+ * The memory view picks a record from the saved timeline. This draws that record with its direct
6
+ * links (the source messages it rests on, what it builds on, what it replaced), names every colour
7
+ * and line in plain words, and shows the detail of whichever dot is clicked.
12
8
  */
13
9
 
14
10
  /* eslint-env browser */
15
11
  /* global vis */
16
12
 
17
- import {
18
- escapeHtml,
19
- escapeAttr,
20
- debounce,
21
- showToast,
22
- getElementByIdOrNull,
23
- getErrorMessage,
24
- } from '../utils/dom.js';
13
+ import { escapeHtml, getElementByIdOrNull } from '../utils/dom.js';
25
14
  import { DebugLogger } from '../utils/debug-logger.js';
26
- import {
27
- API,
28
- type GraphNode,
29
- type GraphEdge,
30
- type SimilarDecision,
31
- type QueryParams,
32
- } from '../utils/api.js';
15
+ import { API, type GraphNode, type GraphEdge } from '../utils/api.js';
33
16
  import { renderSafeMarkdown } from '../utils/markdown.js';
34
17
 
35
18
  type GraphNodeRecord = GraphNode & {
36
19
  topic?: string;
37
- outcome?: string;
38
20
  decision?: string;
39
21
  reasoning?: string;
40
- confidence?: number;
41
22
  created_at?: string | number;
42
23
  };
43
24
 
@@ -52,12 +33,6 @@ type EdgeStyle = {
52
33
  width: number;
53
34
  };
54
35
 
55
- type ConnectedEdges = {
56
- outgoing: GraphEdgeRecord[];
57
- incoming: GraphEdgeRecord[];
58
- all: GraphEdgeRecord[];
59
- };
60
-
61
36
  type GraphInput = {
62
37
  nodes: GraphNodeRecord[];
63
38
  edges: GraphEdgeRecord[];
@@ -65,7 +40,44 @@ type GraphInput = {
65
40
  };
66
41
 
67
42
  const logger = new DebugLogger('Graph');
68
- const INITIAL_GRAPH_LIMIT = 2000;
43
+
44
+ /** What each dot colour stands for, in the words the legend shows. */
45
+ export const KIND_LABELS: Record<string, string> = {
46
+ decision: 'Decision',
47
+ preference: 'Preference',
48
+ constraint: 'Constraint',
49
+ lesson: 'Lesson',
50
+ workflow: 'Workflow',
51
+ fact: 'Fact',
52
+ commitment: 'Work update',
53
+ observation: 'Source message',
54
+ memory: 'Memory without a kind',
55
+ registry: 'Registered name',
56
+ };
57
+
58
+ /** Every relation the engine stores, read from the record that holds the link. */
59
+ export const RELATION_LABELS: Record<string, string> = {
60
+ derived_from: 'rests on (evidence)',
61
+ mentions: 'mentions',
62
+ builds_on: 'builds on',
63
+ amends: 'amends',
64
+ refines: 'refines',
65
+ supersedes: 'replaces',
66
+ contradicts: 'contradicts',
67
+ debates: 'argues with',
68
+ synthesizes: 'combines',
69
+ case_member: 'belongs to case',
70
+ alias_of: 'another name for',
71
+ next_action_for: 'next step for',
72
+ blocks: 'blocks',
73
+ };
74
+
75
+ const LABEL_LENGTH = 40;
76
+
77
+ function shortLabel(text: string): string {
78
+ const flat = text.replace(/\s+/g, ' ').trim();
79
+ return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH - 1)}…` : flat;
80
+ }
69
81
 
70
82
  /**
71
83
  * Graph Module Class
@@ -73,24 +85,16 @@ const INITIAL_GRAPH_LIMIT = 2000;
73
85
  export class GraphModule {
74
86
  network: VisNetwork | null = null;
75
87
  graphData: GraphInput = { nodes: [], edges: [], meta: {} };
76
- currentNodeId: string | null = null;
77
- adjacencyList: Map<string, string[]> = new Map();
78
- searchMatches: GraphNodeRecord[] = [];
79
- currentSearchIndex = 0;
80
- debouncedSearch = debounce(() => this.search(), 300);
81
- private similarDecisionClickHandler = (event: Event): void => {
82
- const target = event.target as HTMLElement;
83
- const btn = target.closest('.similar-decision-btn') as HTMLElement | null;
84
- if (!btn || !btn.dataset.nodeId) {
85
- return;
86
- }
87
- this.navigateToNode(btn.dataset.nodeId);
88
- };
88
+ centerId: string | null = null;
89
+ // A slower earlier read must not draw or describe a record the owner has moved away from.
90
+ private recordRequest = 0;
91
+ private detailRequest = 0;
89
92
  nodeColors: Record<string, string> = {
90
93
  decision: '#60A5FA',
91
94
  preference: '#C084FC',
92
95
  constraint: '#FB7185',
93
96
  lesson: '#FBBF24',
97
+ workflow: '#F472B6',
94
98
  fact: '#2DD4BF',
95
99
  commitment: '#FB923C',
96
100
  observation: '#94A3B8',
@@ -103,39 +107,35 @@ export class GraphModule {
103
107
  derived_from: { color: '#3A9E7E', dashes: false, width: 2 },
104
108
  amends: { color: '#2563EB', dashes: [8, 4], width: 2 },
105
109
  refines: { color: '#B8860B', dashes: false, width: 2 },
106
- builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 }, // dark goldenrod
107
- debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 }, // crimson
108
- synthesizes: { color: '#6B4C9A', width: 3, dashes: false }, // dark purple
110
+ builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
111
+ contradicts: { color: '#DC143C', dashes: false, width: 2.5 },
112
+ debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 },
113
+ synthesizes: { color: '#6B4C9A', width: 3, dashes: false },
109
114
  };
110
115
 
111
- constructor() {
112
- // Network state
113
- }
114
-
115
- // =============================================
116
- // Data Loading
117
- // =============================================
118
-
119
116
  /**
120
- * Fetch graph data from API
117
+ * Draw a record and its direct links, then show its detail.
121
118
  */
122
- async fetchData(params: QueryParams = { limit: INITIAL_GRAPH_LIMIT }): Promise<GraphInput> {
119
+ async showRecord(id: string): Promise<void> {
120
+ const request = ++this.recordRequest;
121
+ const empty = getElementByIdOrNull<HTMLElement>('graph-empty');
122
+ const loading = getElementByIdOrNull<HTMLElement>('graph-loading');
123
+ if (empty) empty.style.display = 'none';
124
+ if (loading) loading.style.display = 'flex';
123
125
  try {
124
- this.graphData = await API.getGraph(params);
125
- logger.info('Graph data loaded:', this.graphData.meta);
126
- return this.graphData;
127
- } catch (error) {
128
- logger.error('Failed to fetch graph:', error);
129
- throw error;
126
+ const data = (await API.getGraphNeighbors(id)) as GraphInput;
127
+ if (request !== this.recordRequest) return;
128
+ this.centerId = id;
129
+ this.init(data);
130
+ const center = data.nodes.find((node) => String(node.id) === id);
131
+ if (center) void this.showDetail(center);
132
+ } finally {
133
+ if (loading && request === this.recordRequest) loading.style.display = 'none';
130
134
  }
131
135
  }
132
136
 
133
- // =============================================
134
- // Graph Initialization
135
- // =============================================
136
-
137
137
  /**
138
- * Initialize vis-network
138
+ * Initialize vis-network over one record's neighbourhood.
139
139
  */
140
140
  init(data: GraphInput): void {
141
141
  const container = getElementByIdOrNull<HTMLDivElement>('graph-canvas');
@@ -143,40 +143,33 @@ export class GraphModule {
143
143
  logger.error('graph-canvas element not found');
144
144
  return;
145
145
  }
146
-
147
- // Ensure container has dimensions for vis-network
148
- if (container.offsetHeight === 0) {
149
- container.style.minHeight = '400px';
150
- }
151
-
152
- logger.debug('Graph canvas dimensions:', container.offsetWidth, 'x', container.offsetHeight);
153
-
154
146
  this.graphData = data;
147
+ this.network?.destroy?.();
155
148
 
156
- // Build adjacency list for BFS
157
- this.buildAdjacencyList(data.edges);
158
-
159
- // Calculate connection counts for sizing
160
- const connectionCounts = this.calculateConnectionCounts(data.nodes, data.edges);
161
-
162
- // Map nodes to vis-network format
163
- const nodes = data.nodes.map((n) => ({
164
- id: n.id,
165
- label: n.label || n.topic || String(n.id).substring(0, 20),
166
- title: this.createNodeTooltip(n),
167
- color: {
168
- background: this.getNodeColor(n.kind),
169
- // Only a recorded outcome draws a ring; otherwise the kind colour stays visible.
170
- border: this.getOutcomeBorderColor(n.outcome, this.getNodeColor(n.kind)),
171
- highlight: { background: this.getNodeColor(n.kind), border: '#fff' },
172
- },
173
- size: this.getNodeSize(connectionCounts[String(n.id)] || 0),
174
- font: { color: '#131313', size: 14, vadjust: -2 },
175
- borderWidth: n.outcome ? 3 : 1,
176
- data: n,
177
- }));
149
+ const nodes = data.nodes.map((n) => {
150
+ const kind = n.kind ?? 'memory';
151
+ const center = String(n.id) === this.centerId;
152
+ const color = this.getNodeColor(kind);
153
+ return {
154
+ id: n.id,
155
+ // Source messages stay unlabelled dots: their time and text are on hover and click.
156
+ label:
157
+ kind === 'observation' && !center
158
+ ? undefined
159
+ : shortLabel(String(n.decision_preview ?? n.decision ?? n.topic ?? n.id)),
160
+ title: this.createNodeTooltip(n),
161
+ color: {
162
+ background: color,
163
+ border: center ? '#131313' : color,
164
+ highlight: { background: color, border: '#131313' },
165
+ },
166
+ size: center ? 22 : kind === 'observation' ? 9 : 14,
167
+ borderWidth: center ? 3 : 1,
168
+ font: { color: '#131313', size: center ? 15 : 13 },
169
+ data: n,
170
+ };
171
+ });
178
172
 
179
- // Map edges to vis-network format
180
173
  const edges = data.edges.map((e) => {
181
174
  const style = this.getEdgeStyle(e.relationship);
182
175
  return {
@@ -185,190 +178,108 @@ export class GraphModule {
185
178
  arrows: { to: { enabled: true, scaleFactor: 0.5 } },
186
179
  color: style.color,
187
180
  dashes: style.dashes,
188
- width: style.width || 2,
189
- title: e.relationship,
181
+ width: style.width,
182
+ title: this.relationLabel(e.relationship),
190
183
  };
191
184
  });
192
185
 
193
- const networkData = {
194
- nodes: new vis.DataSet(nodes),
195
- edges: new vis.DataSet(edges),
196
- };
197
-
198
- const options = {
199
- nodes: {
200
- shape: 'dot',
201
- scaling: {
202
- min: 10,
203
- max: 30,
204
- label: { enabled: true, min: 12, max: 22, maxVisible: 22, drawThreshold: 8 },
205
- },
206
- },
207
- edges: {
208
- smooth: { type: 'continuous', roundness: 0.5 },
209
- width: 2,
210
- },
211
- physics: {
212
- // The initial layout always runs; the stabilized handler turns physics off afterwards.
213
- enabled: true,
214
- barnesHut: {
215
- gravitationalConstant: -8000,
216
- centralGravity: 0.3,
217
- springLength: 150,
218
- springConstant: 0.04,
219
- damping: 0.09,
220
- avoidOverlap: 1,
221
- },
222
- stabilization: {
186
+ this.network = new vis.Network(
187
+ container,
188
+ { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) },
189
+ {
190
+ nodes: { shape: 'dot' },
191
+ edges: { smooth: { type: 'continuous', roundness: 0.5 }, width: 2 },
192
+ physics: {
223
193
  enabled: true,
224
- iterations: data.nodes.length > 500 ? 100 : 200,
225
- updateInterval: 25,
194
+ barnesHut: {
195
+ gravitationalConstant: -6000,
196
+ centralGravity: 0.4,
197
+ springLength: 140,
198
+ springConstant: 0.04,
199
+ damping: 0.09,
200
+ avoidOverlap: 1,
201
+ },
202
+ stabilization: { enabled: true, iterations: 200, updateInterval: 25 },
226
203
  },
227
- },
228
- interaction: {
229
- hover: true,
230
- tooltipDelay: 100,
231
- zoomView: true,
232
- dragView: true,
233
- },
234
- };
235
-
236
- this.network = new vis.Network(container, networkData, options);
237
-
238
- // Event handlers
239
- this.network.on('click', (params: { nodes: Array<string | number> }) => {
240
- try {
241
- if (params.nodes.length > 0) {
242
- const nodeId = params.nodes[0];
243
- const targetId = String(nodeId);
244
- const node = data.nodes.find((n) => String(n.id) === targetId);
245
- if (node) {
246
- logger.debug('Node clicked:', nodeId, node);
247
- this.showDetail(node);
248
- this.highlightConnectedNodes(targetId);
249
- }
250
- } else {
251
- this.closeDetail();
252
- this.resetNodeHighlight();
253
- }
254
- } catch (error) {
255
- logger.error('Error handling click:', error);
256
- if (error instanceof Error && error.stack) {
257
- logger.error('Error stack:', error.stack);
258
- }
204
+ interaction: { hover: true, tooltipDelay: 100, zoomView: true, dragView: true },
259
205
  }
260
- });
206
+ );
261
207
 
262
- // Hover: show label at fixed visual size regardless of zoom (Obsidian style)
263
- this.network.on('hoverNode', (params: { node: string | number }) => {
264
- try {
265
- const scale = this.network?.getScale() || 1;
266
- const targetVisualSize = 16; // px on screen
267
- const fontSize = Math.round(targetVisualSize / scale);
268
- networkData.nodes.update({
269
- id: params.node,
270
- font: { color: '#131313', size: Math.max(fontSize, 14), vadjust: -2 },
271
- });
272
- } catch {
273
- /* ignore */
274
- }
208
+ this.network.on('click', (params: { nodes: Array<string | number> }) => {
209
+ const targetId = params.nodes[0];
210
+ if (targetId === undefined) return;
211
+ const node = data.nodes.find((n) => String(n.id) === String(targetId));
212
+ if (node) void this.showDetail(node);
275
213
  });
276
- this.network.on('blurNode', (params: { node: string | number }) => {
277
- try {
278
- networkData.nodes.update({
279
- id: params.node,
280
- font: { color: '#131313', size: 14, vadjust: -2 },
281
- });
282
- } catch {
283
- /* ignore */
214
+ // Stepping to a linked record redraws the graph around it.
215
+ this.network.on('doubleClick', (params: { nodes: Array<string | number> }) => {
216
+ const targetId = params.nodes[0];
217
+ if (targetId !== undefined && String(targetId) !== this.centerId) {
218
+ void this.showRecord(String(targetId));
284
219
  }
285
220
  });
286
-
287
- // A large graph may never reach vis-network's 'stabilized'; stop when the bounded iterations end.
288
221
  this.network.on('stabilizationIterationsDone', () => {
289
222
  this.network?.setOptions?.({ physics: { enabled: false } });
290
223
  });
291
224
 
292
- this.network.on('stabilized', () => {
293
- const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
294
- if (loadingEl) {
295
- loadingEl.style.display = 'none';
296
- }
297
- // Disable physics after initial layout to free CPU
298
- this.network?.setOptions?.({ physics: { enabled: false } });
299
- logger.info('Graph stabilized, physics disabled');
300
- });
301
-
302
- // Backup: hide loading after 3 seconds even if stabilization doesn't complete
303
- setTimeout(() => {
304
- const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
305
- if (loadingEl && loadingEl.style.display !== 'none') {
306
- loadingEl.style.display = 'none';
307
- logger.warn('Graph loading hidden by timeout');
308
- }
309
- }, 3000);
310
-
311
- // Populate topic filter — sorted by most recent created_at (newest first)
312
- const topicLatest = new Map<string, number>();
313
- for (const n of data.nodes) {
314
- const t = n.topic || '';
315
- const ts = typeof n.created_at === 'number' ? n.created_at : 0;
316
- if (!topicLatest.has(t) || ts > topicLatest.get(t)!) {
317
- topicLatest.set(t, ts);
318
- }
319
- }
320
- const topics = [...topicLatest.entries()].sort((a, b) => b[1] - a[1]).map(([topic]) => topic);
321
- this.populateTopicFilter(topics);
322
- this.populateDecisionList(data.nodes);
323
225
  this.renderLegend();
324
-
226
+ const legend = getElementByIdOrNull<HTMLElement>('legend-panel');
227
+ legend?.classList.add('visible');
325
228
  const stats = getElementByIdOrNull<HTMLElement>('graph-stats');
326
229
  if (stats) {
327
- stats.textContent = `${data.nodes.length} nodes · ${data.edges.length} edges${data.meta?.partial ? ' · partial' : ''}`;
230
+ stats.style.display = '';
231
+ stats.textContent = `${data.nodes.length} records and source messages · ${data.edges.length} links · click a dot for its detail, double-click to step to it`;
328
232
  }
329
-
330
- logger.info('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
331
233
  }
332
234
 
333
235
  // =============================================
334
- // Styling Utilities
236
+ // Styling and legend
335
237
  // =============================================
336
238
 
337
239
  getNodeColor(kind = 'memory'): string {
338
240
  return this.nodeColors[kind] ?? '#CBD5E1';
339
241
  }
340
242
 
341
- /** Count the displayed data, excluding nodes hidden by filters and their edges. */
342
- getLegendEntries() {
343
- const visible = new Set(
344
- (this.network ? this.network.body.data.nodes.get() : this.graphData.nodes)
345
- .filter((node) => !node.hidden)
346
- .map((node) => String(node.id))
243
+ getEdgeStyle(relationship?: string): EdgeStyle {
244
+ return (
245
+ this.edgeStyles[relationship ?? 'default'] ?? { color: '#4a4a6a', dashes: false, width: 2 }
347
246
  );
247
+ }
248
+
249
+ kindLabel(kind: string): string {
250
+ return KIND_LABELS[kind] ?? kind;
251
+ }
252
+
253
+ relationLabel(relationship?: string): string {
254
+ return relationship === undefined ? 'link' : (RELATION_LABELS[relationship] ?? relationship);
255
+ }
256
+
257
+ /** Count what is drawn, by kind and by relationship, with the words the legend shows. */
258
+ getLegendEntries() {
348
259
  const kinds = new Map<string, number>();
349
260
  const relations = new Map<string, number>();
350
261
  for (const node of this.graphData.nodes) {
351
- if (!visible.has(String(node.id))) continue;
352
262
  const kind = node.kind ?? 'memory';
353
263
  kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
354
264
  }
355
265
  for (const edge of this.graphData.edges) {
356
- if (!visible.has(String(edge.from)) || !visible.has(String(edge.to))) continue;
357
266
  const relation = edge.relationship ?? 'default';
358
267
  relations.set(relation, (relations.get(relation) ?? 0) + 1);
359
268
  }
360
269
  return {
361
270
  nodes: [...kinds]
362
- .sort(([a], [b]) => a.localeCompare(b))
271
+ .sort(([, a], [, b]) => b - a)
363
272
  .map(([kind, count]) => ({
364
273
  kind,
274
+ label: this.kindLabel(kind),
365
275
  count,
366
276
  color: this.getNodeColor(kind),
367
277
  })),
368
278
  edges: [...relations]
369
- .sort(([a], [b]) => a.localeCompare(b))
279
+ .sort(([, a], [, b]) => b - a)
370
280
  .map(([relationship, count]) => ({
371
281
  relationship,
282
+ label: this.relationLabel(relationship),
372
283
  count,
373
284
  ...this.getEdgeStyle(relationship),
374
285
  })),
@@ -382,933 +293,75 @@ export class GraphModule {
382
293
  const section = (title: string, rows: string[]) =>
383
294
  rows.length === 0
384
295
  ? ''
385
- : `<div><div class="text-[10px] font-semibold text-gray-600 uppercase tracking-wide mb-1.5">${title}</div>${rows.join('')}</div>`;
296
+ : `<div><div class="legend-heading">${title}</div>${rows.join('')}</div>`;
386
297
  const row = (sample: string, label: string, count: number) =>
387
- `<div class="flex items-center gap-2 text-xs text-gray-700 mb-1">${sample}<span class="flex-1">${escapeHtml(label)}</span><span class="tabular-nums">${count}</span></div>`;
298
+ `<div class="legend-row">${sample}<span class="legend-label">${escapeHtml(label)}</span><span class="legend-count">${count}</span></div>`;
388
299
  container.innerHTML =
389
300
  section(
390
- 'Node kinds',
391
- entries.nodes.map(({ kind, count, color }) =>
392
- row(
393
- `<span class="w-3 h-3 rounded-full shrink-0" style="background:${color}"></span>`,
394
- kind,
395
- count
396
- )
301
+ 'Dots',
302
+ entries.nodes.map(({ label, count, color }) =>
303
+ row(`<span class="legend-dot" style="background:${color}"></span>`, label, count)
397
304
  )
398
305
  ) +
399
306
  section(
400
- 'Relationships',
401
- entries.edges.map(({ relationship, count, color, dashes, width }) => {
307
+ 'Lines (arrow points at what the record links to)',
308
+ entries.edges.map(({ label, count, color, dashes, width }) => {
402
309
  const dash = Array.isArray(dashes) ? dashes.join(' ') : dashes ? '5 5' : 'none';
403
310
  return row(
404
- `<svg width="24" height="10" class="shrink-0" aria-hidden="true"><line x1="0" y1="5" x2="24" y2="5" stroke="${color}" stroke-width="${width}" stroke-dasharray="${dash}" /></svg>`,
405
- relationship,
311
+ `<svg width="28" height="10" class="shrink-0" aria-hidden="true"><line x1="0" y1="5" x2="28" y2="5" stroke="${color}" stroke-width="${width}" stroke-dasharray="${dash}" /></svg>`,
312
+ label,
406
313
  count
407
314
  );
408
315
  })
409
316
  );
410
- if (entries.nodes.length === 0) container.textContent = 'No visible nodes';
411
- }
412
-
413
- /**
414
- * Get border color based on outcome
415
- */
416
- getOutcomeBorderColor(outcome?: string, fallback = '#4a4a6a'): string {
417
- switch (outcome?.toLowerCase()) {
418
- case 'success':
419
- return '#22c55e';
420
- case 'failed':
421
- return '#ef4444';
422
- case 'partial':
423
- return '#f59e0b';
424
- default:
425
- return fallback;
426
- }
427
- }
428
-
429
- /**
430
- * Get edge style by relationship type
431
- */
432
- getEdgeStyle(relationship?: string): EdgeStyle {
433
- return (
434
- this.edgeStyles[relationship || 'default'] || { color: '#4a4a6a', dashes: false, width: 2 }
435
- );
317
+ if (entries.nodes.length === 0) container.textContent = 'Nothing drawn';
436
318
  }
437
319
 
438
- /**
439
- * Get node size based on connection count
440
- */
441
- getNodeSize(connectionCount: number): number {
442
- if (connectionCount <= 2) {
443
- return 12;
444
- }
445
- if (connectionCount <= 5) {
446
- return 18;
447
- }
448
- if (connectionCount <= 10) {
449
- return 24;
450
- }
451
- return 30;
452
- }
453
-
454
- /**
455
- * Create node tooltip
456
- */
457
320
  createNodeTooltip(node: GraphNodeRecord): string {
458
- return `
459
- <strong>${escapeHtml(node.topic || 'Unknown')}</strong><br>
460
- Decision: ${escapeHtml((node.decision || '').substring(0, 100))}...<br>
461
- ${node.outcome ? `Outcome: ${escapeHtml(node.outcome)}<br>` : ''}
462
- Confidence: ${Math.round((node.confidence || 0) * 100)}%
463
- `;
321
+ const kind = this.kindLabel(node.kind ?? 'memory');
322
+ const text = String(node.decision_preview ?? node.decision ?? '');
323
+ return `${kind}${node.topic ? ` · ${node.topic}` : ''}\n${text.slice(0, 160)}`;
464
324
  }
465
325
 
466
326
  // =============================================
467
- // Data Processing
468
- // =============================================
469
-
470
- /**
471
- * Build adjacency list for BFS
472
- */
473
- buildAdjacencyList(edges: GraphEdgeRecord[]): void {
474
- this.adjacencyList = new Map();
475
-
476
- edges.forEach((edge) => {
477
- const from = String(edge.from);
478
- const to = String(edge.to);
479
-
480
- if (!this.adjacencyList.has(from)) {
481
- this.adjacencyList.set(from, []);
482
- }
483
- if (!this.adjacencyList.has(to)) {
484
- this.adjacencyList.set(to, []);
485
- }
486
- this.adjacencyList.get(from).push(to);
487
- this.adjacencyList.get(to).push(from);
488
- });
489
-
490
- logger.debug('Adjacency list built with', this.adjacencyList.size, 'nodes');
491
- }
492
-
493
- /**
494
- * Calculate connection count for each node
495
- */
496
- calculateConnectionCounts(
497
- nodes: GraphNodeRecord[],
498
- edges: GraphEdgeRecord[]
499
- ): Record<string, number> {
500
- const counts: Record<string, number> = {};
501
- nodes.forEach((n) => {
502
- counts[String(n.id)] = 0;
503
- });
504
-
505
- edges.forEach((edge) => {
506
- const from = String(edge.from);
507
- const to = String(edge.to);
508
- if (counts[from] !== undefined) {
509
- counts[from]++;
510
- }
511
- if (counts[to] !== undefined) {
512
- counts[to]++;
513
- }
514
- });
515
-
516
- return counts;
517
- }
518
-
519
- // =============================================
520
- // Graph Traversal & Highlighting
327
+ // Detail Panel
521
328
  // =============================================
522
329
 
523
- /**
524
- * Get connected node IDs using BFS
525
- */
526
- getConnectedNodeIds(nodeId: string, maxDepth = 3): string[] {
527
- const visited = new Set<string>();
528
- const queue: { id: string; depth: number }[] = [{ id: nodeId, depth: 0 }];
529
- visited.add(nodeId);
530
-
531
- while (queue.length > 0) {
532
- const next = queue.shift();
533
- if (!next) {
534
- continue;
535
- }
536
- const { id, depth } = next;
537
-
538
- if (depth >= maxDepth) {
539
- continue;
540
- }
541
-
542
- const neighbors = this.adjacencyList.get(id) || [];
543
- neighbors.forEach((neighborId) => {
544
- if (!visited.has(neighborId)) {
545
- visited.add(neighborId);
546
- queue.push({ id: neighborId, depth: depth + 1 });
547
- }
548
- });
549
- }
550
-
551
- return Array.from(visited);
552
- }
553
-
554
- /**
555
- * Highlight connected nodes
556
- */
557
- highlightConnectedNodes(nodeId: string | number): void {
558
- const targetId = String(nodeId);
559
- if (!this.network) {
330
+ async showDetail(node: GraphNodeRecord): Promise<void> {
331
+ const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
332
+ const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
333
+ const kindEl = getElementByIdOrNull<HTMLElement>('detail-kind');
334
+ const decisionEl = getElementByIdOrNull<HTMLElement>('detail-decision');
335
+ const reasoningEl = getElementByIdOrNull<HTMLElement>('detail-reasoning');
336
+ const createdEl = getElementByIdOrNull<HTMLElement>('detail-created');
337
+ if (!panel || !decisionEl || !reasoningEl) {
338
+ logger.error('Detail panel elements missing');
560
339
  return;
561
340
  }
562
-
563
- const connectedIds = this.getConnectedNodeIds(targetId, 3);
564
- const allNodes = this.network.body.data.nodes.get();
565
- const nodeUpdates = allNodes.map((node) => {
566
- const isConnected = connectedIds.includes(String(node.id));
567
- const opacity = isConnected ? 1.0 : 0.2;
568
- return {
569
- id: node.id,
570
- opacity: opacity,
571
- font: { ...node.font, color: isConnected ? '#131313' : '#999' },
572
- };
573
- });
574
- this.network.body.data.nodes.update(nodeUpdates);
575
-
576
- const allEdges = this.network.body.data.edges.get();
577
- const edgeUpdates = allEdges.map((edge) => {
578
- const isConnected =
579
- connectedIds.includes(String(edge.from)) && connectedIds.includes(String(edge.to));
580
- return {
581
- id: edge.id,
582
- opacity: isConnected ? 1.0 : 0.1,
583
- };
584
- });
585
- this.network.body.data.edges.update(edgeUpdates);
586
- }
587
-
588
- /**
589
- * Reset node highlight
590
- */
591
- resetNodeHighlight(): void {
592
- if (!this.network) {
593
- return;
341
+ if (topicEl) topicEl.textContent = node.topic || this.kindLabel(node.kind ?? 'memory');
342
+ if (kindEl) kindEl.textContent = this.kindLabel(node.kind ?? 'memory');
343
+ if (createdEl) {
344
+ createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
594
345
  }
346
+ decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
347
+ reasoningEl.innerHTML = '';
348
+ panel.classList.add('visible');
595
349
 
596
- const allNodes = this.network.body.data.nodes.get();
597
- this.network.body.data.nodes.update(
598
- allNodes.map((node) => ({
599
- id: node.id,
600
- opacity: 1.0,
601
- font: { ...node.font, color: '#131313' },
602
- }))
603
- );
604
-
605
- const allEdges = this.network.body.data.edges.get();
606
- this.network.body.data.edges.update(
607
- allEdges.map((edge) => ({
608
- id: edge.id,
609
- opacity: 1.0,
610
- }))
611
- );
612
- }
613
-
614
- // =============================================
615
- // Detail Panel
616
- // =============================================
617
-
618
- /**
619
- * Show node detail panel
620
- */
621
- async showDetail(node: GraphNodeRecord): Promise<void> {
350
+ const request = ++this.detailRequest;
622
351
  try {
623
- logger.debug('showDetail called with node:', node);
624
- this.currentNodeId = String(node.id);
625
- const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
626
-
627
- if (!panel) {
628
- logger.error('decision-detail-modal element not found');
629
- return;
630
- }
631
-
632
- // Update existing DOM elements with markdown rendering
633
- const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
634
- const decisionEl = getElementByIdOrNull<HTMLElement>('detail-decision');
635
- const reasoningEl = getElementByIdOrNull<HTMLElement>('detail-reasoning');
636
- if (!decisionEl || !reasoningEl) {
637
- logger.error('Required detail elements missing');
638
- return;
639
- }
640
- if (topicEl) {
641
- topicEl.textContent = node.topic || 'Unknown Topic';
642
- }
643
-
644
- decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
645
- reasoningEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
646
-
647
- const outcomeSelect = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
648
- if (outcomeSelect) {
649
- outcomeSelect.value = node.outcome ? node.outcome.toUpperCase() : '';
650
- outcomeSelect.disabled = Boolean(
651
- (node.kind && node.kind !== 'memory') || (node.state && node.state !== 'current')
652
- );
653
- }
654
-
655
- // Clear outcome status
656
- const outcomeStatus = getElementByIdOrNull<HTMLElement>('outcome-status');
657
- if (outcomeStatus) {
658
- outcomeStatus.textContent = '';
659
- outcomeStatus.className = '';
660
- }
661
-
662
- const confidenceEl = getElementByIdOrNull<HTMLElement>('detail-confidence');
663
- if (confidenceEl) {
664
- confidenceEl.textContent = node.confidence ? `${(node.confidence * 100).toFixed(0)}%` : '-';
665
- }
666
-
667
- const createdEl = getElementByIdOrNull<HTMLElement>('detail-created');
668
- if (createdEl) {
669
- createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
670
- }
671
-
672
- // Reset reasoning toggle
673
- const reasoningArrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
674
- if (reasoningArrow) {
675
- reasoningArrow.textContent = '▶';
676
- }
677
- reasoningEl.classList.add('hidden');
678
-
679
- // Show loading state for similar decisions
680
- const similarEl = getElementByIdOrNull<HTMLElement>('detail-similar');
681
- if (similarEl) {
682
- similarEl.innerHTML = '<span class="loading-similar">Searching...</span>';
683
- }
684
-
685
- // Show panel
686
- panel.classList.add('visible');
687
-
688
352
  const detail = await API.getGraphDetail(String(node.id));
689
- const detailNode = detail.node || node;
690
- const localNode = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
691
- if (localNode) {
692
- Object.assign(localNode, detailNode);
693
- }
694
-
353
+ if (request !== this.detailRequest) return;
354
+ const detailNode = detail.node;
695
355
  decisionEl.innerHTML = renderSafeMarkdown(
696
356
  String(detailNode.decision || detailNode.decision_preview || '-')
697
357
  );
698
358
  reasoningEl.innerHTML = renderSafeMarkdown(String(detailNode.reasoning || '-'));
699
-
700
- // Canonical graph relations are already visible as edges. The old
701
- // vector-similarity endpoint only understands legacy decision ids.
702
- if (node.kind) {
703
- if (similarEl) similarEl.textContent = 'Related records are shown as graph edges.';
704
- } else {
705
- void this.fetchSimilarDecisions(String(node.id));
706
- }
707
- logger.debug('showDetail completed successfully');
708
359
  } catch (error) {
709
- logger.error('Error in showDetail:', error);
710
- if (error instanceof Error && error.stack) {
711
- logger.error('Error stack:', error.stack);
712
- }
713
- logger.error('Node data:', node);
714
- }
715
- }
716
-
717
- /**
718
- * Get outcome icon name
719
- */
720
- getOutcomeIcon(outcome?: string): string {
721
- const outcomeMap = {
722
- pending: 'clock',
723
- success: 'check-circle',
724
- failed: 'x-circle',
725
- partial: 'alert-circle',
726
- };
727
- return outcomeMap[(outcome || 'pending').toLowerCase()] || 'clock';
728
- }
729
-
730
- /**
731
- * Toggle reasoning full text
732
- */
733
- toggleReasoning(): void {
734
- const arrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
735
- const content = getElementByIdOrNull<HTMLElement>('detail-reasoning');
736
- if (arrow && content) {
737
- const isHidden = content.classList.contains('hidden');
738
- content.classList.toggle('hidden');
739
- arrow.textContent = isHidden ? '▼' : '▶';
740
- }
741
- }
742
-
743
- /**
744
- * Close detail panel
745
- */
746
- closeDetail(): void {
747
- const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
748
- if (panel) {
749
- panel.classList.remove('visible');
750
- }
751
- this.currentNodeId = null;
752
- this.resetNodeHighlight();
753
- }
754
-
755
- /**
756
- * Fetch similar decisions
757
- */
758
- async fetchSimilarDecisions(nodeId: string): Promise<void> {
759
- logger.debug('fetchSimilarDecisions called for node:', nodeId);
760
- const container = getElementByIdOrNull<HTMLElement>('detail-similar');
761
-
762
- if (!container) {
763
- logger.warn('detail-similar element not found');
764
- return;
765
- }
766
-
767
- try {
768
- logger.info('Calling API.getSimilarDecisions...');
769
- const data = await API.getSimilarDecisions(nodeId);
770
- logger.debug('Similar decisions received:', data);
771
-
772
- const similar = (data.similar || []) as SimilarDecision[];
773
-
774
- if (similar.length === 0) {
775
- logger.debug('[MAMA] No similar decisions found');
776
- container.innerHTML = '<span style="color:#666">No similar decisions found</span>';
777
- return;
778
- }
779
-
780
- logger.debug('[MAMA] Building similar decisions HTML for', similar.length, 'items');
781
- const html = similar
782
- .map(
783
- (s) => `
784
- <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))}">
785
- <div class="text-xs font-semibold text-indigo-600 dark:text-indigo-400">${escapeHtml(
786
- String(s.topic || '')
787
- )}</div>
788
- <div class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">${escapeHtml(
789
- String(s.decision || '').substring(0, 80)
790
- )}...</div>
791
- <div class="text-xs text-gray-500 mt-1">${Math.round((s.similarity || 0) * 100)}% match</div>
792
- </button>
793
- `
794
- )
795
- .join('');
796
-
797
- logger.debug('[MAMA] Setting similar decisions HTML');
798
- container.innerHTML = html;
799
-
800
- // Bind click handler once to avoid duplicate listeners.
801
- container.removeEventListener('click', this.similarDecisionClickHandler);
802
- container.addEventListener('click', this.similarDecisionClickHandler);
803
- logger.debug('[MAMA] fetchSimilarDecisions completed');
804
- } catch (error) {
805
- const message = getErrorMessage(error);
806
- logger.error('[MAMA] Failed to fetch similar decisions:', message, error);
807
- if (error instanceof Error && error.stack) {
808
- logger.error('Error stack:', error.stack);
809
- }
810
- container.innerHTML = '<span style="color:#f66">Failed to load</span>';
811
- }
812
- }
813
-
814
- /**
815
- * Save outcome for current node
816
- */
817
- async saveOutcome(): Promise<void> {
818
- const current = this.graphData.nodes.find((node) => String(node.id) === this.currentNodeId);
819
- if (
820
- (current?.kind && current.kind !== 'memory') ||
821
- (current?.state && current.state !== 'current')
822
- )
823
- return;
824
- const select = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
825
- if (!select) {
826
- return;
827
- }
828
- const newOutcome = select.value;
829
-
830
- if (!this.currentNodeId || !newOutcome) {
831
- return;
832
- }
833
-
834
- try {
835
- await API.updateOutcome(this.currentNodeId, newOutcome);
836
-
837
- // Update local data
838
- const node = this.graphData.nodes.find((n) => String(n.id) === this.currentNodeId);
839
- if (node) {
840
- node.outcome = newOutcome.toUpperCase();
841
-
842
- // Update visualization
843
- this.network.body.data.nodes.update({
844
- id: this.currentNodeId,
845
- color: {
846
- border: this.getOutcomeBorderColor(newOutcome),
847
- },
848
- });
849
-
850
- // Refresh detail panel
851
- this.showDetail(node);
852
- }
853
-
854
- logger.debug('[MAMA] Outcome updated:', this.currentNodeId, newOutcome);
855
- } catch (error) {
856
- const message = getErrorMessage(error);
857
- logger.error('[MAMA] Failed to update outcome:', message);
858
- alert(`Failed to update outcome: ${message}`);
859
- }
860
- }
861
-
862
- // =============================================
863
- // Navigation
864
- // =============================================
865
-
866
- /**
867
- * Navigate to specific node
868
- */
869
- async navigateToNode(nodeId: string | number): Promise<void> {
870
- if (!this.network) {
871
- return;
872
- }
873
-
874
- let nodeIdString = String(nodeId);
875
-
876
- // Try exact match first
877
- let node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
878
-
879
- // If not found, try partial match (for short IDs from checkpoints)
880
- if (!node) {
881
- logger.debug('[MAMA] Exact match not found, trying partial match for:', nodeIdString);
882
- node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
883
-
884
- if (node) {
885
- logger.debug('[MAMA] Found node via partial match:', node.id);
886
- nodeIdString = String(node.id); // Update to the full ID
887
- }
888
- }
889
-
890
- // If node not in current graph, reload without filters
891
- if (!node) {
892
- logger.warn('[MAMA] Node not in current graph, reloading all nodes...');
893
-
894
- // Reset topic filter
895
- const topicFilter = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
896
- if (topicFilter) {
897
- topicFilter.value = '';
898
- }
899
-
900
- // Reload graph: fetch fresh data and reinitialize
901
- await this.fetchData({ full: true });
902
- this.init(this.graphData);
903
-
904
- // Try exact match again
905
- node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
906
-
907
- // If still not found, try partial match
908
- if (!node) {
909
- node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
910
- if (node) {
911
- logger.debug('[MAMA] Found node via partial match after reload:', node.id);
912
- nodeIdString = String(node.id);
913
- }
914
- }
915
-
916
- if (!node) {
917
- logger.warn('[MAMA] Node not found even after reload:', nodeIdString);
918
- showToast('⚠️ Decision not found in graph');
919
- return;
920
- }
921
- }
922
-
923
- // Focus on node (use resolved nodeIdString, not original nodeId)
924
- this.network.focus(nodeIdString, {
925
- scale: 1.5,
926
- animation: { duration: 500, easingFunction: 'easeInOutQuad' },
927
- });
928
-
929
- // Select node (triggers click event)
930
- this.network.selectNodes([nodeIdString]);
931
-
932
- // Show detail
933
- this.showDetail(node);
934
- this.highlightConnectedNodes(nodeIdString);
935
- }
936
-
937
- /**
938
- * Get connected edge types
939
- */
940
- getConnectedEdges(nodeId: string): ConnectedEdges {
941
- const edges = this.graphData.edges.filter(
942
- (e) => String(e.from) === nodeId || String(e.to) === nodeId
943
- );
944
-
945
- const outgoing = edges.filter((e) => String(e.from) === nodeId);
946
- const incoming = edges.filter((e) => String(e.to) === nodeId);
947
-
948
- return { outgoing, incoming, all: edges };
949
- }
950
-
951
- // =============================================
952
- // Filtering
953
- // =============================================
954
-
955
- /**
956
- * Populate topic filter dropdown
957
- */
958
- populateTopicFilter(topics: string[]): void {
959
- const datalist = getElementByIdOrNull<HTMLDataListElement>('topic-filter-list');
960
- if (!datalist) {
961
- // Fallback for old select element
962
- const select = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
963
- if (!select) return;
964
- select.innerHTML = '<option value="">All Topics</option>';
965
- topics.forEach((topic) => {
966
- const option = document.createElement('option');
967
- option.value = topic;
968
- option.textContent = topic;
969
- select.appendChild(option);
970
- });
971
- return;
972
- }
973
-
974
- datalist.innerHTML = '';
975
- topics.forEach((topic) => {
976
- const option = document.createElement('option');
977
- option.value = topic;
978
- datalist.appendChild(option);
979
- });
980
- }
981
-
982
- /**
983
- * Populate left-side decision list (time-sorted, newest first)
984
- */
985
- populateDecisionList(nodes: GraphNodeRecord[]): void {
986
- const listEl = getElementByIdOrNull<HTMLElement>('decision-list');
987
- const countEl = getElementByIdOrNull<HTMLElement>('decision-list-count');
988
- if (!listEl) return;
989
-
990
- const sorted = [...nodes].sort((a, b) => {
991
- const ta = typeof a.created_at === 'number' ? a.created_at : 0;
992
- const tb = typeof b.created_at === 'number' ? b.created_at : 0;
993
- return tb - ta;
994
- });
995
-
996
- if (countEl) countEl.textContent = String(sorted.length);
997
-
998
- if (sorted.length === 0) {
999
- listEl.innerHTML =
1000
- '<div style="padding:16px;text-align:center;color:#9E9891;font-size:11px">No decisions yet</div>';
1001
- return;
1002
- }
1003
-
1004
- const formatTime = (ts: number | string | undefined): string => {
1005
- if (!ts) return '';
1006
- const d = new Date(typeof ts === 'number' ? ts : parseInt(String(ts), 10));
1007
- const now = Date.now();
1008
- const diffH = Math.floor((now - d.getTime()) / 3600000);
1009
- if (diffH < 1) return 'just now';
1010
- if (diffH < 24) return `${diffH}h ago`;
1011
- const diffD = Math.floor(diffH / 24);
1012
- return `${diffD}d ago`;
1013
- };
1014
-
1015
- const topicLabel = (topic: string): string => {
1016
- const parts = topic.split('/').filter(Boolean);
1017
- return parts[parts.length - 1] || topic;
1018
- };
1019
-
1020
- listEl.innerHTML = sorted
1021
- .map((n) => {
1022
- const preview = escapeHtml((n.decision_preview || n.decision || '').slice(0, 80));
1023
- const topic = escapeHtml(topicLabel(n.topic || ''));
1024
- const time = formatTime(n.created_at);
1025
- const id = escapeAttr(String(n.id));
1026
- return `<div class="mama-decision-item" data-node-id="${id}" onclick="window.graphModule?.focusNode('${id}')" style="padding:6px 8px;border-bottom:1px solid #F5F2ED;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='#FFFDF5'" onmouseout="this.style.background=''"><div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px"><span style="font-size:11px;font-weight:600;color:#1A1A1A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">${topic}</span><span style="font-size:9px;color:#9E9891;flex-shrink:0;font-variant-numeric:tabular-nums">${time}</span></div><div style="font-size:10px;color:#6B6560;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">${preview}</div></div>`;
1027
- })
1028
- .join('');
1029
- }
1030
-
1031
- /**
1032
- * Focus a specific node in the graph (called from decision list click)
1033
- */
1034
- focusNode(nodeId: string): void {
1035
- if (!this.network) return;
1036
- try {
1037
- this.network.selectNodes([nodeId]);
1038
- this.network.focus(nodeId, {
1039
- scale: 1.5,
1040
- animation: { duration: 300, easingFunction: 'easeInOutQuad' },
1041
- });
1042
- // Trigger the detail panel
1043
- const nodeData = this.graphData.nodes.find((n) => String(n.id) === nodeId);
1044
- if (nodeData) {
1045
- this.showDetail(nodeData);
1046
- }
1047
- } catch (err) {
1048
- logger.warn('focusNode failed:', err);
1049
- }
1050
- }
1051
-
1052
- /**
1053
- * Filter by topic
1054
- */
1055
- filterByTopic(topic: string): void {
1056
- if (!this.network) {
1057
- return;
1058
- }
1059
-
1060
- const allNodes = this.network.body.data.nodes.get();
1061
-
1062
- if (!topic) {
1063
- // Show all
1064
- allNodes.forEach((node) => {
1065
- this.network.body.data.nodes.update({
1066
- id: node.id,
1067
- hidden: false,
1068
- });
1069
- });
1070
- } else {
1071
- // Filter — supports exact match from datalist or partial substring match from typing
1072
- const lowerTopic = topic.toLowerCase();
1073
- allNodes.forEach((node) => {
1074
- const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
1075
- const nodeTopic = nodeData?.topic || '';
1076
- const matches = nodeTopic === topic || nodeTopic.toLowerCase().includes(lowerTopic);
1077
- this.network.body.data.nodes.update({
1078
- id: node.id,
1079
- hidden: !matches,
1080
- });
1081
- });
1082
- }
1083
-
1084
- this.renderLegend();
1085
- logger.debug('[MAMA] Filtered by topic:', topic || 'all');
1086
- }
1087
-
1088
- /**
1089
- * Filter by outcome
1090
- */
1091
- filterByOutcome(outcome: string): void {
1092
- if (!this.network) {
1093
- return;
1094
- }
1095
-
1096
- const allNodes = this.network.body.data.nodes.get();
1097
-
1098
- if (!outcome) {
1099
- // Show all
1100
- allNodes.forEach((node) => {
1101
- this.network.body.data.nodes.update({
1102
- id: node.id,
1103
- hidden: false,
1104
- });
1105
- });
1106
- } else {
1107
- // Filter by outcome
1108
- allNodes.forEach((node) => {
1109
- const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
1110
- const nodeOutcome = (nodeData?.outcome || '').toLowerCase();
1111
- this.network.body.data.nodes.update({
1112
- id: node.id,
1113
- hidden: nodeOutcome !== outcome.toLowerCase(),
1114
- });
1115
- });
1116
- }
1117
-
1118
- this.renderLegend();
1119
- logger.debug('[MAMA] Filtered by outcome:', outcome || 'all');
1120
- }
1121
-
1122
- /**
1123
- * Clear all filters
1124
- */
1125
- clearFilters(): void {
1126
- if (!this.network) {
1127
- return;
1128
- }
1129
-
1130
- // Show all nodes
1131
- const allNodes = this.network.body.data.nodes.get();
1132
- allNodes.forEach((node) => {
1133
- this.network.body.data.nodes.update({
1134
- id: node.id,
1135
- hidden: false,
1136
- opacity: 1.0,
1137
- font: { ...node.font, color: '#131313' },
1138
- });
1139
- });
1140
-
1141
- // Show all edges
1142
- const allEdges = this.network.body.data.edges.get();
1143
- allEdges.forEach((edge) => {
1144
- this.network.body.data.edges.update({
1145
- id: edge.id,
1146
- opacity: 1.0,
1147
- });
1148
- });
1149
-
1150
- // Clear search state
1151
- this.searchMatches = [];
1152
- this.currentSearchIndex = 0;
1153
-
1154
- const countEl = getElementByIdOrNull<HTMLElement>('search-count');
1155
- if (countEl) {
1156
- countEl.style.display = 'none';
1157
- }
1158
-
1159
- this.renderLegend();
1160
- logger.debug('[MAMA] All filters cleared');
1161
- }
1162
-
1163
- // =============================================
1164
- // Search
1165
- // =============================================
1166
-
1167
- /**
1168
- * Perform search
1169
- */
1170
- search(): void {
1171
- const queryInput = getElementByIdOrNull<HTMLInputElement>('search-input');
1172
- const query = queryInput ? queryInput.value.trim().toLowerCase() : '';
1173
-
1174
- if (!query) {
1175
- this.clearSearch();
1176
- return;
1177
- }
1178
-
1179
- // Search in topic, decision, and reasoning
1180
- this.searchMatches = this.graphData.nodes.filter(
1181
- (node) =>
1182
- (node.topic || '').toLowerCase().includes(query) ||
1183
- (node.decision || '').toLowerCase().includes(query) ||
1184
- (node.reasoning || '').toLowerCase().includes(query)
1185
- );
1186
-
1187
- this.currentSearchIndex = 0;
1188
- this.updateSearchResults();
1189
-
1190
- if (this.searchMatches.length > 0) {
1191
- this.highlightSearchResults();
1192
- this.navigateToNode(String(this.searchMatches[0].id));
1193
- }
1194
-
1195
- logger.debug('[MAMA] Search:', query, '- Found', this.searchMatches.length, 'matches');
1196
- }
1197
-
1198
- /**
1199
- * Handle search input
1200
- */
1201
- handleSearchInput(event: KeyboardEvent): void {
1202
- if (event.key === 'Enter' && this.searchMatches.length > 0) {
1203
- this.nextSearchResult();
1204
- } else {
1205
- this.debouncedSearch();
1206
- }
1207
- }
1208
-
1209
- /**
1210
- * Navigate to next search result
1211
- */
1212
- nextSearchResult(): void {
1213
- if (this.searchMatches.length === 0) {
1214
- return;
1215
- }
1216
-
1217
- this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchMatches.length;
1218
- this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
1219
- this.updateSearchResults();
1220
- }
1221
-
1222
- /**
1223
- * Navigate to previous search result
1224
- */
1225
- prevSearchResult(): void {
1226
- if (this.searchMatches.length === 0) {
1227
- return;
1228
- }
1229
-
1230
- this.currentSearchIndex =
1231
- (this.currentSearchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
1232
- this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
1233
- this.updateSearchResults();
1234
- }
1235
-
1236
- /**
1237
- * Update search count display
1238
- */
1239
- updateSearchResults(): void {
1240
- const countEl = getElementByIdOrNull<HTMLElement>('search-count');
1241
- if (!countEl) {
1242
- return;
1243
- }
1244
-
1245
- if (this.searchMatches.length > 0) {
1246
- countEl.textContent = `${this.currentSearchIndex + 1} / ${this.searchMatches.length}`;
1247
- countEl.style.display = 'inline';
1248
- } else {
1249
- countEl.textContent = 'No results';
1250
- countEl.style.display = 'inline';
1251
- }
1252
- }
1253
-
1254
- /**
1255
- * Highlight search results
1256
- */
1257
- highlightSearchResults(): void {
1258
- if (!this.network) {
1259
- return;
1260
- }
1261
-
1262
- const matchIds = this.searchMatches.map((n) => n.id);
1263
- const allNodes = this.network.body.data.nodes.get();
1264
-
1265
- allNodes.forEach((node) => {
1266
- const isMatch = matchIds.includes(String(node.id));
1267
- this.network.body.data.nodes.update({
1268
- id: node.id,
1269
- opacity: isMatch ? 1.0 : 0.2,
1270
- font: { ...node.font, color: isMatch ? '#131313' : '#999' },
1271
- });
1272
- });
1273
- }
1274
-
1275
- /**
1276
- * Clear search
1277
- */
1278
- clearSearch(): void {
1279
- this.searchMatches = [];
1280
- this.currentSearchIndex = 0;
1281
- this.resetNodeHighlight();
1282
-
1283
- const countEl = getElementByIdOrNull<HTMLElement>('search-count');
1284
- if (countEl) {
1285
- countEl.style.display = 'none';
1286
- }
1287
- }
1288
-
1289
- /**
1290
- * Open search panel
1291
- */
1292
- openSearch(): void {
1293
- const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
1294
- const searchInput = getElementByIdOrNull<HTMLInputElement>('search-input');
1295
-
1296
- if (!searchContainer || !searchInput) {
1297
- return;
1298
- }
1299
- searchContainer.style.display = 'flex';
1300
- searchInput.focus();
1301
- }
1302
-
1303
- /**
1304
- * Close search panel
1305
- */
1306
- closeSearch(): void {
1307
- const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
1308
- if (!searchContainer) {
1309
- return;
360
+ if (request !== this.detailRequest) return;
361
+ logger.error('Failed to read record detail:', error);
362
+ decisionEl.textContent = `Could not read this record: ${
363
+ error instanceof Error ? error.message : String(error)
364
+ }`;
1310
365
  }
1311
- searchContainer.style.display = 'none';
1312
- this.clearSearch();
1313
366
  }
1314
367
  }