@jungjaehoon/mama-os 0.61.2 → 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.
Files changed (165) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +1 -1
  3. package/dist/api/drive-actions.d.ts +12 -0
  4. package/dist/api/drive-actions.d.ts.map +1 -0
  5. package/dist/api/drive-actions.js +322 -0
  6. package/dist/api/drive-actions.js.map +1 -0
  7. package/dist/api/report-source-actions.d.ts.map +1 -1
  8. package/dist/api/report-source-actions.js +15 -7
  9. package/dist/api/report-source-actions.js.map +1 -1
  10. package/dist/api/security-events.d.ts +26 -0
  11. package/dist/api/security-events.d.ts.map +1 -1
  12. package/dist/api/security-events.js +107 -24
  13. package/dist/api/security-events.js.map +1 -1
  14. package/dist/api/source-actions.d.ts.map +1 -1
  15. package/dist/api/source-actions.js +7 -7
  16. package/dist/api/source-actions.js.map +1 -1
  17. package/dist/api/stored-source-reader.d.ts.map +1 -1
  18. package/dist/api/stored-source-reader.js +3 -0
  19. package/dist/api/stored-source-reader.js.map +1 -1
  20. package/dist/api/trello-actions.d.ts +15 -0
  21. package/dist/api/trello-actions.d.ts.map +1 -0
  22. package/dist/api/trello-actions.js +130 -0
  23. package/dist/api/trello-actions.js.map +1 -0
  24. package/dist/api/viewer-data.d.ts +77 -0
  25. package/dist/api/viewer-data.d.ts.map +1 -1
  26. package/dist/api/viewer-data.js +116 -0
  27. package/dist/api/viewer-data.js.map +1 -1
  28. package/dist/api/viewer-server.d.ts +1 -1
  29. package/dist/api/viewer-server.d.ts.map +1 -1
  30. package/dist/api/viewer-server.js +87 -3
  31. package/dist/api/viewer-server.js.map +1 -1
  32. package/dist/api/work-actions.d.ts +3 -0
  33. package/dist/api/work-actions.d.ts.map +1 -1
  34. package/dist/api/work-actions.js +23 -15
  35. package/dist/api/work-actions.js.map +1 -1
  36. package/dist/backfill/format.d.ts +105 -0
  37. package/dist/backfill/format.d.ts.map +1 -0
  38. package/dist/backfill/format.js +314 -0
  39. package/dist/backfill/format.js.map +1 -0
  40. package/dist/backfill/push.d.ts +63 -0
  41. package/dist/backfill/push.d.ts.map +1 -0
  42. package/dist/backfill/push.js +221 -0
  43. package/dist/backfill/push.js.map +1 -0
  44. package/dist/cli/commands/backfill.d.ts +12 -0
  45. package/dist/cli/commands/backfill.d.ts.map +1 -0
  46. package/dist/cli/commands/backfill.js +67 -0
  47. package/dist/cli/commands/backfill.js.map +1 -0
  48. package/dist/cli/commands/daemon.d.ts +2 -1
  49. package/dist/cli/commands/daemon.d.ts.map +1 -1
  50. package/dist/cli/commands/daemon.js +55 -14
  51. package/dist/cli/commands/daemon.js.map +1 -1
  52. package/dist/cli/commands/init.d.ts.map +1 -1
  53. package/dist/cli/commands/init.js +4 -2
  54. package/dist/cli/commands/init.js.map +1 -1
  55. package/dist/cli/index.js +18 -1
  56. package/dist/cli/index.js.map +1 -1
  57. package/dist/cli/runtime/claude-caller-config.d.ts +6 -1
  58. package/dist/cli/runtime/claude-caller-config.d.ts.map +1 -1
  59. package/dist/cli/runtime/claude-caller-config.js +3 -1
  60. package/dist/cli/runtime/claude-caller-config.js.map +1 -1
  61. package/dist/connectors/framework/gws-utils.d.ts +6 -3
  62. package/dist/connectors/framework/gws-utils.d.ts.map +1 -1
  63. package/dist/connectors/framework/gws-utils.js +35 -6
  64. package/dist/connectors/framework/gws-utils.js.map +1 -1
  65. package/dist/connectors/index.d.ts +0 -1
  66. package/dist/connectors/index.d.ts.map +1 -1
  67. package/dist/connectors/index.js +1 -5
  68. package/dist/connectors/index.js.map +1 -1
  69. package/dist/connectors/slack/index.d.ts +2 -0
  70. package/dist/connectors/slack/index.d.ts.map +1 -1
  71. package/dist/connectors/slack/index.js +83 -39
  72. package/dist/connectors/slack/index.js.map +1 -1
  73. package/dist/connectors/trello/action-line.d.ts +13 -0
  74. package/dist/connectors/trello/action-line.d.ts.map +1 -0
  75. package/dist/connectors/trello/action-line.js +118 -0
  76. package/dist/connectors/trello/action-line.js.map +1 -0
  77. package/dist/connectors/trello/actions.d.ts +27 -0
  78. package/dist/connectors/trello/actions.d.ts.map +1 -0
  79. package/dist/connectors/trello/actions.js +35 -0
  80. package/dist/connectors/trello/actions.js.map +1 -0
  81. package/dist/connectors/trello/api.d.ts +23 -0
  82. package/dist/connectors/trello/api.d.ts.map +1 -0
  83. package/dist/connectors/trello/api.js +102 -0
  84. package/dist/connectors/trello/api.js.map +1 -0
  85. package/dist/connectors/trello/index.d.ts +63 -31
  86. package/dist/connectors/trello/index.d.ts.map +1 -1
  87. package/dist/connectors/trello/index.js +165 -246
  88. package/dist/connectors/trello/index.js.map +1 -1
  89. package/dist/gateways/discord.d.ts +4 -0
  90. package/dist/gateways/discord.d.ts.map +1 -1
  91. package/dist/gateways/discord.js +7 -3
  92. package/dist/gateways/discord.js.map +1 -1
  93. package/dist/gateways/slack.d.ts +4 -0
  94. package/dist/gateways/slack.d.ts.map +1 -1
  95. package/dist/gateways/slack.js +7 -3
  96. package/dist/gateways/slack.js.map +1 -1
  97. package/dist/gateways/telegram-message-ledger.d.ts +5 -0
  98. package/dist/gateways/telegram-message-ledger.d.ts.map +1 -1
  99. package/dist/gateways/telegram-message-ledger.js +8 -1
  100. package/dist/gateways/telegram-message-ledger.js.map +1 -1
  101. package/dist/gateways/telegram.d.ts +4 -0
  102. package/dist/gateways/telegram.d.ts.map +1 -1
  103. package/dist/gateways/telegram.js +7 -5
  104. package/dist/gateways/telegram.js.map +1 -1
  105. package/dist/replay/replay-source-catalog.d.ts.map +1 -1
  106. package/dist/replay/replay-source-catalog.js +5 -45
  107. package/dist/replay/replay-source-catalog.js.map +1 -1
  108. package/dist/replay/trello-import.d.ts +1 -8
  109. package/dist/replay/trello-import.d.ts.map +1 -1
  110. package/dist/replay/trello-import.js +16 -103
  111. package/dist/replay/trello-import.js.map +1 -1
  112. package/dist/runtime/action-surface.d.ts +4 -1
  113. package/dist/runtime/action-surface.d.ts.map +1 -1
  114. package/dist/runtime/action-surface.js +30 -1
  115. package/dist/runtime/action-surface.js.map +1 -1
  116. package/dist/runtime/config.d.ts +5 -0
  117. package/dist/runtime/config.d.ts.map +1 -1
  118. package/dist/runtime/config.js +10 -1
  119. package/dist/runtime/config.js.map +1 -1
  120. package/dist/runtime/connectors.d.ts +0 -1
  121. package/dist/runtime/connectors.d.ts.map +1 -1
  122. package/dist/runtime/connectors.js +0 -1
  123. package/dist/runtime/connectors.js.map +1 -1
  124. package/dist/runtime/egress-proxy.d.ts +15 -0
  125. package/dist/runtime/egress-proxy.d.ts.map +1 -0
  126. package/dist/runtime/egress-proxy.js +176 -0
  127. package/dist/runtime/egress-proxy.js.map +1 -0
  128. package/dist/runtime/native-session.d.ts +7 -0
  129. package/dist/runtime/native-session.d.ts.map +1 -1
  130. package/dist/runtime/native-session.js +8 -1
  131. package/dist/runtime/native-session.js.map +1 -1
  132. package/dist/runtime/outbound-attempts.d.ts +18 -0
  133. package/dist/runtime/outbound-attempts.d.ts.map +1 -0
  134. package/dist/runtime/outbound-attempts.js +98 -0
  135. package/dist/runtime/outbound-attempts.js.map +1 -0
  136. package/dist/runtime/owner-runtime.d.ts +9 -0
  137. package/dist/runtime/owner-runtime.d.ts.map +1 -1
  138. package/dist/runtime/owner-runtime.js +8 -0
  139. package/dist/runtime/owner-runtime.js.map +1 -1
  140. package/dist/runtime/owner-system-prompt.d.ts.map +1 -1
  141. package/dist/runtime/owner-system-prompt.js +6 -1
  142. package/dist/runtime/owner-system-prompt.js.map +1 -1
  143. package/dist/runtime/record-orders.d.ts.map +1 -1
  144. package/dist/runtime/record-orders.js +33 -13
  145. package/dist/runtime/record-orders.js.map +1 -1
  146. package/dist/runtime/stimulus-delivery.d.ts +15 -0
  147. package/dist/runtime/stimulus-delivery.d.ts.map +1 -1
  148. package/dist/runtime/stimulus-delivery.js +29 -2
  149. package/dist/runtime/stimulus-delivery.js.map +1 -1
  150. package/dist/runtime/turn-orders.d.ts.map +1 -1
  151. package/dist/runtime/turn-orders.js +6 -2
  152. package/dist/runtime/turn-orders.js.map +1 -1
  153. package/package.json +2 -2
  154. package/public/viewer/js/modules/graph.js +169 -957
  155. package/public/viewer/js/modules/memory-timeline.js +215 -0
  156. package/public/viewer/js/utils/api.js +7 -26
  157. package/public/viewer/log-viewer.html +7 -3
  158. package/public/viewer/operator/operator.css +1 -1
  159. package/public/viewer/operator/operator.js +5428 -5685
  160. package/public/viewer/src/modules/graph.ts +185 -1132
  161. package/public/viewer/src/modules/memory-timeline.ts +272 -0
  162. package/public/viewer/src/types/global.d.ts +1 -1
  163. package/public/viewer/src/utils/api.ts +43 -47
  164. package/public/viewer/viewer.css +257 -7
  165. package/public/viewer/viewer.html +68 -292
