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