laminark 0.1.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 (55) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/plugin/.claude-plugin/plugin.json +13 -0
  4. package/plugin/.mcp.json +12 -0
  5. package/plugin/CLAUDE.md +10 -0
  6. package/plugin/commands/recall.md +55 -0
  7. package/plugin/commands/remember.md +34 -0
  8. package/plugin/commands/resume.md +45 -0
  9. package/plugin/commands/stash.md +34 -0
  10. package/plugin/commands/status.md +33 -0
  11. package/plugin/dist/analysis/worker.d.ts +1 -0
  12. package/plugin/dist/analysis/worker.js +233 -0
  13. package/plugin/dist/analysis/worker.js.map +1 -0
  14. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  15. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  16. package/plugin/dist/hooks/handler.d.ts +286 -0
  17. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  18. package/plugin/dist/hooks/handler.js +2413 -0
  19. package/plugin/dist/hooks/handler.js.map +1 -0
  20. package/plugin/dist/index.d.ts +447 -0
  21. package/plugin/dist/index.d.ts.map +1 -0
  22. package/plugin/dist/index.js +7334 -0
  23. package/plugin/dist/index.js.map +1 -0
  24. package/plugin/dist/observations-CorAAc1A.d.mts +192 -0
  25. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  26. package/plugin/dist/tool-registry-e710BvXq.mjs +3574 -0
  27. package/plugin/dist/tool-registry-e710BvXq.mjs.map +1 -0
  28. package/plugin/hooks/hooks.json +78 -0
  29. package/plugin/laminark.db +0 -0
  30. package/plugin/package.json +17 -0
  31. package/plugin/scripts/README.md +65 -0
  32. package/plugin/scripts/bump-version.sh +42 -0
  33. package/plugin/scripts/dev-sync.sh +58 -0
  34. package/plugin/scripts/ensure-deps.sh +15 -0
  35. package/plugin/scripts/install.sh +139 -0
  36. package/plugin/scripts/local-install.sh +138 -0
  37. package/plugin/scripts/uninstall.sh +133 -0
  38. package/plugin/scripts/update.sh +39 -0
  39. package/plugin/scripts/verify-install.sh +87 -0
  40. package/plugin/skills/status/SKILL.md +6 -0
  41. package/plugin/ui/activity.js +197 -0
  42. package/plugin/ui/app.js +1612 -0
  43. package/plugin/ui/graph.js +2560 -0
  44. package/plugin/ui/help/activity-feed.png +0 -0
  45. package/plugin/ui/help/analysis-panel.png +0 -0
  46. package/plugin/ui/help/graph-toolbar.png +0 -0
  47. package/plugin/ui/help/graph-view.png +0 -0
  48. package/plugin/ui/help/settings.png +0 -0
  49. package/plugin/ui/help/timeline.png +0 -0
  50. package/plugin/ui/help.js +932 -0
  51. package/plugin/ui/index.html +756 -0
  52. package/plugin/ui/settings.js +1414 -0
  53. package/plugin/ui/styles.css +3856 -0
  54. package/plugin/ui/timeline.js +652 -0
  55. package/plugin/ui/tools.js +826 -0