@@ -1,47 +1,68 @@
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
  /* eslint-env browser */
14
10
  /* global vis */
15
- import { escapeHtml, escapeAttr, debounce, showToast, getElementByIdOrNull, getErrorMessage, } from '../utils/dom.js';
11
+ import { escapeHtml, getElementByIdOrNull } from '../utils/dom.js';
16
12
  import { DebugLogger } from '../utils/debug-logger.js';
17
- import { API, } from '../utils/api.js';
13
+ import { API } from '../utils/api.js';
18
14
  import { renderSafeMarkdown } from '../utils/markdown.js';
19
15
  const logger = new DebugLogger('Graph');
20
- const INITIAL_GRAPH_LIMIT = 2000;
16
+ /** What each dot colour stands for, in the words the legend shows. */
17
+ export const KIND_LABELS = {
18
+ decision: 'Decision',
19
+ preference: 'Preference',
20
+ constraint: 'Constraint',
21
+ lesson: 'Lesson',
22
+ workflow: 'Workflow',
23
+ fact: 'Fact',
24
+ commitment: 'Work update',
25
+ observation: 'Source message',
26
+ memory: 'Memory without a kind',
27
+ registry: 'Registered name',
28
+ };
29
+ /** Every relation the engine stores, read from the record that holds the link. */
30
+ export const RELATION_LABELS = {
31
+ derived_from: 'rests on (evidence)',
32
+ mentions: 'mentions',
33
+ builds_on: 'builds on',
34
+ amends: 'amends',
35
+ refines: 'refines',
36
+ supersedes: 'replaces',
37
+ contradicts: 'contradicts',
38
+ debates: 'argues with',
39
+ synthesizes: 'combines',
40
+ case_member: 'belongs to case',
41
+ alias_of: 'another name for',
42
+ next_action_for: 'next step for',
43
+ blocks: 'blocks',
44
+ };
45
+ const LABEL_LENGTH = 40;
46
+ function shortLabel(text) {
47
+ const flat = text.replace(/\s+/g, ' ').trim();
48
+ return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH - 1)}…` : flat;
49
+ }
21
50
  /**
22
51
  * Graph Module Class
23
52
  */
24
53
  export class GraphModule {
25
54
  network = null;
26
55
  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
- };
56
+ centerId = null;
57
+ // A slower earlier read must not draw or describe a record the owner has moved away from.
58
+ recordRequest = 0;
59
+ detailRequest = 0;
40
60
  nodeColors = {
41
61
  decision: '#60A5FA',
42
62
  preference: '#C084FC',
43
63
  constraint: '#FB7185',
44
64
  lesson: '#FBBF24',
65
+ workflow: '#F472B6',
45
66
  fact: '#2DD4BF',
46
67
  commitment: '#FB923C',
47
68
  observation: '#94A3B8',
@@ -54,35 +75,39 @@ export class GraphModule {
54
75
  derived_from: { color: '#3A9E7E', dashes: false, width: 2 },
55
76
  amends: { color: '#2563EB', dashes: [8, 4], width: 2 },
56
77
  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
78
+ builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
79
+ contradicts: { color: '#DC143C', dashes: false, width: 2.5 },
80
+ debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 },
81
+ synthesizes: { color: '#6B4C9A', width: 3, dashes: false },
60
82
  };
61
- constructor() {
62
- // Network state
63
- }
64
- // =============================================
65
- // Data Loading
66
- // =============================================
67
83
  /**
68
- * Fetch graph data from API
84
+ * Draw a record and its direct links, then show its detail.
69
85
  */
70
- async fetchData(params = { limit: INITIAL_GRAPH_LIMIT }) {
86
+ async showRecord(id) {
87
+ const request = ++this.recordRequest;
88
+ const empty = getElementByIdOrNull('graph-empty');
89
+ const loading = getElementByIdOrNull('graph-loading');
90
+ if (empty)
91
+ empty.style.display = 'none';
92
+ if (loading)
93
+ loading.style.display = 'flex';
71
94
  try {
72
- this.graphData = await API.getGraph(params);
73
- logger.info('Graph data loaded:', this.graphData.meta);
74
- return this.graphData;
95
+ const data = (await API.getGraphNeighbors(id));
96
+ if (request !== this.recordRequest)
97
+ return;
98
+ this.centerId = id;
99
+ this.init(data);
100
+ const center = data.nodes.find((node) => String(node.id) === id);
101
+ if (center)
102
+ void this.showDetail(center);
75
103
  }
76
- catch (error) {
77
- logger.error('Failed to fetch graph:', error);
78
- throw error;
104
+ finally {
105
+ if (loading && request === this.recordRequest)
106
+ loading.style.display = 'none';
79
107
  }
80
108
  }
81
- // =============================================
82
- // Graph Initialization
83
- // =============================================
84
109
  /**
85
- * Initialize vis-network
110
+ * Initialize vis-network over one record's neighbourhood.
86
111
  */
87
112
  init(data) {
88
113
  const container = getElementByIdOrNull('graph-canvas');
@@ -90,33 +115,30 @@ export class GraphModule {
90
115
  logger.error('graph-canvas element not found');
91
116
  return;
92
117
  }
93
- // Ensure container has dimensions for vis-network
94
- if (container.offsetHeight === 0) {
95
- container.style.minHeight = '400px';
96
- }
97
- logger.debug('Graph canvas dimensions:', container.offsetWidth, 'x', container.offsetHeight);
98
118
  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
119
+ this.network?.destroy?.();
120
+ const nodes = data.nodes.map((n) => {
121
+ const kind = n.kind ?? 'memory';
122
+ const center = String(n.id) === this.centerId;
123
+ const color = this.getNodeColor(kind);
124
+ return {
125
+ id: n.id,
126
+ // Source messages stay unlabelled dots: their time and text are on hover and click.
127
+ label: kind === 'observation' && !center
128
+ ? undefined
129
+ : shortLabel(String(n.decision_preview ?? n.decision ?? n.topic ?? n.id)),
130
+ title: this.createNodeTooltip(n),
131
+ color: {
132
+ background: color,
133
+ border: center ? '#131313' : color,
134
+ highlight: { background: color, border: '#131313' },
135
+ },
136
+ size: center ? 22 : kind === 'observation' ? 9 : 14,
137
+ borderWidth: center ? 3 : 1,
138
+ font: { color: '#131313', size: center ? 15 : 13 },
139
+ data: n,
140
+ };
141
+ });
120
142
  const edges = data.edges.map((e) => {
121
143
  const style = this.getEdgeStyle(e.relationship);
122
144
  return {
@@ -125,180 +147,95 @@ export class GraphModule {
125
147
  arrows: { to: { enabled: true, scaleFactor: 0.5 } },
126
148
  color: style.color,
127
149
  dashes: style.dashes,
128
- width: style.width || 2,
129
- title: e.relationship,
150
+ width: style.width,
151
+ title: this.relationLabel(e.relationship),
130
152
  };
131
153
  });
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
- },
154
+ this.network = new vis.Network(container, { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) }, {
155
+ nodes: { shape: 'dot' },
156
+ edges: { smooth: { type: 'continuous', roundness: 0.5 }, width: 2 },
149
157
  physics: {
150
- // The initial layout always runs; the stabilized handler turns physics off afterwards.
151
158
  enabled: true,
152
159
  barnesHut: {
153
- gravitationalConstant: -8000,
154
- centralGravity: 0.3,
155
- springLength: 150,
160
+ gravitationalConstant: -6000,
161
+ centralGravity: 0.4,
162
+ springLength: 140,
156
163
  springConstant: 0.04,
157
164
  damping: 0.09,
158
165
  avoidOverlap: 1,
159
166
  },
160
- stabilization: {
161
- enabled: true,
162
- iterations: data.nodes.length > 500 ? 100 : 200,
163
- updateInterval: 25,
164
- },
165
- },
166
- interaction: {
167
- hover: true,
168
- tooltipDelay: 100,
169
- zoomView: true,
170
- dragView: true,
167
+ stabilization: { enabled: true, iterations: 200, updateInterval: 25 },
171
168
  },
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
- }
169
+ interaction: { hover: true, tooltipDelay: 100, zoomView: true, dragView: true },
198
170
  });
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
- }
171
+ this.network.on('click', (params) => {
172
+ const targetId = params.nodes[0];
173
+ if (targetId === undefined)
174
+ return;
175
+ const node = data.nodes.find((n) => String(n.id) === String(targetId));
176
+ if (node)
177
+ void this.showDetail(node);
213
178
  });
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 */
179
+ // Stepping to a linked record redraws the graph around it.
180
+ this.network.on('doubleClick', (params) => {
181
+ const targetId = params.nodes[0];
182
+ if (targetId !== undefined && String(targetId) !== this.centerId) {
183
+ void this.showRecord(String(targetId));
223
184
  }
224
185
  });
225
- // A large graph may never reach vis-network's 'stabilized'; stop when the bounded iterations end.
226
186
  this.network.on('stabilizationIterationsDone', () => {
227
187
  this.network?.setOptions?.({ physics: { enabled: false } });
228
188
  });
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
189
  this.renderLegend();
190
+ const legend = getElementByIdOrNull('legend-panel');
191
+ legend?.classList.add('visible');
259
192
  const stats = getElementByIdOrNull('graph-stats');
260
193
  if (stats) {
261
- stats.textContent = `${data.nodes.length} nodes · ${data.edges.length} edges${data.meta?.partial ? ' · partial' : ''}`;
194
+ stats.style.display = '';
195
+ 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`;
262
196
  }
