@andespindola/brainlink 0.1.0-beta.2 → 0.1.0-beta.21

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 (56) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +50 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +158 -20
  6. package/SECURITY.md +1 -1
  7. package/dist/application/add-note.js +62 -13
  8. package/dist/application/analyze-vault.js +95 -8
  9. package/dist/application/frontend/client-css.js +205 -99
  10. package/dist/application/frontend/client-html.js +58 -45
  11. package/dist/application/frontend/client-js.js +416 -85
  12. package/dist/application/get-graph-layout.js +22 -7
  13. package/dist/application/get-graph-node.js +12 -0
  14. package/dist/application/get-graph-summary.js +12 -0
  15. package/dist/application/get-graph.js +3 -3
  16. package/dist/application/index-vault.js +11 -4
  17. package/dist/application/list-agents.js +3 -3
  18. package/dist/application/list-links.js +5 -5
  19. package/dist/application/migrate-vault.js +91 -0
  20. package/dist/application/search-graph-node-ids.js +12 -0
  21. package/dist/application/search-knowledge.js +75 -5
  22. package/dist/application/server/routes.js +27 -1
  23. package/dist/benchmarks/large-vault.js +1 -1
  24. package/dist/cli/commands/agent-commands.js +412 -0
  25. package/dist/cli/commands/config-commands.js +167 -0
  26. package/dist/cli/commands/read-commands.js +25 -8
  27. package/dist/cli/commands/write-commands.js +173 -4
  28. package/dist/cli/main.js +4 -0
  29. package/dist/cli/runtime.js +5 -2
  30. package/dist/domain/context.js +53 -11
  31. package/dist/domain/embeddings.js +2 -1
  32. package/dist/domain/graph-layout.js +20 -14
  33. package/dist/domain/markdown.js +36 -4
  34. package/dist/domain/middle-out.js +18 -0
  35. package/dist/infrastructure/config.js +94 -8
  36. package/dist/infrastructure/file-index.js +294 -0
  37. package/dist/infrastructure/file-system-vault.js +15 -0
  38. package/dist/infrastructure/paths.js +9 -1
  39. package/dist/infrastructure/private-pack-codec.js +73 -0
  40. package/dist/infrastructure/search-packs.js +348 -0
  41. package/dist/infrastructure/session-state.js +172 -0
  42. package/dist/mcp/main.js +11 -3
  43. package/dist/mcp/server.js +17 -2
  44. package/dist/mcp/startup.js +35 -0
  45. package/dist/mcp/tools.js +571 -19
  46. package/docs/AGENT_USAGE.md +99 -15
  47. package/docs/ARCHITECTURE.md +37 -26
  48. package/docs/QUICKSTART.md +104 -0
  49. package/docs/RELEASE.md +3 -3
  50. package/package.json +2 -3
  51. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  52. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  53. package/dist/infrastructure/sqlite/schema.js +0 -111
  54. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  55. package/dist/infrastructure/sqlite/types.js +0 -1
  56. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,17 +1,30 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
2
  const ctx = canvas.getContext('2d')
3
+ const largeGraphNodeThreshold = 4000
4
+ const largeGraphEdgeRenderLimit = 16000
5
+ const renderNodeBudget = 1800
6
+ const minNodePixelRadius = 1.8
7
+ const viewportPaddingPx = 280
3
8
  const state = {
4
9
  graph: { nodes: [], edges: [] },
5
10
  nodes: [],
6
11
  edges: [],
12
+ visibleNodes: [],
13
+ visibleEdges: [],
14
+ renderNodes: [],
15
+ renderEdges: [],
16
+ nodeDegrees: new Map(),
7
17
  selected: null,
8
18
  hovered: null,
9
19
  query: '',
20
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
21
  agentId: '',
11
22
  agentsSignature: '',
23
+ nodeDetails: new Map(),
12
24
  transform: { x: 0, y: 0, scale: 1 },
13
25
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
14
26
  graphSignature: '',
27
+ graphStatus: '',
15
28
  last: performance.now()
16
29
  }
17
30
 
@@ -23,26 +36,40 @@ const escapeHtml = value => String(value)
23
36
  .replaceAll('"', '"')