@@ -0,0 +1,1612 @@
1
+ /**
2
+ * Laminark client-side application.
3
+ *
4
+ * Handles tab navigation, SSE connection with auto-reconnect,
5
+ * REST API data fetching, and initial state loading.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Global state
10
+ // ---------------------------------------------------------------------------
11
+
12
+ window.laminarkState = {
13
+ graph: null,
14
+ timeline: null,
15
+ graphInitialized: false,
16
+ timelineInitialized: false,
17
+ toolsInitialized: false,
18
+ currentProject: null,
19
+ };
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // REST API helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Fetches graph data (nodes + edges) from the REST API.
27
+ * @param {Object} [filters] - Optional filters
28
+ * @param {string} [filters.type] - Comma-separated entity types
29
+ * @param {string} [filters.since] - ISO8601 timestamp
30
+ * @returns {Promise<{nodes: Array, edges: Array}>}
31
+ */
32
+ async function fetchGraphData(filters) {
33
+ const params = new URLSearchParams();
34
+ if (filters?.type) params.set('type', filters.type);
35
+ if (filters?.since) params.set('since', filters.since);
36
+ if (filters?.until) params.set('until', filters.until);
37
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
38
+
39
+ const url = '/api/graph' + (params.toString() ? '?' + params.toString() : '');
40
+
41
+ try {
42
+ const res = await fetch(url);
43
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
+ return await res.json();
45
+ } catch (err) {
46
+ console.error('[laminark] Failed to fetch graph data:', err);
47
+ return { nodes: [], edges: [] };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Fetches timeline data (sessions, observations, topic shifts).
53
+ * @param {Object} [range] - Optional time range
54
+ * @param {string} [range.from] - ISO8601 start
55
+ * @param {string} [range.to] - ISO8601 end
56
+ * @param {number} [range.limit] - Max observations
57
+ * @returns {Promise<{sessions: Array, observations: Array, topicShifts: Array}>}
58
+ */
59
+ async function fetchTimelineData(range) {
60
+ const params = new URLSearchParams();
61
+ if (range?.from) params.set('from', range.from);
62
+ if (range?.to) params.set('to', range.to);
63
+ if (range?.limit) params.set('limit', String(range.limit));
64
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
65
+
66
+ const url = '/api/timeline' + (params.toString() ? '?' + params.toString() : '');
67
+
68
+ try {
69
+ const res = await fetch(url);
70
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
71
+ return await res.json();
72
+ } catch (err) {
73
+ console.error('[laminark] Failed to fetch timeline data:', err);
74
+ return { sessions: [], observations: [], topicShifts: [] };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Fetches detailed info for a single graph node.
80
+ * @param {string} id - Node ID
81
+ * @returns {Promise<{entity: Object, observations: Array, relationships: Array}|null>}
82
+ */
83
+ async function fetchNodeDetails(id) {
84
+ try {
85
+ const res = await fetch(`/api/node/${encodeURIComponent(id)}`);
86
+ if (!res.ok) return null;
87
+ return await res.json();
88
+ } catch (err) {
89
+ console.error('[laminark] Failed to fetch node details:', err);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Fetches available projects from the REST API.
96
+ * @returns {Promise<{projects: Array, defaultProject: string|null}>}
97
+ */
98
+ async function fetchProjects() {
99
+ try {
100
+ const res = await fetch('/api/projects');
101
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
102
+ return await res.json();
103
+ } catch (err) {
104
+ console.error('[laminark] Failed to fetch projects:', err);
105
+ return { projects: [], defaultProject: null };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Fetches recent debug paths from the REST API.
111
+ * @returns {Promise<{paths: Array}>}
112
+ */
113
+ async function fetchPaths() {
114
+ try {
115
+ const params = new URLSearchParams();
116
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
117
+ const res = await fetch('/api/paths' + (params.toString() ? '?' + params.toString() : ''));
118
+ if (!res.ok) throw new Error('HTTP ' + res.status);
119
+ return await res.json();
120
+ } catch (err) {
121
+ console.error('[laminark] Failed to fetch paths:', err);
122
+ return { paths: [] };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Fetches detail for a single debug path including waypoints.
128
+ * @param {string} pathId - Path ID
129
+ * @returns {Promise<{path: Object, waypoints: Array}|null>}
130
+ */
131
+ async function fetchPathDetail(pathId) {
132
+ try {
133
+ const res = await fetch('/api/paths/' + encodeURIComponent(pathId));
134
+ if (!res.ok) return null;
135
+ return await res.json();
136
+ } catch (err) {
137
+ console.error('[laminark] Failed to fetch path detail:', err);
138
+ return null;
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // SSE connection with auto-reconnect and heartbeat watchdog
144
+ // ---------------------------------------------------------------------------
145
+
146
+ let eventSource = null;
147
+ let reconnectDelay = 3000;
148
+ const MAX_RECONNECT_DELAY = 30000;
149
+ const HEARTBEAT_TIMEOUT_MS = 60000; // 60s with no heartbeat triggers reconnect
150
+ let lastEventTime = 0;
151
+ let heartbeatWatchdog = null;
152
+
153
+ function updateSSEStatus(status) {
154
+ var indicator = document.getElementById('sse-status');
155
+ if (indicator) {
156
+ indicator.className = 'status-indicator ' + status;
157
+ updateSSETooltip(indicator, status);
158
+ }
159
+ }
160
+
161
+ function updateSSETooltip(indicator, status) {
162
+ var base = 'SSE: ' + status;
163
+ if (lastEventTime > 0) {
164
+ var ago = Math.round((Date.now() - lastEventTime) / 1000);
165
+ var agoText = ago < 60 ? ago + 's ago' : Math.round(ago / 60) + 'min ago';
166
+ indicator.title = base + ' (last event: ' + agoText + ')';
167
+ } else {
168
+ indicator.title = base;
169
+ }
170
+ }
171
+
172
+ function recordEventReceived() {
173
+ lastEventTime = Date.now();
174
+ resetHeartbeatWatchdog();
175
+ }
176
+
177
+ function resetHeartbeatWatchdog() {
178
+ if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
179
+ heartbeatWatchdog = setTimeout(function () {
180
+ console.warn('[laminark] No heartbeat for ' + (HEARTBEAT_TIMEOUT_MS / 1000) + 's, forcing SSE reconnect');
181
+ if (eventSource) {
182
+ eventSource.close();
183
+ eventSource = null;
184
+ }
185
+ updateSSEStatus('disconnected');
186
+ reconnectWithCatchup();
187
+ }, HEARTBEAT_TIMEOUT_MS);
188
+ }
189
+
190
+ /**
191
+ * Reconnect SSE and fetch fresh data from REST API to catch up on missed events.
192
+ */
193
+ function reconnectWithCatchup() {
194
+ setTimeout(function () {
195
+ console.log('[laminark] Reconnecting SSE with data catch-up');
196
+ connectSSE();
197
+
198
+ // Belt-and-suspenders: fetch fresh data from REST API to catch up
199
+ if (window.laminarkGraph && window.laminarkState.graphInitialized) {
200
+ var filters = getActiveFilters();
201
+ window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
202
+ }
203
+ if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
204
+ window.laminarkTimeline.loadTimelineData();
205
+ }
206
+
207
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
208
+ }, reconnectDelay);
209
+ }
210
+
211
+ function connectSSE() {
212
+ updateSSEStatus('connecting');
213
+
214
+ eventSource = new EventSource('/api/sse');
215
+
216
+ eventSource.addEventListener('connected', function (e) {
217
+ console.log('[laminark] SSE connected:', JSON.parse(e.data));
218
+ updateSSEStatus('connected');
219
+ reconnectDelay = 3000; // Reset backoff on successful connection
220
+ recordEventReceived();
221
+ });
222
+
223
+ eventSource.addEventListener('heartbeat', function (_e) {
224
+ // Heartbeat received -- connection is alive
225
+ updateSSEStatus('connected');
226
+ recordEventReceived();
227
+ });
228
+
229
+ // Helper: dispatch SSE events that belong to the currently selected project.
230
+ // Events with a mismatched projectHash are dropped; events without projectHash
231
+ // are allowed through (server may not always tag them, e.g. older builds).
232
+ function dispatchIfCurrentProject(eventName, data) {
233
+ if (data.projectHash && data.projectHash !== window.laminarkState.currentProject) return;
234
+ document.dispatchEvent(new CustomEvent(eventName, { detail: data }));
235
+ }
236
+
237
+ eventSource.addEventListener('new_observation', function (e) {
238
+ var data = JSON.parse(e.data);
239
+ recordEventReceived();
240
+ dispatchIfCurrentProject('laminark:new_observation', data);
241
+ });
242
+
243
+ eventSource.addEventListener('entity_updated', function (e) {
244
+ var data = JSON.parse(e.data);
245
+ recordEventReceived();
246
+ dispatchIfCurrentProject('laminark:entity_updated', data);
247
+ });
248
+
249
+ eventSource.addEventListener('topic_shift', function (e) {
250
+ var data = JSON.parse(e.data);
251
+ recordEventReceived();
252
+ dispatchIfCurrentProject('laminark:topic_shift', data);
253
+ });
254
+
255
+ eventSource.onerror = function () {
256
+ console.warn('[laminark] SSE connection error, reconnecting in', reconnectDelay, 'ms');
257
+ updateSSEStatus('disconnected');
258
+ eventSource.close();
259
+ eventSource = null;
260
+
261
+ if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
262
+ reconnectWithCatchup();
263
+ };
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Project selector
268
+ // ---------------------------------------------------------------------------
269
+
270
+ async function initProjectSelector() {
271
+ var select = document.getElementById('project-selector');
272
+ if (!select) return;
273
+
274
+ var data = await fetchProjects();
275
+ var projects = data.projects || [];
276
+ var defaultProject = data.defaultProject;
277
+
278
+ select.innerHTML = '';
279
+
280
+ if (projects.length === 0) {
281
+ var opt = document.createElement('option');
282
+ opt.value = '';
283
+ opt.textContent = 'No projects';
284
+ select.appendChild(opt);
285
+ return;
286
+ }
287
+
288
+ projects.forEach(function (p) {
289
+ var opt = document.createElement('option');
290
+ opt.value = p.hash;
291
+ opt.textContent = p.displayName;
292
+ if (p.hash === defaultProject) opt.selected = true;
293
+ select.appendChild(opt);
294
+ });
295
+
296
+ // Set initial current project
297
+ window.laminarkState.currentProject = select.value || defaultProject;
298
+
299
+ select.addEventListener('change', function () {
300
+ window.laminarkState.currentProject = select.value;
301
+ // Clear any pending batch updates from the previous project
302
+ if (window.laminarkGraph && window.laminarkGraph.clearBatchQueue) {
303
+ window.laminarkGraph.clearBatchQueue();
304
+ }
305
+ // Reload all data for the new project
306
+ if (window.laminarkGraph && window.laminarkState.graphInitialized) {
307
+ window.laminarkGraph.loadGraphData();
308
+ }
309
+ if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
310
+ window.laminarkTimeline.loadTimelineData();
311
+ }
312
+ });
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Tab navigation
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function initNavigation() {
320
+ const tabs = document.querySelectorAll('.nav-tab');
321
+ const views = document.querySelectorAll('.view-container');
322
+ const filterBar = document.getElementById('filter-bar');
323
+
324
+ tabs.forEach(function (tab) {
325
+ tab.addEventListener('click', function () {
326
+ const targetView = tab.getAttribute('data-view');
327
+
328
+ // Update active tab
329
+ tabs.forEach(function (t) { t.classList.remove('active'); });
330
+ tab.classList.add('active');
331
+
332
+ // Show target view, hide others
333
+ views.forEach(function (v) {
334
+ v.classList.toggle('active', v.id === targetView);
335
+ });
336
+
337
+ // Show/hide filter bar and time range bar (only for graph view)
338
+ var isGraph = targetView === 'graph-view';
339
+ var mainContent = document.getElementById('main-content');
340
+ if (filterBar) {
341
+ filterBar.style.display = isGraph ? '' : 'none';
342
+ }
343
+ var timeRangeBar = document.getElementById('time-range-bar');
344
+ if (timeRangeBar) {
345
+ timeRangeBar.style.display = isGraph ? '' : 'none';
346
+ }
347
+ if (mainContent) {
348
+ mainContent.classList.toggle('no-bars', !isGraph);
349
+ }
350
+
351
+ // Refresh stats when switching to settings tab
352
+ if (targetView === 'settings-view' && window.laminarkSettings) {
353
+ window.laminarkSettings.refreshStats();
354
+ }
355
+
356
+ // Lazy initialization: only init each view when first activated
357
+ if (targetView === 'timeline-view' && !window.laminarkState.timelineInitialized) {
358
+ if (window.laminarkTimeline) {
359
+ window.laminarkTimeline.initTimeline('timeline-view');
360
+ window.laminarkTimeline.loadTimelineData();
361
+ window.laminarkState.timelineInitialized = true;
362
+ }
363
+ } else if (targetView === 'graph-view' && !window.laminarkState.graphInitialized) {
364
+ if (window.laminarkGraph) {
365
+ window.laminarkGraph.initGraph('cy');
366
+ window.laminarkGraph.loadGraphData();
367
+ window.laminarkState.graphInitialized = true;
368
+ }
369
+ } else if (targetView === 'tools-view' && !window.laminarkState.toolsInitialized) {
370
+ if (window.laminarkTools) {
371
+ window.laminarkTools.initTools('tools-view');
372
+ window.laminarkState.toolsInitialized = true;
373
+ }
374
+ }
375
+ });
376
+ });
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Filter bar
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function initFilters() {
384
+ const pills = document.querySelectorAll('.filter-pill');
385
+
386
+ pills.forEach(function (pill) {
387
+ pill.addEventListener('click', function () {
388
+ const type = pill.getAttribute('data-type');
389
+
390
+ if (type === 'all') {
391
+ var typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
392
+ var allCurrentlyActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
393
+
394
+ if (allCurrentlyActive) {
395
+ // Toggle off -- deactivate all pills
396
+ pills.forEach(function (p) {
397
+ p.classList.remove('active');
398
+ });
399
+ if (window.laminarkGraph && window.laminarkGraph.setActiveTypes) {
400
+ window.laminarkGraph.setActiveTypes([]);
401
+ }
402
+ } else {
403
+ // Toggle on -- activate all type pills
404
+ pills.forEach(function (p) {
405
+ p.classList.add('active');
406
+ });
407
+ if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
408
+ window.laminarkGraph.resetFilters();
409
+ }
410
+ }
411
+ } else {
412
+ // Toggle this filter pill
413
+ pill.classList.toggle('active');
414
+
415
+ // Check if all type pills are now active
416
+ const typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
417
+ const allActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
418
+ const noneActive = !Array.from(typePills).some(function (p) { return p.classList.contains('active'); });
419
+ const allPill = document.querySelector('.filter-pill[data-type="all"]');
420
+
421
+ if (allActive || noneActive) {
422
+ // All selected or none selected -- reset to "All"
423
+ pills.forEach(function (p) { p.classList.add('active'); });
424
+ if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
425
+ window.laminarkGraph.resetFilters();
426
+ }
427
+ return;
428
+ }
429
+
430
+ // Update "All" pill state
431
+ if (allPill) allPill.classList.remove('active');
432
+
433
+ // Use graph.js filterByType for toggle behavior
434
+ if (window.laminarkGraph && window.laminarkGraph.filterByType) {
435
+ window.laminarkGraph.filterByType(type);
436
+ }
437
+ }
438
+
439
+ // Dispatch filter change event for any other listeners
440
+ const activeTypes = getActiveFilters();
441
+ document.dispatchEvent(new CustomEvent('laminark:filter_change', { detail: { types: activeTypes } }));
442
+ });
443
+ });
444
+ }
445
+
446
+ function getActiveFilters() {
447
+ const allPill = document.querySelector('.filter-pill[data-type="all"].active');
448
+ if (allPill) return null; // No filter -- show all
449
+
450
+ const active = document.querySelectorAll('.filter-pill.active');
451
+ return Array.from(active).map(function (p) { return p.getAttribute('data-type'); });
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Time range controls
456
+ // ---------------------------------------------------------------------------
457
+
458
+ function initTimeRange() {
459
+ var presetBtns = document.querySelectorAll('.time-preset');
460
+ var timeFromInput = document.getElementById('time-from');
461
+ var timeToInput = document.getElementById('time-to');
462
+ var applyBtn = document.getElementById('time-apply');
463
+
464
+ presetBtns.forEach(function (btn) {
465
+ btn.addEventListener('click', function () {
466
+ var preset = btn.getAttribute('data-preset');
467
+
468
+ // Update active state
469
+ presetBtns.forEach(function (b) { b.classList.remove('active'); });
470
+ btn.classList.add('active');
471
+
472
+ // Calculate from/to dates based on preset
473
+ var now = new Date();
474
+ var from = null;
475
+ var to = null;
476
+
477
+ if (preset === 'hour') {
478
+ from = new Date(now.getTime() - 60 * 60 * 1000);
479
+ to = now;
480
+ } else if (preset === 'today') {
481
+ from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
482
+ to = now;
483
+ } else if (preset === 'week') {
484
+ // Start of this week (Monday)
485
+ var day = now.getDay();
486
+ var diff = day === 0 ? 6 : day - 1; // Monday = 0 offset
487
+ from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff);
488
+ to = now;
489
+ } else if (preset === 'month') {
490
+ from = new Date(now.getFullYear(), now.getMonth(), 1);
491
+ to = now;
492
+ }
493
+ // preset === 'all' => from = null, to = null
494
+
495
+ // Update the date inputs to reflect the preset
496
+ if (timeFromInput && timeToInput) {
497
+ timeFromInput.value = from ? toDatetimeLocalString(from) : '';
498
+ timeToInput.value = to ? toDatetimeLocalString(to) : '';
499
+ }
500
+
501
+ // Apply client-side time range filter (instant for presets)
502
+ if (window.laminarkGraph && window.laminarkGraph.filterByTimeRange) {
503
+ window.laminarkGraph.filterByTimeRange(
504
+ from ? from.toISOString() : null,
505
+ to ? to.toISOString() : null
506
+ );
507
+ }
508
+ });
509
+ });
510
+
511
+ // Custom date range -- apply button
512
+ if (applyBtn) {
513
+ applyBtn.addEventListener('click', function () {
514
+ // Deactivate preset buttons
515
+ presetBtns.forEach(function (b) { b.classList.remove('active'); });
516
+
517
+ var fromVal = timeFromInput ? timeFromInput.value : '';
518
+ var toVal = timeToInput ? timeToInput.value : '';
519
+
520
+ var from = fromVal ? new Date(fromVal).toISOString() : null;
521
+ var to = toVal ? new Date(toVal).toISOString() : null;
522
+
523
+ // For custom date ranges, re-fetch from API for server-side filtering
524
+ if (window.laminarkGraph) {
525
+ var filters = {};
526
+ if (from) filters.since = from;
527
+ if (to) filters.until = to;
528
+
529
+ // Re-fetch graph data with server-side filtering
530
+ window.laminarkGraph.loadGraphData(filters).then(function () {
531
+ // Then also apply client-side time range for combined filtering
532
+ if (window.laminarkGraph.filterByTimeRange) {
533
+ window.laminarkGraph.filterByTimeRange(from, to);
534
+ }
535
+ });
536
+ }
537
+ });
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Converts a Date to a datetime-local input value string.
543
+ * @param {Date} date
544
+ * @returns {string}
545
+ */
546
+ function toDatetimeLocalString(date) {
547
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
548
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
549
+ 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Detail panel
554
+ // ---------------------------------------------------------------------------
555
+
556
+ function initDetailPanel() {
557
+ const closeBtn = document.getElementById('detail-close');
558
+ const focusBtn = document.getElementById('detail-focus');
559
+
560
+ if (closeBtn) {
561
+ closeBtn.addEventListener('click', function () {
562
+ if (window.laminarkGraph && window.laminarkGraph.hideDetailPanel) {
563
+ window.laminarkGraph.hideDetailPanel();
564
+ } else {
565
+ var panel = document.getElementById('detail-panel');
566
+ if (panel) panel.classList.add('hidden');
567
+ }
568
+ });
569
+ }
570
+
571
+ if (focusBtn) {
572
+ focusBtn.addEventListener('click', function () {
573
+ var nodeId = focusBtn.getAttribute('data-node-id');
574
+ var nodeLabel = focusBtn.getAttribute('data-node-label');
575
+ if (nodeId && window.laminarkGraph && window.laminarkGraph.enterFocusMode) {
576
+ window.laminarkGraph.enterFocusMode(nodeId, nodeLabel || nodeId);
577
+ }
578
+ });
579
+ }
580
+ }
581
+
582
+ function showNodeDetails(nodeData) {
583
+ const panel = document.getElementById('detail-panel');
584
+ const title = document.getElementById('detail-title');
585
+ const body = document.getElementById('detail-body');
586
+
587
+ if (!panel || !title || !body) return;
588
+
589
+ title.textContent = nodeData.entity.label;
590
+
591
+ // Update focus button with current node info
592
+ var focusBtn = document.getElementById('detail-focus');
593
+ if (focusBtn) {
594
+ focusBtn.setAttribute('data-node-id', nodeData.entity.id);
595
+ focusBtn.setAttribute('data-node-label', nodeData.entity.label);
596
+ }
597
+
598
+ // Build detail panel content using DOM elements for safety
599
+ body.innerHTML = '';
600
+
601
+ // Entity info section
602
+ var entitySection = document.createElement('div');
603
+ entitySection.className = 'detail-section';
604
+
605
+ var entityTitle = document.createElement('div');
606
+ entityTitle.className = 'detail-section-title';
607
+ entityTitle.textContent = 'Entity';
608
+ entitySection.appendChild(entityTitle);
609
+
610
+ // Type field with colored badge
611
+ var typeField = document.createElement('div');
612
+ typeField.className = 'detail-field';
613
+ var typeLabel = document.createElement('span');
614
+ typeLabel.className = 'field-label';
615
+ typeLabel.textContent = 'Type: ';
616
+ var typeBadge = document.createElement('span');
617
+ typeBadge.className = 'type-badge';
618
+ typeBadge.setAttribute('data-type', nodeData.entity.type);
619
+ typeBadge.textContent = nodeData.entity.type;
620
+ typeField.appendChild(typeLabel);
621
+ typeField.appendChild(typeBadge);
622
+ entitySection.appendChild(typeField);
623
+
624
+ // Created date
625
+ var createdField = document.createElement('div');
626
+ createdField.className = 'detail-field';
627
+ var createdLabel = document.createElement('span');
628
+ createdLabel.className = 'field-label';
629
+ createdLabel.textContent = 'Created: ';
630
+ var createdValue = document.createElement('span');
631
+ createdValue.className = 'field-value';
632
+ createdValue.textContent = formatTime(nodeData.entity.createdAt);
633
+ createdField.appendChild(createdLabel);
634
+ createdField.appendChild(createdValue);
635
+ entitySection.appendChild(createdField);
636
+
637
+ body.appendChild(entitySection);
638
+
639
+ // Observations section -- scrollable list sorted most recent first
640
+ if (nodeData.observations && nodeData.observations.length > 0) {
641
+ var obsSection = document.createElement('div');
642
+ obsSection.className = 'detail-section';
643
+
644
+ var obsTitle = document.createElement('div');
645
+ obsTitle.className = 'detail-section-title';
646
+ obsTitle.textContent = 'Observations (' + nodeData.observations.length + ')';
647
+ obsSection.appendChild(obsTitle);
648
+
649
+ var obsList = document.createElement('div');
650
+ obsList.className = 'observation-list-panel';
651
+
652
+ nodeData.observations.forEach(function (obs) {
653
+ var item = document.createElement('div');
654
+ item.className = 'observation-item';
655
+
656
+ var timestamp = document.createElement('span');
657
+ timestamp.className = 'obs-timestamp';
658
+ timestamp.textContent = formatTime(obs.createdAt);
659
+ item.appendChild(timestamp);
660
+
661
+ var text = document.createElement('span');
662
+ text.className = 'obs-text-content';
663
+ text.textContent = obs.text.length > 200 ? obs.text.substring(0, 200) + '...' : obs.text;
664
+ item.appendChild(text);
665
+
666
+ obsList.appendChild(item);
667
+ });
668
+
669
+ obsSection.appendChild(obsList);
670
+ body.appendChild(obsSection);
671
+ }
672
+
673
+ // Relationships section -- each clickable to navigate to that node
674
+ if (nodeData.relationships && nodeData.relationships.length > 0) {
675
+ var relSection = document.createElement('div');
676
+ relSection.className = 'detail-section';
677
+
678
+ var relTitle = document.createElement('div');
679
+ relTitle.className = 'detail-section-title';
680
+ relTitle.textContent = 'Relationships (' + nodeData.relationships.length + ')';
681
+ relSection.appendChild(relTitle);
682
+
683
+ nodeData.relationships.forEach(function (rel) {
684
+ var item = document.createElement('div');
685
+ item.className = 'relationship-item';
686
+ item.setAttribute('data-target-id', rel.targetId);
687
+ item.style.cursor = 'pointer';
688
+
689
+ var relType = document.createElement('span');
690
+ relType.className = 'rel-type';
691
+ relType.textContent = rel.type;
692
+ item.appendChild(relType);
693
+
694
+ var arrow = document.createElement('span');
695
+ arrow.className = 'rel-arrow';
696
+ arrow.textContent = rel.direction === 'outgoing' ? ' \u2192 ' : ' \u2190 ';
697
+ item.appendChild(arrow);
698
+
699
+ var target = document.createElement('span');
700
+ target.className = 'rel-target';
701
+ target.textContent = rel.targetLabel;
702
+ item.appendChild(target);
703
+
704
+ // Click to navigate to the related node in the graph
705
+ item.addEventListener('click', function () {
706
+ if (window.laminarkGraph && window.laminarkGraph.selectAndCenterNode) {
707
+ window.laminarkGraph.selectAndCenterNode(rel.targetId);
708
+ }
709
+ });
710
+
711
+ relSection.appendChild(item);
712
+ });
713
+
714
+ body.appendChild(relSection);
715
+ }
716
+
717
+ if (!nodeData.observations?.length && !nodeData.relationships?.length) {
718
+ var emptyMsg = document.createElement('p');
719
+ emptyMsg.className = 'empty-state';
720
+ emptyMsg.textContent = 'No details available for this node.';
721
+ body.appendChild(emptyMsg);
722
+ }
723
+
724
+ panel.classList.remove('hidden');
725
+ }
726
+
727
+ function showPathDetails(pathData) {
728
+ var panel = document.getElementById('path-detail-panel');
729
+ var title = document.getElementById('path-detail-title');
730
+ var body = document.getElementById('path-detail-body');
731
+
732
+ if (!panel || !title || !body) return;
733
+
734
+ var path = pathData.path;
735
+ var waypoints = pathData.waypoints || [];
736
+
737
+ title.textContent = 'Debug Path';
738
+ body.innerHTML = '';
739
+
740
+ // Status + trigger section
741
+ var infoSection = document.createElement('div');
742
+ infoSection.className = 'path-info-section';
743
+
744
+ var statusBadge = document.createElement('span');
745
+ statusBadge.className = 'path-status-badge ' + path.status;
746
+ statusBadge.textContent = path.status;
747
+ infoSection.appendChild(statusBadge);
748
+
749
+ var triggerLabel = document.createElement('div');
750
+ triggerLabel.className = 'path-info-label';
751
+ triggerLabel.style.marginTop = '8px';
752
+ triggerLabel.textContent = 'Trigger';
753
+ infoSection.appendChild(triggerLabel);
754
+
755
+ var triggerValue = document.createElement('div');
756
+ triggerValue.className = 'path-info-value';
757
+ triggerValue.textContent = path.trigger_summary || 'Unknown trigger';
758
+ infoSection.appendChild(triggerValue);
759
+
760
+ // Started at
761
+ var startedLabel = document.createElement('div');
762
+ startedLabel.className = 'path-info-label';
763
+ startedLabel.style.marginTop = '6px';
764
+ startedLabel.textContent = 'Started';
765
+ infoSection.appendChild(startedLabel);
766
+
767
+ var startedValue = document.createElement('div');
768
+ startedValue.className = 'path-info-value';
769
+ startedValue.textContent = formatTime(path.started_at);
770
+ infoSection.appendChild(startedValue);
771
+
772
+ // Resolved at (if resolved)
773
+ if (path.resolved_at) {
774
+ var resolvedLabel = document.createElement('div');
775
+ resolvedLabel.className = 'path-info-label';
776
+ resolvedLabel.style.marginTop = '6px';
777
+ resolvedLabel.textContent = 'Resolved';
778
+ infoSection.appendChild(resolvedLabel);
779
+
780
+ var resolvedValue = document.createElement('div');
781
+ resolvedValue.className = 'path-info-value';
782
+ resolvedValue.textContent = formatTime(path.resolved_at);
783
+ infoSection.appendChild(resolvedValue);
784
+ }
785
+
786
+ // Resolution summary
787
+ if (path.resolution_summary) {
788
+ var resLabel = document.createElement('div');
789
+ resLabel.className = 'path-info-label';
790
+ resLabel.style.marginTop = '6px';
791
+ resLabel.textContent = 'Resolution';
792
+ infoSection.appendChild(resLabel);
793
+
794
+ var resValue = document.createElement('div');
795
+ resValue.className = 'path-info-value';
796
+ resValue.textContent = path.resolution_summary;
797
+ infoSection.appendChild(resValue);
798
+ }
799
+
800
+ body.appendChild(infoSection);
801
+
802
+ // KISS Summary (if present)
803
+ if (path.kiss_summary) {
804
+ var kiss;
805
+ try {
806
+ kiss = typeof path.kiss_summary === 'string' ? JSON.parse(path.kiss_summary) : path.kiss_summary;
807
+ } catch (e) { kiss = null; }
808
+
809
+ if (kiss) {
810
+ var kissSection = document.createElement('div');
811
+ kissSection.className = 'kiss-summary-section';
812
+
813
+ var kissTitle = document.createElement('div');
814
+ kissTitle.className = 'kiss-summary-title';
815
+ kissTitle.textContent = 'KISS Summary';
816
+ kissSection.appendChild(kissTitle);
817
+
818
+ var kissFields = [
819
+ { label: 'Problem', value: kiss.problem },
820
+ { label: 'Cause', value: kiss.cause },
821
+ { label: 'Fix', value: kiss.fix },
822
+ { label: 'Prevention', value: kiss.prevention },
823
+ ];
824
+
825
+ kissFields.forEach(function(field) {
826
+ if (!field.value) return;
827
+ var fieldDiv = document.createElement('div');
828
+ fieldDiv.className = 'kiss-summary-field';
829
+
830
+ var fieldLabel = document.createElement('div');
831
+ fieldLabel.className = 'kiss-summary-field-label';
832
+ fieldLabel.textContent = field.label;
833
+ fieldDiv.appendChild(fieldLabel);
834
+
835
+ var fieldValue = document.createElement('div');
836
+ fieldValue.className = 'kiss-summary-field-value';
837
+ fieldValue.textContent = field.value;
838
+ fieldDiv.appendChild(fieldValue);
839
+
840
+ kissSection.appendChild(fieldDiv);
841
+ });
842
+
843
+ body.appendChild(kissSection);
844
+ }
845
+ }
846
+
847
+ // Waypoint timeline
848
+ if (waypoints.length > 0) {
849
+ var wpSectionTitle = document.createElement('div');
850
+ wpSectionTitle.className = 'waypoint-section-title';
851
+ wpSectionTitle.textContent = 'Waypoints (' + waypoints.length + ')';
852
+ body.appendChild(wpSectionTitle);
853
+
854
+ var timeline = document.createElement('div');
855
+ timeline.className = 'waypoint-timeline';
856
+
857
+ waypoints.forEach(function(wp) {
858
+ var item = document.createElement('div');
859
+ item.className = 'waypoint-item';
860
+ item.setAttribute('data-type', wp.waypoint_type);
861
+
862
+ var header = document.createElement('div');
863
+ header.className = 'waypoint-header';
864
+
865
+ var seq = document.createElement('span');
866
+ seq.className = 'waypoint-seq';
867
+ seq.textContent = '#' + wp.sequence_order;
868
+ header.appendChild(seq);
869
+
870
+ var typeLabel = document.createElement('span');
871
+ typeLabel.className = 'waypoint-type-label';
872
+ var WAYPOINT_COLORS = {
873
+ error: '#f85149', attempt: '#d29922', failure: '#f0883e',
874
+ success: '#3fb950', pivot: '#a371f7', revert: '#79c0ff',
875
+ discovery: '#58a6ff', resolution: '#3fb950'
876
+ };
877
+ typeLabel.style.color = WAYPOINT_COLORS[wp.waypoint_type] || '#8b949e';
878
+ typeLabel.textContent = wp.waypoint_type;
879
+ header.appendChild(typeLabel);
880
+
881
+ var time = document.createElement('span');
882
+ time.className = 'waypoint-time';
883
+ time.textContent = formatTime(wp.created_at);
884
+ header.appendChild(time);
885
+
886
+ item.appendChild(header);
887
+
888
+ if (wp.summary) {
889
+ var summary = document.createElement('div');
890
+ summary.className = 'waypoint-summary';
891
+ summary.textContent = wp.summary;
892
+ item.appendChild(summary);
893
+ }
894
+
895
+ timeline.appendChild(item);
896
+ });
897
+
898
+ body.appendChild(timeline);
899
+ } else {
900
+ var emptyMsg = document.createElement('p');
901
+ emptyMsg.className = 'empty-state';
902
+ emptyMsg.textContent = 'No waypoints recorded yet.';
903
+ body.appendChild(emptyMsg);
904
+ }
905
+
906
+ // Hide the node detail panel if it's open (avoid two panels)
907
+ var nodePanel = document.getElementById('detail-panel');
908
+ if (nodePanel) nodePanel.classList.add('hidden');
909
+
910
+ panel.classList.remove('hidden');
911
+ }
912
+
913
+ function initPathDetailPanel() {
914
+ var closeBtn = document.getElementById('path-detail-close');
915
+ if (closeBtn) {
916
+ closeBtn.addEventListener('click', function() {
917
+ var panel = document.getElementById('path-detail-panel');
918
+ if (panel) panel.classList.add('hidden');
919
+ });
920
+ }
921
+
922
+ // Listen for path detail events from graph overlay clicks
923
+ document.addEventListener('laminark:show_path_detail', function(e) {
924
+ if (e.detail) {
925
+ showPathDetails(e.detail);
926
+ }
927
+ });
928
+ }
929
+
930
+ function escapeHtml(text) {
931
+ const div = document.createElement('div');
932
+ div.textContent = text;
933
+ return div.innerHTML;
934
+ }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // Graph Search
938
+ // ---------------------------------------------------------------------------
939
+
940
+ function initSearch() {
941
+ var searchInput = document.getElementById('graph-search');
942
+ var dropdown = document.getElementById('search-results');
943
+ if (!searchInput || !dropdown) return;
944
+
945
+ var debounceTimer = null;
946
+ var activeIndex = -1;
947
+ var currentResults = [];
948
+
949
+ function renderDropdown(results) {
950
+ currentResults = results;
951
+ activeIndex = -1;
952
+
953
+ if (results.length === 0 && searchInput.value.trim().length > 0) {
954
+ dropdown.innerHTML = '<div class="search-no-results">No matches found</div>';
955
+ dropdown.classList.remove('hidden');
956
+ return;
957
+ }
958
+
959
+ if (results.length === 0) {
960
+ dropdown.classList.add('hidden');
961
+ return;
962
+ }
963
+
964
+ dropdown.innerHTML = '';
965
+ results.forEach(function (r, idx) {
966
+ var item = document.createElement('div');
967
+ item.className = 'search-result-item';
968
+ item.setAttribute('data-index', idx);
969
+
970
+ var dot = document.createElement('span');
971
+ dot.className = 'search-result-dot';
972
+ var style = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES[r.type]) || { color: '#8b949e' };
973
+ dot.style.background = style.color;
974
+ item.appendChild(dot);
975
+
976
+ var labelWrap = document.createElement('div');
977
+ labelWrap.style.flex = '1';
978
+ labelWrap.style.minWidth = '0';
979
+
980
+ var label = document.createElement('div');
981
+ label.className = 'search-result-label';
982
+ label.textContent = r.label;
983
+ labelWrap.appendChild(label);
984
+
985
+ if (r.snippet) {
986
+ var snippet = document.createElement('div');
987
+ snippet.className = 'search-result-snippet';
988
+ snippet.textContent = r.snippet;
989
+ labelWrap.appendChild(snippet);
990
+ }
991
+
992
+ item.appendChild(labelWrap);
993
+
994
+ var typeBadge = document.createElement('span');
995
+ typeBadge.className = 'search-result-type';
996
+ typeBadge.textContent = r.type;
997
+ item.appendChild(typeBadge);
998
+
999
+ if (r.matchSource) {
1000
+ var src = document.createElement('span');
1001
+ src.className = 'search-result-source';
1002
+ src.textContent = r.matchSource;
1003
+ item.appendChild(src);
1004
+ }
1005
+
1006
+ item.addEventListener('click', function () {
1007
+ selectResult(r);
1008
+ });
1009
+
1010
+ dropdown.appendChild(item);
1011
+ });
1012
+
1013
+ dropdown.classList.remove('hidden');
1014
+ }
1015
+
1016
+ function selectResult(r) {
1017
+ dropdown.classList.add('hidden');
1018
+ searchInput.value = r.label;
1019
+
1020
+ if (window.laminarkGraph) {
1021
+ window.laminarkGraph.selectAndCenterNode(r.id);
1022
+ window.laminarkGraph.highlightSearchMatches([r.id]);
1023
+ }
1024
+ }
1025
+
1026
+ function clearSearch() {
1027
+ searchInput.value = '';
1028
+ dropdown.classList.add('hidden');
1029
+ currentResults = [];
1030
+ activeIndex = -1;
1031
+ if (window.laminarkGraph) {
1032
+ window.laminarkGraph.clearSearchHighlight();
1033
+ }
1034
+ }
1035
+
1036
+ // Debounced client-side search on keyup
1037
+ searchInput.addEventListener('input', function () {
1038
+ var query = searchInput.value.trim();
1039
+
1040
+ if (debounceTimer) clearTimeout(debounceTimer);
1041
+
1042
+ if (!query) {
1043
+ clearSearch();
1044
+ return;
1045
+ }
1046
+
1047
+ debounceTimer = setTimeout(function () {
1048
+ // Client-side search on loaded Cytoscape data
1049
+ var results = [];
1050
+ if (window.laminarkGraph && window.laminarkGraph.searchNodes) {
1051
+ var clientResults = window.laminarkGraph.searchNodes(query);
1052
+ results = clientResults.map(function (r) {
1053
+ return {
1054
+ id: r.id,
1055
+ label: r.label,
1056
+ type: r.type,
1057
+ matchSource: null,
1058
+ snippet: null,
1059
+ };
1060
+ });
1061
+ }
1062
+
1063
+ // Highlight matching nodes in graph
1064
+ if (results.length > 0 && window.laminarkGraph) {
1065
+ window.laminarkGraph.highlightSearchMatches(results.map(function (r) { return r.id; }));
1066
+ }
1067
+
1068
+ renderDropdown(results);
1069
+ }, 250);
1070
+ });
1071
+
1072
+ // Enter triggers server-side search
1073
+ searchInput.addEventListener('keydown', function (e) {
1074
+ if (e.key === 'Enter') {
1075
+ e.preventDefault();
1076
+ var query = searchInput.value.trim();
1077
+ if (!query) return;
1078
+
1079
+ var params = new URLSearchParams({ q: query, limit: '20' });
1080
+ if (window.laminarkState.currentProject) {
1081
+ params.set('project', window.laminarkState.currentProject);
1082
+ }
1083
+
1084
+ fetch('/api/graph/search?' + params.toString())
1085
+ .then(function (res) { return res.json(); })
1086
+ .then(function (data) {
1087
+ renderDropdown(data.results || []);
1088
+
1089
+ // Highlight all matching nodes in graph
1090
+ if (data.results && data.results.length > 0 && window.laminarkGraph) {
1091
+ window.laminarkGraph.highlightSearchMatches(
1092
+ data.results.map(function (r) { return r.id; })
1093
+ );
1094
+ }
1095
+ })
1096
+ .catch(function (err) {
1097
+ console.error('[laminark] Search failed:', err);
1098
+ });
1099
+ } else if (e.key === 'Escape') {
1100
+ clearSearch();
1101
+ } else if (e.key === 'ArrowDown') {
1102
+ e.preventDefault();
1103
+ if (currentResults.length > 0) {
1104
+ activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
1105
+ updateActiveItem();
1106
+ }
1107
+ } else if (e.key === 'ArrowUp') {
1108
+ e.preventDefault();
1109
+ if (currentResults.length > 0) {
1110
+ activeIndex = Math.max(activeIndex - 1, 0);
1111
+ updateActiveItem();
1112
+ }
1113
+ }
1114
+
1115
+ // Enter on active dropdown item
1116
+ if (e.key === 'Enter' && activeIndex >= 0 && currentResults[activeIndex]) {
1117
+ selectResult(currentResults[activeIndex]);
1118
+ }
1119
+ });
1120
+
1121
+ function updateActiveItem() {
1122
+ var items = dropdown.querySelectorAll('.search-result-item');
1123
+ items.forEach(function (item, idx) {
1124
+ item.classList.toggle('active', idx === activeIndex);
1125
+ });
1126
+ }
1127
+
1128
+ // Close dropdown on outside click
1129
+ document.addEventListener('click', function (e) {
1130
+ if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
1131
+ dropdown.classList.add('hidden');
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // Graph Analysis Panel
1138
+ // ---------------------------------------------------------------------------
1139
+
1140
+ var analysisOpen = false;
1141
+
1142
+ function initAnalysis() {
1143
+ var btn = document.getElementById('analysis-btn');
1144
+ var panel = document.getElementById('analysis-panel');
1145
+ var closeBtn = document.getElementById('analysis-close');
1146
+ if (!btn || !panel) return;
1147
+
1148
+ btn.addEventListener('click', function () {
1149
+ analysisOpen = !analysisOpen;
1150
+ btn.classList.toggle('active', analysisOpen);
1151
+ if (analysisOpen) {
1152
+ panel.classList.remove('hidden');
1153
+ loadAnalysis();
1154
+ } else {
1155
+ panel.classList.add('hidden');
1156
+ // Clear any cluster highlights
1157
+ if (window.laminarkGraph) {
1158
+ window.laminarkGraph.clearSearchHighlight();
1159
+ }
1160
+ }
1161
+ // Let CSS transition finish, then refit graph to new width
1162
+ setTimeout(function () {
1163
+ if (window.laminarkGraph) window.laminarkGraph.fitToView();
1164
+ }, 350);
1165
+ });
1166
+
1167
+ if (closeBtn) {
1168
+ closeBtn.addEventListener('click', function () {
1169
+ analysisOpen = false;
1170
+ panel.classList.add('hidden');
1171
+ btn.classList.remove('active');
1172
+ if (window.laminarkGraph) {
1173
+ window.laminarkGraph.clearSearchHighlight();
1174
+ }
1175
+ setTimeout(function () {
1176
+ if (window.laminarkGraph) window.laminarkGraph.fitToView();
1177
+ }, 350);
1178
+ });
1179
+ }
1180
+ }
1181
+
1182
+ function loadAnalysis() {
1183
+ var body = document.getElementById('analysis-body');
1184
+ if (!body) return;
1185
+
1186
+ body.innerHTML = '<p class="empty-state">Loading analysis...</p>';
1187
+
1188
+ var params = new URLSearchParams();
1189
+ if (window.laminarkState.currentProject) {
1190
+ params.set('project', window.laminarkState.currentProject);
1191
+ }
1192
+
1193
+ fetch('/api/graph/analysis' + (params.toString() ? '?' + params.toString() : ''))
1194
+ .then(function (res) { return res.json(); })
1195
+ .then(function (data) {
1196
+ renderAnalysis(body, data);
1197
+ })
1198
+ .catch(function (err) {
1199
+ console.error('[laminark] Analysis failed:', err);
1200
+ body.innerHTML = '<p class="empty-state">Failed to load analysis</p>';
1201
+ });
1202
+ }
1203
+
1204
+ function renderAnalysis(container, data) {
1205
+ container.innerHTML = '';
1206
+
1207
+ var entityStyles = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES) || {};
1208
+
1209
+ // Recent activity
1210
+ if (data.recentActivity) {
1211
+ var actSection = document.createElement('div');
1212
+ actSection.className = 'analysis-section';
1213
+
1214
+ var actTitle = document.createElement('div');
1215
+ actTitle.className = 'analysis-section-title';
1216
+ actTitle.textContent = 'Recent Activity';
1217
+ actSection.appendChild(actTitle);
1218
+
1219
+ var rows = [
1220
+ { label: 'Last 24 hours', value: data.recentActivity.lastDay },
1221
+ { label: 'Last 7 days', value: data.recentActivity.lastWeek },
1222
+ ];
1223
+
1224
+ rows.forEach(function (r) {
1225
+ var row = document.createElement('div');
1226
+ row.className = 'analysis-stat-row';
1227
+ var lbl = document.createElement('span');
1228
+ lbl.className = 'analysis-stat-label';
1229
+ lbl.textContent = r.label;
1230
+ var val = document.createElement('span');
1231
+ val.className = 'analysis-stat-value';
1232
+ val.textContent = r.value + ' new entities';
1233
+ row.appendChild(lbl);
1234
+ row.appendChild(val);
1235
+ actSection.appendChild(row);
1236
+ });
1237
+
1238
+ container.appendChild(actSection);
1239
+ }
1240
+
1241
+ // Entity type distribution
1242
+ if (data.entityTypes && data.entityTypes.length > 0) {
1243
+ var etSection = document.createElement('div');
1244
+ etSection.className = 'analysis-section';
1245
+
1246
+ var etTitle = document.createElement('div');
1247
+ etTitle.className = 'analysis-section-title';
1248
+ etTitle.textContent = 'Entity Types';
1249
+ etSection.appendChild(etTitle);
1250
+
1251
+ var maxCount = data.entityTypes[0].count;
1252
+
1253
+ data.entityTypes.forEach(function (et) {
1254
+ var row = document.createElement('div');
1255
+ row.className = 'analysis-bar-row';
1256
+
1257
+ var label = document.createElement('span');
1258
+ label.className = 'analysis-bar-label';
1259
+ label.textContent = et.type;
1260
+ row.appendChild(label);
1261
+
1262
+ var track = document.createElement('div');
1263
+ track.className = 'analysis-bar-track';
1264
+ var fill = document.createElement('div');
1265
+ fill.className = 'analysis-bar-fill';
1266
+ fill.style.width = Math.round((et.count / maxCount) * 100) + '%';
1267
+ fill.style.background = (entityStyles[et.type] || { color: '#8b949e' }).color;
1268
+ track.appendChild(fill);
1269
+ row.appendChild(track);
1270
+
1271
+ var count = document.createElement('span');
1272
+ count.className = 'analysis-bar-count';
1273
+ count.textContent = et.count;
1274
+ row.appendChild(count);
1275
+
1276
+ etSection.appendChild(row);
1277
+ });
1278
+
1279
+ container.appendChild(etSection);
1280
+ }
1281
+
1282
+ // Relationship type distribution
1283
+ if (data.relationshipTypes && data.relationshipTypes.length > 0) {
1284
+ var rtSection = document.createElement('div');
1285
+ rtSection.className = 'analysis-section';
1286
+
1287
+ var rtTitle = document.createElement('div');
1288
+ rtTitle.className = 'analysis-section-title';
1289
+ rtTitle.textContent = 'Relationship Types';
1290
+ rtSection.appendChild(rtTitle);
1291
+
1292
+ var maxRel = data.relationshipTypes[0].count;
1293
+
1294
+ data.relationshipTypes.forEach(function (rt) {
1295
+ var row = document.createElement('div');
1296
+ row.className = 'analysis-bar-row';
1297
+
1298
+ var label = document.createElement('span');
1299
+ label.className = 'analysis-bar-label';
1300
+ label.textContent = rt.type;
1301
+ row.appendChild(label);
1302
+
1303
+ var track = document.createElement('div');
1304
+ track.className = 'analysis-bar-track';
1305
+ var fill = document.createElement('div');
1306
+ fill.className = 'analysis-bar-fill';
1307
+ fill.style.width = Math.round((rt.count / maxRel) * 100) + '%';
1308
+ fill.style.background = '#8b949e';
1309
+ track.appendChild(fill);
1310
+ row.appendChild(track);
1311
+
1312
+ var count = document.createElement('span');
1313
+ count.className = 'analysis-bar-count';
1314
+ count.textContent = rt.count;
1315
+ row.appendChild(count);
1316
+
1317
+ rtSection.appendChild(row);
1318
+ });
1319
+
1320
+ container.appendChild(rtSection);
1321
+ }
1322
+
1323
+ // Top 10 entities by degree
1324
+ if (data.topEntities && data.topEntities.length > 0) {
1325
+ var teSection = document.createElement('div');
1326
+ teSection.className = 'analysis-section';
1327
+
1328
+ var teTitle = document.createElement('div');
1329
+ teTitle.className = 'analysis-section-title';
1330
+ teTitle.textContent = 'Most Connected Entities';
1331
+ teSection.appendChild(teTitle);
1332
+
1333
+ data.topEntities.forEach(function (ent) {
1334
+ var item = document.createElement('div');
1335
+ item.className = 'analysis-entity-item';
1336
+
1337
+ var dot = document.createElement('span');
1338
+ dot.className = 'analysis-entity-dot';
1339
+ dot.style.background = (entityStyles[ent.type] || { color: '#8b949e' }).color;
1340
+ item.appendChild(dot);
1341
+
1342
+ var name = document.createElement('span');
1343
+ name.className = 'analysis-entity-name';
1344
+ name.textContent = ent.label;
1345
+ item.appendChild(name);
1346
+
1347
+ var degree = document.createElement('span');
1348
+ degree.className = 'analysis-entity-degree';
1349
+ degree.textContent = ent.degree + ' links';
1350
+ item.appendChild(degree);
1351
+
1352
+ item.addEventListener('click', function () {
1353
+ if (window.laminarkGraph) {
1354
+ window.laminarkGraph.selectAndCenterNode(ent.id);
1355
+ }
1356
+ });
1357
+
1358
+ teSection.appendChild(item);
1359
+ });
1360
+
1361
+ container.appendChild(teSection);
1362
+ }
1363
+
1364
+ // Connected components / clusters
1365
+ if (data.components && data.components.length > 0) {
1366
+ var ccSection = document.createElement('div');
1367
+ ccSection.className = 'analysis-section';
1368
+
1369
+ var ccTitle = document.createElement('div');
1370
+ ccTitle.className = 'analysis-section-title';
1371
+ ccTitle.textContent = 'Clusters (' + data.components.length + ')';
1372
+ ccSection.appendChild(ccTitle);
1373
+
1374
+ data.components.forEach(function (comp) {
1375
+ var card = document.createElement('div');
1376
+ card.className = 'analysis-cluster-card';
1377
+
1378
+ var label = document.createElement('div');
1379
+ label.className = 'analysis-cluster-label';
1380
+ label.textContent = comp.label;
1381
+ card.appendChild(label);
1382
+
1383
+ var meta = document.createElement('div');
1384
+ meta.className = 'analysis-cluster-meta';
1385
+ meta.textContent = comp.nodeCount + ' nodes, ' + comp.edgeCount + ' edges';
1386
+ card.appendChild(meta);
1387
+
1388
+ card.addEventListener('click', function () {
1389
+ if (window.laminarkGraph) {
1390
+ window.laminarkGraph.highlightCluster(comp.nodeIds);
1391
+ }
1392
+ });
1393
+
1394
+ ccSection.appendChild(card);
1395
+ });
1396
+
1397
+ container.appendChild(ccSection);
1398
+ }
1399
+
1400
+ if (!data.entityTypes?.length && !data.topEntities?.length) {
1401
+ var empty = document.createElement('p');
1402
+ empty.className = 'empty-state';
1403
+ empty.textContent = 'No graph data to analyze yet.';
1404
+ container.appendChild(empty);
1405
+ }
1406
+ }
1407
+
1408
+ // ---------------------------------------------------------------------------
1409
+ // Initialization
1410
+ // ---------------------------------------------------------------------------
1411
+
1412
+ document.addEventListener('DOMContentLoaded', async function () {
1413
+ console.log('[laminark] Initializing application');
1414
+
1415
+ await initProjectSelector();
1416
+ initNavigation();
1417
+ initFilters();
1418
+ initTimeRange();
1419
+ initDetailPanel();
1420
+ initPathDetailPanel();
1421
+ initSearch();
1422
+ initAnalysis();
1423
+ connectSSE();
1424
+
1425
+ // Initialize activity feed
1426
+ if (window.laminarkActivity) {
1427
+ window.laminarkActivity.initActivityFeed();
1428
+ }
1429
+
1430
+ // Initialize settings
1431
+ if (window.laminarkSettings) {
1432
+ window.laminarkSettings.initSettings();
1433
+ }
1434
+
1435
+ // Fetch initial data
1436
+ const [graphData, timelineData] = await Promise.all([
1437
+ fetchGraphData(),
1438
+ fetchTimelineData(),
1439
+ ]);
1440
+
1441
+ window.laminarkState.graph = graphData;
1442
+ window.laminarkState.timeline = timelineData;
1443
+
1444
+ console.log('[laminark] Initial data loaded:', {
1445
+ nodes: graphData.nodes.length,
1446
+ edges: graphData.edges.length,
1447
+ sessions: timelineData.sessions.length,
1448
+ observations: timelineData.observations.length,
1449
+ });
1450
+
1451
+ // Initialize the knowledge graph (active by default)
1452
+ if (window.laminarkGraph) {
1453
+ window.laminarkGraph.initGraph('cy');
1454
+ await window.laminarkGraph.loadGraphData();
1455
+ window.laminarkState.graphInitialized = true;
1456
+ }
1457
+
1458
+ // Initialize timeline module (lazy -- waits for tab click, or pre-init if timeline tab is active)
1459
+ var activeTab = document.querySelector('.nav-tab.active');
1460
+ if (activeTab && activeTab.getAttribute('data-view') === 'timeline-view') {
1461
+ if (window.laminarkTimeline) {
1462
+ window.laminarkTimeline.initTimeline('timeline-view');
1463
+ window.laminarkTimeline.loadTimelineData();
1464
+ window.laminarkState.timelineInitialized = true;
1465
+ }
1466
+ }
1467
+
1468
+ // Listen for SSE-dispatched events for graph updates (batched for performance)
1469
+ document.addEventListener('laminark:entity_updated', function (e) {
1470
+ if (!window.laminarkGraph) return;
1471
+ var data = e.detail;
1472
+ // Only add entities belonging to the currently selected project
1473
+ if (data && data.id && data.projectHash && data.projectHash === window.laminarkState.currentProject) {
1474
+ window.laminarkGraph.queueBatchUpdate({
1475
+ type: 'addNode',
1476
+ data: {
1477
+ id: data.id,
1478
+ label: data.label || data.name,
1479
+ type: data.type,
1480
+ observationCount: data.observationCount || 0,
1481
+ createdAt: data.createdAt,
1482
+ },
1483
+ });
1484
+ }
1485
+ });
1486
+
1487
+ document.addEventListener('laminark:new_observation', function () {
1488
+ // Refresh observation counts by reloading graph data (already project-filtered at SSE dispatch)
1489
+ if (window.laminarkGraph) {
1490
+ var filters = getActiveFilters();
1491
+ window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
1492
+ }
1493
+ });
1494
+
1495
+ // Filter change handler for graph (legacy listener)
1496
+ document.addEventListener('laminark:filter_change', function () {
1497
+ // Filter changes now handled directly in initFilters via graph.filterByType/resetFilters
1498
+ // This listener remains for any external consumers
1499
+ });
1500
+
1501
+ // After initial graph load, update filter pill counts
1502
+ setTimeout(function () {
1503
+ if (window.laminarkGraph && window.laminarkGraph.updateFilterCounts) {
1504
+ window.laminarkGraph.updateFilterCounts();
1505
+ }
1506
+ }, 1000);
1507
+ });
1508
+
1509
+ // ---------------------------------------------------------------------------
1510
+ // Timeline rendering
1511
+ // ---------------------------------------------------------------------------
1512
+
1513
+ function renderTimeline(data) {
1514
+ const container = document.getElementById('timeline-content');
1515
+ if (!container) return;
1516
+
1517
+ if (!data.sessions.length && !data.observations.length) {
1518
+ container.innerHTML = '<p class="empty-state">No timeline data yet. Observations will appear here as they are captured.</p>';
1519
+ return;
1520
+ }
1521
+
1522
+ let html = '';
1523
+
1524
+ // Group observations by session
1525
+ const sessionMap = new Map();
1526
+ data.sessions.forEach(function (s) { sessionMap.set(s.id, s); });
1527
+
1528
+ // Observations without sessions
1529
+ const ungrouped = data.observations.filter(function (o) { return !o.sessionId; });
1530
+
1531
+ // Build session groups
1532
+ if (data.sessions.length > 0) {
1533
+ data.sessions.forEach(function (session) {
1534
+ const sessionObs = data.observations.filter(function (o) { return o.sessionId === session.id; });
1535
+ const shifts = data.topicShifts.filter(function (ts) {
1536
+ return ts.timestamp >= session.startedAt && (!session.endedAt || ts.timestamp <= session.endedAt);
1537
+ });
1538
+
1539
+ html += '<div class="timeline-session">';
1540
+ html += '<div class="timeline-session-header">';
1541
+ html += '<span class="session-time">' + formatTime(session.startedAt) + '</span>';
1542
+ html += '<span class="session-summary">' + escapeHtml(session.summary || 'Session ' + session.id.substring(0, 8)) + '</span>';
1543
+ html += '</div>';
1544
+
1545
+ // Interleave observations and topic shifts by time
1546
+ const items = [];
1547
+ sessionObs.forEach(function (o) { items.push({ time: o.createdAt, type: 'obs', data: o }); });
1548
+ shifts.forEach(function (s) { items.push({ time: s.timestamp, type: 'shift', data: s }); });
1549
+ items.sort(function (a, b) { return a.time.localeCompare(b.time); });
1550
+
1551
+ items.forEach(function (item) {
1552
+ if (item.type === 'obs') {
1553
+ const text = item.data.text.length > 300 ? item.data.text.substring(0, 300) + '...' : item.data.text;
1554
+ html += '<div class="timeline-observation">';
1555
+ html += '<span class="obs-time">' + formatTime(item.data.createdAt) + '</span>';
1556
+ html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
1557
+ html += '</div>';
1558
+ } else {
1559
+ html += '<div class="timeline-topic-shift">';
1560
+ html += '\u21BB Topic shift detected';
1561
+ if (item.data.confidence != null) {
1562
+ html += ' (confidence: ' + (item.data.confidence * 100).toFixed(0) + '%)';
1563
+ }
1564
+ html += '</div>';
1565
+ }
1566
+ });
1567
+
1568
+ html += '</div>';
1569
+ });
1570
+ }
1571
+
1572
+ // Ungrouped observations
1573
+ if (ungrouped.length > 0) {
1574
+ html += '<div class="timeline-session">';
1575
+ html += '<div class="timeline-session-header">';
1576
+ html += '<span class="session-summary">Ungrouped observations</span>';
1577
+ html += '</div>';
1578
+ ungrouped.forEach(function (obs) {
1579
+ const text = obs.text.length > 300 ? obs.text.substring(0, 300) + '...' : obs.text;
1580
+ html += '<div class="timeline-observation">';
1581
+ html += '<span class="obs-time">' + formatTime(obs.createdAt) + '</span>';
1582
+ html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
1583
+ html += '</div>';
1584
+ });
1585
+ html += '</div>';
1586
+ }
1587
+
1588
+ container.innerHTML = html;
1589
+ }
1590
+
1591
+ function formatTime(isoString) {
1592
+ if (!isoString) return '';
1593
+ try {
1594
+ const d = new Date(isoString);
1595
+ return d.toLocaleString();
1596
+ } catch {
1597
+ return isoString;
1598
+ }
1599
+ }
1600
+
1601
+ // Export helpers for potential use by graph/timeline modules
1602
+ window.laminarkApp = {
1603
+ fetchGraphData: fetchGraphData,
1604
+ fetchTimelineData: fetchTimelineData,
1605
+ fetchNodeDetails: fetchNodeDetails,
1606
+ fetchProjects: fetchProjects,
1607
+ fetchPaths: fetchPaths,
1608
+ fetchPathDetail: fetchPathDetail,
1609
+ showNodeDetails: showNodeDetails,
1610
+ showPathDetails: showPathDetails,
1611
+ getActiveFilters: getActiveFilters,
1612
+ };