263
- logger.info('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
264
197
  }
265
198
  // =============================================
266
- // Styling Utilities
199
+ // Styling and legend
267
200
  // =============================================
268
201
  getNodeColor(kind = 'memory') {
269
202
  return this.nodeColors[kind] ?? '#CBD5E1';
270
203
  }
271
- /** Count the displayed data, excluding nodes hidden by filters and their edges. */
204
+ getEdgeStyle(relationship) {
205
+ return (this.edgeStyles[relationship ?? 'default'] ?? { color: '#4a4a6a', dashes: false, width: 2 });
206
+ }
207
+ kindLabel(kind) {
208
+ return KIND_LABELS[kind] ?? kind;
209
+ }
210
+ relationLabel(relationship) {
211
+ return relationship === undefined ? 'link' : (RELATION_LABELS[relationship] ?? relationship);
212
+ }
213
+ /** Count what is drawn, by kind and by relationship, with the words the legend shows. */
272
214
  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
215
  const kinds = new Map();
277
216
  const relations = new Map();
278
217
  for (const node of this.graphData.nodes) {
279
- if (!visible.has(String(node.id)))
280
- continue;
281
218
  const kind = node.kind ?? 'memory';
282
219
  kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
283
220
  }
284
221
  for (const edge of this.graphData.edges) {
285
- if (!visible.has(String(edge.from)) || !visible.has(String(edge.to)))
286
- continue;
287
222
  const relation = edge.relationship ?? 'default';
288
223
  relations.set(relation, (relations.get(relation) ?? 0) + 1);
289
224
  }