24
37
  .replaceAll("'", ''')
25
38
  const elements = {
26
- stats: byId('stats'),
27
39
  search: byId('search'),
28
40
  agent: byId('agent'),
29
- title: byId('title'),
30
- path: byId('path'),
31
- tags: byId('tags'),
32
- notes: byId('notes'),
33
- content: byId('content'),
34
- outgoing: byId('outgoing'),
35
- incoming: byId('incoming'),
36
41
  nodeCount: byId('nodeCount'),
37
42
  edgeCount: byId('edgeCount'),
38
43
  tagCount: byId('tagCount'),
39
44
  zoomIn: byId('zoomIn'),
40
45
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
46
+ fit: byId('fit'),
47
+ reset: byId('reset'),
48
+ contentDialog: byId('contentDialog'),
49
+ contentTitle: byId('contentTitle'),
50
+ contentPath: byId('contentPath'),
51
+ contentTags: byId('contentTags'),
52
+ contentOutgoing: byId('contentOutgoing'),
53
+ contentIncoming: byId('contentIncoming'),
54
+ contentBody: byId('contentBody'),
55
+ contentClose: byId('contentClose')
56
+ }
57
+
58
+ const zoomRange = {
59
+ min: 0.05,
60
+ max: 4.5
42
61
  }
43
62
 
44
63
  const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
45
64
 
65
+ const setGraphStatus = text => {
66
+ state.graphStatus = text
67
+ }
68
+
69
+ const handleGraphRefreshError = error => {
70
+ console.error(error)
71
+ }
72
+
46
73
  const graphTheme = {
47
74
  node: '#aeb8c5',
48
75
  nodeSelected: '#f3f7fb',
@@ -66,30 +93,110 @@ const resize = () => {
66
93
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
67
94
  }
68
95
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
96
+ const normalizeQuery = value => value.trim().toLowerCase()
97
+
98
+ const localFilteredNodes = query =>
99
+ state.nodes.filter(node =>
73
100
  node.title.toLowerCase().includes(query) ||
74
101
  node.path.toLowerCase().includes(query) ||
75
102
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
103
  )
77
- }
78
104
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
105
+ const filteredNodes = () => {
106
+ const query = normalizeQuery(state.query)
107
+ if (!query) return state.nodes
108
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
109
+ return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
110
+ }
80
111
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
112
+ return localFilteredNodes(query)
113
+ }
114
+
115
+ const recomputeVisibility = () => {
116
+ const nodes = filteredNodes()
117
+ const ids = new Set(nodes.map(node => node.id))
118
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
119
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
120
+ ? [...edges]
121
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
122
+ .slice(0, largeGraphEdgeRenderLimit)
123
+ : edges
124
+
125
+ state.visibleNodes = nodes
126
+ state.visibleEdges = limitedEdges
84
127
  }
85
128
 
86
129
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
87
130
 
88
- const resetView = () => {
131
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
132
+
133
+ const graphBounds = nodes => {
134
+ if (nodes.length === 0) return null
135
+ let minX = Number.POSITIVE_INFINITY
136
+ let maxX = Number.NEGATIVE_INFINITY
137
+ let minY = Number.POSITIVE_INFINITY
138
+ let maxY = Number.NEGATIVE_INFINITY
139
+
140
+ nodes.forEach(node => {
141
+ const radius = baseNodeRadius(node)
142
+ minX = Math.min(minX, node.x - radius)
143
+ maxX = Math.max(maxX, node.x + radius)
144
+ minY = Math.min(minY, node.y - radius)
145
+ maxY = Math.max(maxY, node.y + radius)
146
+ })
147
+
148
+ return {
149
+ minX,
150
+ maxX,
151
+ minY,
152
+ maxY,
153
+ width: Math.max(maxX - minX, 1),
154
+ height: Math.max(maxY - minY, 1)
155
+ }
156
+ }
157
+
158
+ const fitScaleBiasByNodeCount = nodeCount => {
159
+ if (nodeCount <= 6) return 2.4
160
+ if (nodeCount <= 20) return 1.9
161
+ if (nodeCount <= 60) return 1.5
162
+ if (nodeCount <= 180) return 1.25
163
+ if (nodeCount <= 600) return 1.05
164
+ if (nodeCount <= 2000) return 0.9
165
+ if (nodeCount <= 6000) return 0.72
166
+ return 0.62
167
+ }
168
+
169
+ const fitView = (options = { useFiltered: true }) => {
89
170
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
171
+ const width = Math.max(rect.width, 320)
172
+ const height = Math.max(rect.height, 320)
173
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
174
+ const bounds = graphBounds(nodes)
175
+
176
+ if (!bounds) {
177
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
178
+ return
179
+ }
180
+
181
+ const padding = 100
182
+ const scaleX = width / (bounds.width + padding * 2)
183
+ const scaleY = height / (bounds.height + padding * 2)
184
+ const fitScale = clampScale(Math.min(scaleX, scaleY))
185
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
186
+ const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
187
+ const scale = Math.max(biasedScale, minimumLargeGraphScale)
188
+ const centerX = (bounds.minX + bounds.maxX) / 2
189
+ const centerY = (bounds.minY + bounds.maxY) / 2
190
+
191
+ state.transform = {
192
+ x: width / 2 - centerX * scale,
193
+ y: height / 2 - centerY * scale,
194
+ scale
195
+ }
91
196
  }
92
197
 
198
+ const resetView = () => fitView({ useFiltered: false })
199
+
93
200
  const createLayout = graph => {
94
201
  const nodes = graph.nodes.map(node => ({
95
202
  ...node,
@@ -111,18 +218,79 @@ const encodeEntityTag = (value) => {
111
218
  binary += String.fromCharCode(utf8[index])
112
219
  }
113
220
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
221
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
222
  }
116
223
 
117
224
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
225
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
226
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
227
  })
121
228
 
229
+ const resetContentFilter = () => {
230
+ if (state.contentFilter.timer) {
231
+ clearTimeout(state.contentFilter.timer)
232
+ }
233
+ state.contentFilter = {
234
+ query: '',
235
+ ids: null,
236
+ token: state.contentFilter.token + 1,
237
+ timer: null
238
+ }
239
+ recomputeVisibility()
240
+ }
241
+
242
+ const syncContentFilter = async (query, token) => {
243
+ const response = await fetch(
244
+ '/api/graph-filter?q=' +
245
+ encodeURIComponent(query) +
246
+ '&limit=' +
247
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
248
+ agentQuery()
249
+ )
250
+
251
+ if (!response.ok || token !== state.contentFilter.token) {
252
+ return
253
+ }
254
+
255
+ const payload = await response.json()
256
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
257
+ if (token !== state.contentFilter.token) {
258
+ return
259
+ }
260
+
261
+ state.contentFilter.query = query
262
+ state.contentFilter.ids = new Set(nodeIds)
263
+ recomputeVisibility()
264
+ }
265
+
266
+ const scheduleContentFilterSync = () => {
267
+ const query = normalizeQuery(state.query)
268
+ if (!query) {
269
+ resetContentFilter()
270
+ return
271
+ }
272
+
273
+ if (state.contentFilter.timer) {
274
+ clearTimeout(state.contentFilter.timer)
275
+ }
276
+
277
+ const token = state.contentFilter.token + 1
278
+ state.contentFilter = {
279
+ query: state.contentFilter.query,
280
+ ids: state.contentFilter.ids,
281
+ token,
282
+ timer: setTimeout(() => {
283
+ syncContentFilter(query, token).catch(() => {})
284
+ }, 180)
285
+ }
286
+ }
287
+
122
288
  const tick = delta => {
123
- const nodes = filteredNodes()
124
- const ids = new Set(nodes.map(node => node.id))
125
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
289
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
290
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
291
+ if (nodes.length > 1200) {
292
+ return
293
+ }
126
294
  const strength = Math.min(delta / 16, 2)
127
295
 
128
296
  edges.forEach(edge => {
@@ -181,7 +349,11 @@ const worldPoint = event => {
181
349
  }
182
350
 
183
351
  const hitNode = point => {
184
- const nodes = filteredNodes()
352
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
353
+ return null
354
+ }
355
+
356
+ const nodes = state.renderNodes
185
357
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
358
  const node = nodes[index]
187
359
  const radius = nodeRadius(node)
@@ -190,14 +362,92 @@ const hitNode = point => {
190
362
  return null
191
363
  }
192
364
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
365
+ const baseNodeRadius = node => {
366
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
367
  return 9 + Math.min(degree, 8) * 1.6
196
368
  }
197
369
 
370
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
371
+
372
+ const worldViewportBounds = () => {
373
+ const rect = canvas.getBoundingClientRect()
374
+ const width = Math.max(rect.width, 320)
375
+ const height = Math.max(rect.height, 320)
376
+ const padding = viewportPaddingPx
377
+
378
+ return {
379
+ minX: (-state.transform.x - padding) / state.transform.scale,
380
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
381
+ minY: (-state.transform.y - padding) / state.transform.scale,
382
+ maxY: (height - state.transform.y + padding) / state.transform.scale
383
+ }
384
+ }
385
+
386
+ const isNodeInViewport = (node, viewport) =>
387
+ node.x >= viewport.minX &&
388
+ node.x <= viewport.maxX &&
389
+ node.y >= viewport.minY &&
390
+ node.y <= viewport.maxY
391
+
392
+ const viewportNodeStride = () => {
393
+ if (state.nodes.length <= largeGraphNodeThreshold) {
394
+ return 1
395
+ }
396
+
397
+ if (state.transform.scale >= 0.95) {
398
+ return 1
399
+ }
400
+ if (state.transform.scale >= 0.7) {
401
+ return 2
402
+ }
403
+ if (state.transform.scale >= 0.48) {
404
+ return 3
405
+ }
406
+ if (state.transform.scale >= 0.28) {
407
+ return 5
408
+ }
409
+
410
+ return 8
411
+ }
412
+
413
+ const computeRenderVisibility = () => {
414
+ const viewport = worldViewportBounds()
415
+ const stride = viewportNodeStride()
416
+ const picked = []
417
+
418
+ for (let index = 0; index < state.visibleNodes.length; index += 1) {
419
+ const node = state.visibleNodes[index]
420
+ if (!isNodeInViewport(node, viewport)) {
421
+ continue
422
+ }
423
+
424
+ const isPriority =
425
+ node.id === state.selected?.id ||
426
+ node.id === state.hovered?.id ||
427
+ node.id === state.pointer.dragNode?.id
428
+ if (isPriority || index % stride === 0) {
429
+ picked.push(node)
430
+ }
431
+ }
432
+
433
+ const nodes = picked.length > renderNodeBudget
434
+ ? picked.slice(0, renderNodeBudget)
435
+ : picked
436
+ const nodeIds = new Set(nodes.map((node) => node.id))
437
+ const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
438
+
439
+ state.renderNodes = nodes
440
+ state.renderEdges = edges
441
+ }
442
+
198
443
  const render = now => {
199
444
  const delta = now - state.last
200
445
  state.last = now
446
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
447
+ if (delta < minFrameIntervalMs) {
448
+ requestAnimationFrame(render)
449
+ return
450
+ }
201
451
  const rect = canvas.getBoundingClientRect()
202
452
  const width = Math.max(rect.width, 320)
203
453
  const height = Math.max(rect.height, 320)
@@ -214,7 +464,11 @@ const render = now => {
214
464
  ctx.translate(state.transform.x, state.transform.y)
215
465
  ctx.scale(state.transform.scale, state.transform.scale)
216
466
 
217
- visibleEdges().forEach(edge => {
467
+ computeRenderVisibility()
468
+ tick(delta)
469
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
470
+ if (drawEdges) {
471
+ state.renderEdges.forEach(edge => {
218
472
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
473
  ctx.beginPath()
220
474
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +476,10 @@ const render = now => {
222
476
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
477
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
478
  ctx.stroke()
225
- })
479
+ })
480
+ }
226
481
 
227
- filteredNodes().forEach(node => {
482
+ state.renderNodes.forEach(node => {
228
483
  const radius = nodeRadius(node)
229
484
  const isSelected = state.selected?.id === node.id
230
485
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +495,11 @@ const render = now => {
240
495
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
496
  ctx.stroke()
242
497
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
498
+ const shouldDrawLabels =
499
+ isSelected ||
500
+ isHovered ||
501
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
502
+ if (shouldDrawLabels) {
244
503
  ctx.fillStyle = graphTheme.label
245
504
  ctx.font = '12px Inter, system-ui, sans-serif'
246
505
  ctx.textAlign = 'center'
@@ -250,6 +509,12 @@ const render = now => {
250
509
  })
251
510
 
252
511
  ctx.restore()
512
+ if (state.renderNodes.length === 0) {
513
+ ctx.fillStyle = '#99a5b5'
514
+ ctx.font = '12px Inter, system-ui, sans-serif'
515
+ ctx.textAlign = 'center'
516
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
517
+ }
253
518
  requestAnimationFrame(render)
254
519
  }
255
520
 
@@ -257,22 +522,7 @@ const list = items => items.length
257
522
  ? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + (item.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : '') + '</small></li>').join('')
258
523
  : '<li><small>No links found.</small></li>'
259
524
 
260
- const allNotesList = () => state.nodes.length
261
- ? state.nodes.map(node => '<li><button type="button" data-node-id="' + escapeHtml(node.id) + '">' + escapeHtml(node.title) + '</button><small>' + escapeHtml(node.path) + '</small></li>').join('')
262
- : '<li><small>No notes indexed.</small></li>'
263
-
264
- const selectNode = node => {
265
- state.selected = node
266
- if (!node) {
267
- elements.title.textContent = 'Graph Overview'
268
- elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
269
- elements.tags.innerHTML = ''
270
- elements.notes.innerHTML = allNotesList()
271
- elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
272
- elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
273
- elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
274
- return
275
- }
525
+ const linkedNodes = node => {
276
526
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
527
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
528
  ...linkedNode,
@@ -288,56 +538,129 @@ const selectNode = node => {
288
538
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
539
  .filter(Boolean)
290
540
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
541
+ return { outgoing, incoming }
542
+ }
543
+
544
+ const fetchNodeDetails = async node => {
545
+ const cached = state.nodeDetails.get(node.id)
546
+ if (cached) {
547
+ return cached
548
+ }
549
+
550
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
551
+ if (!response.ok) {
552
+ throw new Error('Failed to load graph node details')
553
+ }
554
+
555
+ const payload = await response.json()
556
+ const detail = payload?.node
557
+ if (!detail || !detail.id) {
558
+ throw new Error('Invalid graph node payload')
559
+ }
560
+ state.nodeDetails.set(detail.id, detail)
561
+ return detail
562
+ }
563
+
564
+ const openContentDialog = async node => {
565
+ if (!node) return
566
+ const { outgoing, incoming } = linkedNodes(node)
567
+ elements.contentTitle.textContent = node.title
568
+ elements.contentPath.textContent = node.path
569
+ elements.contentTags.innerHTML = node.tags.length
294
570
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
571
  : '<span>No tags</span>'
296
- elements.notes.innerHTML = allNotesList()
297
- elements.content.textContent = node.content
298
- elements.outgoing.innerHTML = list(outgoing)
299
- elements.incoming.innerHTML = list(incoming)
572
+ elements.contentOutgoing.innerHTML = list(outgoing)
573
+ elements.contentIncoming.innerHTML = list(incoming)
574
+ elements.contentBody.textContent = 'Loading note content...'
575
+ if (!elements.contentDialog.open) {
576
+ elements.contentDialog.showModal()
577
+ }
578
+
579
+ try {
580
+ const detailedNode = await fetchNodeDetails(node)
581
+ if (state.selected?.id !== node.id) {
582
+ return
583
+ }
584
+ elements.contentBody.textContent = detailedNode.content
585
+ } catch {
586
+ elements.contentBody.textContent = 'Unable to load note content.'
587
+ }
588
+ }
589
+
590
+ const selectNode = (node, options = { openContent: false }) => {
591
+ state.selected = node
592
+ if (node && options.openContent) {
593
+ openContentDialog(node).catch(() => {
594
+ elements.contentBody.textContent = 'Unable to load note content.'
595
+ })
596
+ }
300
597
  }
301
598
 
302
599
  const selectNodeById = id => {
303
600
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
601
+ if (node) selectNode(node, { openContent: true })
305
602
  }
306
603
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
604
+ const zoomAtPoint = (screenX, screenY, factor) => {
605
+ const nextScale = clampScale(state.transform.scale * factor)
606
+ if (nextScale === state.transform.scale) return
607
+ const worldX = (screenX - state.transform.x) / state.transform.scale
608
+ const worldY = (screenY - state.transform.y) / state.transform.scale
609
+ state.transform.scale = nextScale
610
+ state.transform.x = screenX - worldX * nextScale
611
+ state.transform.y = screenY - worldY * nextScale
309
612
  }
310
613
 
311
614
  const bindEvents = () => {
312
615
  window.addEventListener('resize', resize)
313
616
  elements.search.addEventListener('input', event => {
314
617
  state.query = event.target.value
315
- elements.stats.textContent = state.query
316
- ? filteredNodes().length + ' filtered notes'
317
- : state.nodes.length + ' notes · ' + state.edges.length + ' links'
618
+ recomputeVisibility()
619
+ scheduleContentFilterSync()
318
620
  })
319
621
  elements.agent.addEventListener('change', event => {
320
622
  state.agentId = event.target.value
321
623
  state.selected = null
624
+ state.nodeDetails = new Map()
625
+ resetContentFilter()
626
+ recomputeVisibility()
627
+ scheduleContentFilterSync()
322
628
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
629
  console.error(error)
325
630
  })
326
631
  })
327
- elements.zoomIn.addEventListener('click', () => zoom(1.18))
328
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
329
- elements.reset.addEventListener('click', resetView)
330
- ;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
331
- element.addEventListener('click', event => {
332
- const target = event.target
333
- if (!(target instanceof HTMLElement)) return
334
- const nodeId = target.dataset.nodeId
335
- if (nodeId) selectNodeById(nodeId)
632
+ elements.zoomIn.addEventListener('click', () => {
633
+ const rect = canvas.getBoundingClientRect()
634
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
635
+ })
636
+ elements.zoomOut.addEventListener('click', () => {
637
+ const rect = canvas.getBoundingClientRect()
638
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
639
+ })
640
+ if (elements.fit) {
641
+ elements.fit.addEventListener('click', () => {
642
+ fitView({ useFiltered: true })
336
643
  })
644
+ }
645
+ elements.reset.addEventListener('click', () => {
646
+ resetView()
647
+ })
648
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
649
+ elements.contentDialog.addEventListener('click', event => {
650
+ const target = event.target
651
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
652
+ selectNodeById(target.dataset.nodeId)
653
+ return
654
+ }
655
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
337
656
  })
338
657
  canvas.addEventListener('wheel', event => {
339
658
  event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
659
+ const rect = canvas.getBoundingClientRect()
660
+ const cursorX = event.clientX - rect.left
661
+ const cursorY = event.clientY - rect.top
662
+ const factor = event.deltaY < 0 ? 1.08 : 0.92
663
+ zoomAtPoint(cursorX, cursorY, factor)
341
664
  }, { passive: false })
342
665
  canvas.addEventListener('pointerdown', event => {
343
666
  const point = worldPoint(event)
@@ -367,8 +690,8 @@ const bindEvents = () => {
367
690
  state.transform.y += dy
368
691
  })
369
692
  canvas.addEventListener('pointerup', event => {
370
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
371
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
693
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
694
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
695
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
696
  canvas.releasePointerCapture(event.pointerId)
374
697
  })
@@ -417,14 +740,29 @@ const loadGraph = async (options = { reset: false }) => {
417
740
  state.graph = graph
418
741
  state.nodes = layout.nodes
419
742
  state.edges = layout.edges
743
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
744
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
745
+ if (edge.target) {
746
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
747
+ }
748
+ return degrees
749
+ }, new Map())
750
+ state.nodeDetails = new Map()
751
+ resetContentFilter()
752
+ recomputeVisibility()
753
+ scheduleContentFilterSync()
420
754
  const tags = new Set(graph.nodes.flatMap(node => node.tags))
421
- elements.stats.textContent = state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live'
755
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
756
  elements.nodeCount.textContent = graph.nodes.length
423
757
  elements.edgeCount.textContent = graph.edges.length
424
758
  elements.tagCount.textContent = tags.size
425
759
  resize()
426
760
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
761
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
762
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
763
+ if (!selectedNode && elements.contentDialog.open) {
764
+ elements.contentDialog.close()
765
+ }
428
766
  }
429
767
 
430
768
  bindEvents()
@@ -441,10 +779,7 @@ const refreshGraphLoop = () => {
441
779
  return
442
780
  }
443
781
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
782
+ loadGraph().catch(handleGraphRefreshError)
448
783
 
449
784
  tickCounter += 1
450
785
  if (tickCounter % 3 === 0) {
@@ -461,7 +796,6 @@ loadAgents()
461
796
  setInterval(refreshGraphLoop, pollIntervalMs)
462
797
  })
463
798
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
799
  console.error(error)
466
800
  })
467
801
 
@@ -470,9 +804,6 @@ document.addEventListener('visibilitychange', () => {
470
804
  return
471
805
  }
472
806
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
807
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
808
  })
478
809
  `;