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