290
225
  return {
291
226
  nodes: [...kinds]
292
- .sort(([a], [b]) => a.localeCompare(b))
227
+ .sort(([, a], [, b]) => b - a)
293
228
  .map(([kind, count]) => ({
294
229
  kind,
230
+ label: this.kindLabel(kind),
295
231
  count,
296
232
  color: this.getNodeColor(kind),
297
233
  })),
298
234
  edges: [...relations]
299
- .sort(([a], [b]) => a.localeCompare(b))
235
+ .sort(([, a], [, b]) => b - a)
300
236
  .map(([relationship, count]) => ({
301
237
  relationship,
238
+ label: this.relationLabel(relationship),
302
239
  count,
303
240
  ...this.getEdgeStyle(relationship),
304
241
  })),
@@ -311,785 +248,60 @@ export class GraphModule {
311
248
  const entries = this.getLegendEntries();
312
249
  const section = (title, rows) => rows.length === 0
313
250
  ? ''
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>`;
251
+ : `<div><div class="legend-heading">${title}</div>${rows.join('')}</div>`;
252
+ 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
253
  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 }) => {
254
+ section('Dots', entries.nodes.map(({ label, count, color }) => row(`<span class="legend-dot" style="background:${color}"></span>`, label, count))) +
255
+ section('Lines (arrow points at what the record links to)', entries.edges.map(({ label, count, color, dashes, width }) => {
319
256
  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);
257
+ 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
258
  }));
322
259
  if (entries.nodes.length === 0)
323
- container.textContent = 'No visible nodes';
324
- }
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;
260
+ container.textContent = 'Nothing drawn';
360
261
  }
361
- /**
362
- * Create node tooltip
363
- */
364
262
  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
- })));
263
+ const kind = this.kindLabel(node.kind ?? 'memory');
264
+ const text = String(node.decision_preview ?? node.decision ?? '');
265
+ return `${kind}${node.topic ? ` · ${node.topic}` : ''}\n${text.slice(0, 160)}`;
491
266
  }
492
267
  // =============================================
493
268
  // Detail Panel
494
269
  // =============================================
495
- /**
496
- * Show node detail panel
497
- */
498
270
  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() {
607
271
  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');
272
+ const topicEl = getElementByIdOrNull('detail-topic');
273
+ const kindEl = getElementByIdOrNull('detail-kind');
274
+ const decisionEl = getElementByIdOrNull('detail-decision');
275
+ const reasoningEl = getElementByIdOrNull('detail-reasoning');
276
+ const createdEl = getElementByIdOrNull('detail-created');
277
+ if (!panel || !decisionEl || !reasoningEl) {
278
+ logger.error('Detail panel elements missing');
622
279
  return;
623
280
  }
281
+ if (topicEl)
282
+ topicEl.textContent = node.topic || this.kindLabel(node.kind ?? 'memory');
283
+ if (kindEl)
284
+ kindEl.textContent = this.kindLabel(node.kind ?? 'memory');
285
+ if (createdEl) {
286
+ createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
287
+ }
288
+ decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
289
+ reasoningEl.innerHTML = '';
290
+ panel.classList.add('visible');
291
+ const request = ++this.detailRequest;
624
292
  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>';
293
+ const detail = await API.getGraphDetail(String(node.id));
294
+ if (request !== this.detailRequest)
632
295
  return;
633
- }
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);
296
+ const detailNode = detail.node;
297
+ decisionEl.innerHTML = renderSafeMarkdown(String(detailNode.decision || detailNode.decision_preview || '-'));
298
+ reasoningEl.innerHTML = renderSafeMarkdown(String(detailNode.reasoning || '-'));
693
299
  }
694
300
  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');
746
- 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)
301
+ if (request !== this.detailRequest)
781
302
  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;
303
+ logger.error('Failed to read record detail:', error);
304
+ decisionEl.textContent = `Could not read this record: ${error instanceof Error ? error.message : String(error)}`;
1091
305
  }
1092
- searchContainer.style.display = 'none';
1093
- this.clearSearch();
1094
306
  }
1095
307
  }