@andespindola/brainlink 0.1.0-beta.3 → 0.1.0-beta.31

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 +37 -3
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +172 -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 +214 -100
  10. package/dist/application/frontend/client-html.js +60 -45
  11. package/dist/application/frontend/client-js.js +553 -91
  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/import-legacy-sqlite.js +296 -0
  17. package/dist/application/index-vault.js +11 -4
  18. package/dist/application/list-agents.js +3 -3
  19. package/dist/application/list-links.js +5 -5
  20. package/dist/application/migrate-vault.js +91 -0
  21. package/dist/application/search-graph-node-ids.js +12 -0
  22. package/dist/application/search-knowledge.js +75 -5
  23. package/dist/application/server/routes.js +27 -1
  24. package/dist/benchmarks/large-vault.js +1 -1
  25. package/dist/cli/commands/agent-commands.js +412 -0
  26. package/dist/cli/commands/config-commands.js +167 -0
  27. package/dist/cli/commands/read-commands.js +25 -8
  28. package/dist/cli/commands/write-commands.js +205 -4
  29. package/dist/cli/main.js +4 -0
  30. package/dist/cli/runtime.js +5 -2
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +20 -14
  34. package/dist/domain/markdown.js +36 -4
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +94 -8
  37. package/dist/infrastructure/file-index.js +294 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/paths.js +9 -1
  40. package/dist/infrastructure/private-pack-codec.js +73 -0
  41. package/dist/infrastructure/search-packs.js +348 -0
  42. package/dist/infrastructure/session-state.js +172 -0
  43. package/dist/mcp/main.js +11 -3
  44. package/dist/mcp/server.js +17 -2
  45. package/dist/mcp/startup.js +35 -0
  46. package/dist/mcp/tools.js +571 -19
  47. package/docs/AGENT_USAGE.md +112 -16
  48. package/docs/ARCHITECTURE.md +37 -26
  49. package/docs/QUICKSTART.md +111 -0
  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,31 @@
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 },
26
+ cursor: { x: 0, y: 0, inCanvas: false },
14
27
  graphSignature: '',
28
+ graphStatus: '',
15
29
  last: performance.now()
16
30
  }
17
31
 
@@ -23,26 +37,40 @@ const escapeHtml = value => String(value)
23
37
  .replaceAll('"', '"')
24
38
  .replaceAll("'", ''')
25
39
  const elements = {
26
- stats: byId('stats'),
27
40
  search: byId('search'),
28
41
  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
42
  nodeCount: byId('nodeCount'),
37
43
  edgeCount: byId('edgeCount'),
38
44
  tagCount: byId('tagCount'),
39
45
  zoomIn: byId('zoomIn'),
40
46
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
47
+ fit: byId('fit'),
48
+ reset: byId('reset'),
49
+ contentDialog: byId('contentDialog'),
50
+ contentTitle: byId('contentTitle'),
51
+ contentPath: byId('contentPath'),
52
+ contentTags: byId('contentTags'),
53
+ contentOutgoing: byId('contentOutgoing'),
54
+ contentIncoming: byId('contentIncoming'),
55
+ contentBody: byId('contentBody'),
56
+ contentClose: byId('contentClose')
57
+ }
58
+
59
+ const zoomRange = {
60
+ min: 0.05,
61
+ max: 4.5
42
62
  }
43
63
 
44
64
  const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
45
65
 
66
+ const setGraphStatus = text => {
67
+ state.graphStatus = text
68
+ }
69
+
70
+ const handleGraphRefreshError = error => {
71
+ console.error(error)
72
+ }
73
+
46
74
  const graphTheme = {
47
75
  node: '#aeb8c5',
48
76
  nodeSelected: '#f3f7fb',
@@ -66,35 +94,138 @@ const resize = () => {
66
94
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
67
95
  }
68
96
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
97
+ const normalizeQuery = value => value.trim().toLowerCase()
98
+
99
+ const localFilteredNodes = query =>
100
+ state.nodes.filter(node =>
73
101
  node.title.toLowerCase().includes(query) ||
74
102
  node.path.toLowerCase().includes(query) ||
75
103
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
104
  )
77
- }
78
105
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
106
+ const filteredNodes = () => {
107
+ const query = normalizeQuery(state.query)
108
+ if (!query) return state.nodes
109
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
110
+ return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
111
+ }
112
+
113
+ return localFilteredNodes(query)
114
+ }
80
115
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
116
+ const recomputeVisibility = () => {
117
+ const nodes = filteredNodes()
118
+ const ids = new Set(nodes.map(node => node.id))
119
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
120
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
121
+ ? [...edges]
122
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
123
+ .slice(0, largeGraphEdgeRenderLimit)
124
+ : edges
125
+
126
+ state.visibleNodes = nodes
127
+ state.visibleEdges = limitedEdges
84
128
  }
85
129
 
86
130
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
87
131
 
88
- const resetView = () => {
132
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
133
+
134
+ const graphBounds = nodes => {
135
+ if (nodes.length === 0) return null
136
+ let minX = Number.POSITIVE_INFINITY
137
+ let maxX = Number.NEGATIVE_INFINITY
138
+ let minY = Number.POSITIVE_INFINITY
139
+ let maxY = Number.NEGATIVE_INFINITY
140
+
141
+ nodes.forEach(node => {
142
+ const radius = baseNodeRadius(node)
143
+ minX = Math.min(minX, node.x - radius)
144
+ maxX = Math.max(maxX, node.x + radius)
145
+ minY = Math.min(minY, node.y - radius)
146
+ maxY = Math.max(maxY, node.y + radius)
147
+ })
148
+
149
+ return {
150
+ minX,
151
+ maxX,
152
+ minY,
153
+ maxY,
154
+ width: Math.max(maxX - minX, 1),
155
+ height: Math.max(maxY - minY, 1)
156
+ }
157
+ }
158
+
159
+ const fitScaleBiasByNodeCount = nodeCount => {
160
+ if (nodeCount <= 6) return 2.8
161
+ if (nodeCount <= 20) return 2.2
162
+ if (nodeCount <= 60) return 1.72
163
+ if (nodeCount <= 180) return 1.34
164
+ if (nodeCount <= 600) return 1.08
165
+ if (nodeCount <= 2000) return 0.9
166
+ if (nodeCount <= 6000) return 0.72
167
+ return 0.58
168
+ }
169
+
170
+ const fitView = (options = { useFiltered: true }) => {
89
171
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
172
+ const width = Math.max(rect.width, 320)
173
+ const height = Math.max(rect.height, 320)
174
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
175
+ const bounds = graphBounds(nodes)
176
+
177
+ if (!bounds) {
178
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
179
+ return
180
+ }
181
+
182
+ const paddingByNodeCount = nodeCount => {
183
+ if (nodeCount <= 6) return 28
184
+ if (nodeCount <= 20) return 44
185
+ if (nodeCount <= 60) return 68
186
+ if (nodeCount <= 180) return 86
187
+ if (nodeCount <= 600) return 110
188
+ if (nodeCount <= 2000) return 140
189
+ return 180
190
+ }
191
+ const minFitScaleByNodeCount = nodeCount => {
192
+ if (nodeCount <= 6) return 2.4
193
+ if (nodeCount <= 20) return 1.8
194
+ if (nodeCount <= 60) return 1.2
195
+ if (nodeCount <= 180) return 0.86
196
+ if (nodeCount <= 600) return 0.58
197
+ if (nodeCount <= 2000) return 0.34
198
+ if (nodeCount <= 6000) return 0.2
199
+ return 0.13
200
+ }
201
+
202
+ const padding = paddingByNodeCount(nodes.length)
203
+ const scaleX = width / (bounds.width + padding * 2)
204
+ const scaleY = height / (bounds.height + padding * 2)
205
+ const fitScale = clampScale(Math.min(scaleX, scaleY))
206
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
207
+ const minimumScale = minFitScaleByNodeCount(nodes.length)
208
+ const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
209
+ const scale = Math.max(biasedScale, minimumScale, minimumLargeGraphScale)
210
+ const centerX = (bounds.minX + bounds.maxX) / 2
211
+ const centerY = (bounds.minY + bounds.maxY) / 2
212
+
213
+ state.transform = {
214
+ x: width / 2 - centerX * scale,
215
+ y: height / 2 - centerY * scale,
216
+ scale
217
+ }
91
218
  }
92
219
 
220
+ const resetView = () => fitView({ useFiltered: false })
221
+
93
222
  const createLayout = graph => {
94
223
  const nodes = graph.nodes.map(node => ({
95
224
  ...node,
96
225
  x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
226
+ y: Number.isFinite(node.y) ? node.y : 0,
227
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
228
+ vy: Number.isFinite(node.vy) ? node.vy : 0
98
229
  }))
99
230
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
231
  const edges = graph.edges
@@ -111,23 +242,88 @@ const encodeEntityTag = (value) => {
111
242
  binary += String.fromCharCode(utf8[index])
112
243
  }
113
244
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
245
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
246
  }
116
247
 
117
248
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
249
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
250
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
251
  })
121
252
 
253
+ const resetContentFilter = () => {
254
+ if (state.contentFilter.timer) {
255
+ clearTimeout(state.contentFilter.timer)
256
+ }
257
+ state.contentFilter = {
258
+ query: '',
259
+ ids: null,
260
+ token: state.contentFilter.token + 1,
261
+ timer: null
262
+ }
263
+ recomputeVisibility()
264
+ }
265
+
266
+ const syncContentFilter = async (query, token) => {
267
+ const response = await fetch(
268
+ '/api/graph-filter?q=' +
269
+ encodeURIComponent(query) +
270
+ '&limit=' +
271
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
272
+ agentQuery()
273
+ )
274
+
275
+ if (!response.ok || token !== state.contentFilter.token) {
276
+ return
277
+ }
278
+
279
+ const payload = await response.json()
280
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
281
+ if (token !== state.contentFilter.token) {
282
+ return
283
+ }
284
+
285
+ state.contentFilter.query = query
286
+ state.contentFilter.ids = new Set(nodeIds)
287
+ recomputeVisibility()
288
+ }
289
+
290
+ const scheduleContentFilterSync = () => {
291
+ const query = normalizeQuery(state.query)
292
+ if (!query) {
293
+ resetContentFilter()
294
+ return
295
+ }
296
+
297
+ if (state.contentFilter.timer) {
298
+ clearTimeout(state.contentFilter.timer)
299
+ }
300
+
301
+ const token = state.contentFilter.token + 1
302
+ state.contentFilter = {
303
+ query: state.contentFilter.query,
304
+ ids: state.contentFilter.ids,
305
+ token,
306
+ timer: setTimeout(() => {
307
+ syncContentFilter(query, token).catch(() => {})
308
+ }, 180)
309
+ }
310
+ }
311
+
122
312
  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))
313
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
314
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
315
+ if (nodes.length > 1200) {
316
+ return
317
+ }
126
318
  const strength = Math.min(delta / 16, 2)
127
319
 
128
320
  edges.forEach(edge => {
129
321
  const source = edge.sourceNode
130
322
  const target = edge.targetNode
323
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
324
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
325
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
326
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
327
  const dx = target.x - source.x
132
328
  const dy = target.y - source.y
133
329
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -144,6 +340,10 @@ const tick = delta => {
144
340
  for (let j = i + 1; j < nodes.length; j += 1) {
145
341
  const a = nodes[i]
146
342
  const b = nodes[j]
343
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
344
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
345
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
346
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
347
  const dx = b.x - a.x
148
348
  const dy = b.y - a.y
149
349
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +358,10 @@ const tick = delta => {
158
358
  }
159
359
 
160
360
  nodes.forEach(node => {
361
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
362
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
363
+ node.x = Number.isFinite(node.x) ? node.x : 0
364
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
365
  if (state.pointer.dragNode === node) {
162
366
  node.vx = 0
163
367
  node.vy = 0
@@ -181,7 +385,11 @@ const worldPoint = event => {
181
385
  }
182
386
 
183
387
  const hitNode = point => {
184
- const nodes = filteredNodes()
388
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
389
+ return null
390
+ }
391
+
392
+ const nodes = state.renderNodes
185
393
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
394
  const node = nodes[index]
187
395
  const radius = nodeRadius(node)
@@ -190,14 +398,118 @@ const hitNode = point => {
190
398
  return null
191
399
  }
192
400
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
401
+ const baseNodeRadius = node => {
402
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
403
  return 9 + Math.min(degree, 8) * 1.6
196
404
  }
197
405
 
406
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
407
+
408
+ const worldViewportBounds = () => {
409
+ const rect = canvas.getBoundingClientRect()
410
+ const width = Math.max(rect.width, 320)
411
+ const height = Math.max(rect.height, 320)
412
+ const padding = viewportPaddingPx
413
+
414
+ return {
415
+ minX: (-state.transform.x - padding) / state.transform.scale,
416
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
417
+ minY: (-state.transform.y - padding) / state.transform.scale,
418
+ maxY: (height - state.transform.y + padding) / state.transform.scale
419
+ }
420
+ }
421
+
422
+ const isNodeInViewport = (node, viewport) =>
423
+ node.x >= viewport.minX &&
424
+ node.x <= viewport.maxX &&
425
+ node.y >= viewport.minY &&
426
+ node.y <= viewport.maxY
427
+
428
+ const viewportNodeStride = () => {
429
+ if (state.nodes.length <= largeGraphNodeThreshold) {
430
+ return 1
431
+ }
432
+
433
+ if (state.transform.scale >= 0.95) {
434
+ return 1
435
+ }
436
+ if (state.transform.scale >= 0.7) {
437
+ return 2
438
+ }
439
+ if (state.transform.scale >= 0.48) {
440
+ return 3
441
+ }
442
+ if (state.transform.scale >= 0.28) {
443
+ return 5
444
+ }
445
+
446
+ return 8
447
+ }
448
+
449
+ const computeRenderVisibility = () => {
450
+ if (state.visibleNodes.length <= 2000) {
451
+ state.renderNodes = state.visibleNodes
452
+ const ids = new Set(state.renderNodes.map((node) => node.id))
453
+ state.renderEdges = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
454
+ return
455
+ }
456
+
457
+ const viewport = worldViewportBounds()
458
+ const stride = viewportNodeStride()
459
+ const picked = []
460
+
461
+ for (let index = 0; index < state.visibleNodes.length; index += 1) {
462
+ const node = state.visibleNodes[index]
463
+ if (!isNodeInViewport(node, viewport)) {
464
+ continue
465
+ }
466
+
467
+ const isPriority =
468
+ node.id === state.selected?.id ||
469
+ node.id === state.hovered?.id ||
470
+ node.id === state.pointer.dragNode?.id
471
+ if (isPriority || index % stride === 0) {
472
+ picked.push(node)
473
+ }
474
+ }
475
+
476
+ const nodes = picked.length > renderNodeBudget
477
+ ? picked.slice(0, renderNodeBudget)
478
+ : picked
479
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
480
+ const centerX = (viewport.minX + viewport.maxX) / 2
481
+ const centerY = (viewport.minY + viewport.maxY) / 2
482
+ const closest = [...state.visibleNodes]
483
+ .sort((left, right) => {
484
+ const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
485
+ const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
486
+ return leftDistance - rightDistance
487
+ })
488
+ .slice(0, Math.min(renderNodeBudget, 180))
489
+ const closestIds = new Set(closest.map((node) => node.id))
490
+
491
+ state.renderNodes = closest
492
+ state.renderEdges = state.visibleEdges.filter(
493
+ (edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
494
+ )
495
+ return
496
+ }
497
+
498
+ const nodeIds = new Set(nodes.map((node) => node.id))
499
+ const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
500
+
501
+ state.renderNodes = nodes
502
+ state.renderEdges = edges
503
+ }
504
+
198
505
  const render = now => {
199
506
  const delta = now - state.last
200
507
  state.last = now
508
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
509
+ if (delta < minFrameIntervalMs) {
510
+ requestAnimationFrame(render)
511
+ return
512
+ }
201
513
  const rect = canvas.getBoundingClientRect()
202
514
  const width = Math.max(rect.width, 320)
203
515
  const height = Math.max(rect.height, 320)
@@ -214,7 +526,11 @@ const render = now => {
214
526
  ctx.translate(state.transform.x, state.transform.y)
215
527
  ctx.scale(state.transform.scale, state.transform.scale)
216
528
 
217
- visibleEdges().forEach(edge => {
529
+ computeRenderVisibility()
530
+ tick(delta)
531
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
532
+ if (drawEdges) {
533
+ state.renderEdges.forEach(edge => {
218
534
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
535
  ctx.beginPath()
220
536
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +538,10 @@ const render = now => {
222
538
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
539
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
540
  ctx.stroke()
225
- })
541
+ })
542
+ }
226
543
 
227
- filteredNodes().forEach(node => {
544
+ state.renderNodes.forEach(node => {
228
545
  const radius = nodeRadius(node)
229
546
  const isSelected = state.selected?.id === node.id
230
547
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +557,11 @@ const render = now => {
240
557
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
558
  ctx.stroke()
242
559
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
560
+ const shouldDrawLabels =
561
+ isSelected ||
562
+ isHovered ||
563
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
564
+ if (shouldDrawLabels) {
244
565
  ctx.fillStyle = graphTheme.label
245
566
  ctx.font = '12px Inter, system-ui, sans-serif'
246
567
  ctx.textAlign = 'center'
@@ -250,6 +571,12 @@ const render = now => {
250
571
  })
251
572
 
252
573
  ctx.restore()
574
+ if (state.renderNodes.length === 0) {
575
+ ctx.fillStyle = '#99a5b5'
576
+ ctx.font = '12px Inter, system-ui, sans-serif'
577
+ ctx.textAlign = 'center'
578
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
579
+ }
253
580
  requestAnimationFrame(render)
254
581
  }
255
582
 
@@ -257,22 +584,7 @@ const list = items => items.length
257
584
  ? 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
585
  : '<li><small>No links found.</small></li>'
259
586
 
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
- }
587
+ const linkedNodes = node => {
276
588
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
589
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
590
  ...linkedNode,
@@ -288,57 +600,168 @@ const selectNode = node => {
288
600
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
601
  .filter(Boolean)
290
602
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
603
+ return { outgoing, incoming }
604
+ }
605
+
606
+ const fetchNodeDetails = async node => {
607
+ const cached = state.nodeDetails.get(node.id)
608
+ if (cached) {
609
+ return cached
610
+ }
611
+
612
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
613
+ if (!response.ok) {
614
+ throw new Error('Failed to load graph node details')
615
+ }
616
+
617
+ const payload = await response.json()
618
+ const detail = payload?.node
619
+ if (!detail || !detail.id) {
620
+ throw new Error('Invalid graph node payload')
621
+ }
622
+ state.nodeDetails.set(detail.id, detail)
623
+ return detail
624
+ }
625
+
626
+ const openContentDialog = async node => {
627
+ if (!node) return
628
+ const { outgoing, incoming } = linkedNodes(node)
629
+ elements.contentTitle.textContent = node.title
630
+ elements.contentPath.textContent = node.path
631
+ elements.contentTags.innerHTML = node.tags.length
294
632
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
633
  : '<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)
634
+ elements.contentOutgoing.innerHTML = list(outgoing)
635
+ elements.contentIncoming.innerHTML = list(incoming)
636
+ elements.contentBody.textContent = 'Loading note content...'
637
+ if (!elements.contentDialog.open) {
638
+ elements.contentDialog.showModal()
639
+ }
640
+
641
+ try {
642
+ const detailedNode = await fetchNodeDetails(node)
643
+ if (state.selected?.id !== node.id) {
644
+ return
645
+ }
646
+ elements.contentBody.textContent = detailedNode.content
647
+ } catch {
648
+ elements.contentBody.textContent = 'Unable to load note content.'
649
+ }
650
+ }
651
+
652
+ const selectNode = (node, options = { openContent: false }) => {
653
+ state.selected = node
654
+ if (node && options.openContent) {
655
+ openContentDialog(node).catch(() => {
656
+ elements.contentBody.textContent = 'Unable to load note content.'
657
+ })
658
+ }
300
659
  }
301
660
 
302
661
  const selectNodeById = id => {
303
662
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
663
+ if (node) selectNode(node, { openContent: true })
305
664
  }
306
665
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
666
+ const zoomAtPoint = (screenX, screenY, factor) => {
667
+ const nextScale = clampScale(state.transform.scale * factor)
668
+ if (nextScale === state.transform.scale) return
669
+ const worldX = (screenX - state.transform.x) / state.transform.scale
670
+ const worldY = (screenY - state.transform.y) / state.transform.scale
671
+ state.transform.scale = nextScale
672
+ state.transform.x = screenX - worldX * nextScale
673
+ state.transform.y = screenY - worldY * nextScale
674
+ }
675
+
676
+ const wheelZoomFactor = event => {
677
+ const isModifierZoom = event.metaKey || event.ctrlKey
678
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
679
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
680
+
681
+ if (absoluteDelta <= 0.0001) {
682
+ return 1
683
+ }
684
+
685
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
686
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
687
+
688
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
689
+ }
690
+
691
+ const isScreenPointInsideCanvas = (screenX, screenY) => {
692
+ const rect = canvas.getBoundingClientRect()
693
+
694
+ return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
695
+ }
696
+
697
+ const handleWheelZoom = event => {
698
+ if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
699
+ return
700
+ }
701
+
702
+ event.preventDefault()
703
+ const rect = canvas.getBoundingClientRect()
704
+ const cursorX = event.clientX - rect.left
705
+ const cursorY = event.clientY - rect.top
706
+ const factor = wheelZoomFactor(event)
707
+
708
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
709
+ return
710
+ }
711
+
712
+ zoomAtPoint(cursorX, cursorY, factor)
309
713
  }
310
714
 
311
715
  const bindEvents = () => {
312
716
  window.addEventListener('resize', resize)
313
717
  elements.search.addEventListener('input', event => {
314
718
  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'
719
+ recomputeVisibility()
720
+ scheduleContentFilterSync()
318
721
  })
319
722
  elements.agent.addEventListener('change', event => {
320
723
  state.agentId = event.target.value
321
724
  state.selected = null
725
+ state.nodeDetails = new Map()
726
+ resetContentFilter()
727
+ recomputeVisibility()
728
+ scheduleContentFilterSync()
322
729
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
730
  console.error(error)
325
731
  })
326
732
  })
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)
733
+ elements.zoomIn.addEventListener('click', () => {
734
+ const rect = canvas.getBoundingClientRect()
735
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
736
+ })
737
+ elements.zoomOut.addEventListener('click', () => {
738
+ const rect = canvas.getBoundingClientRect()
739
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
740
+ })
741
+ if (elements.fit) {
742
+ elements.fit.addEventListener('click', () => {
743
+ fitView({ useFiltered: true })
336
744
  })
745
+ }
746
+ elements.reset.addEventListener('click', () => {
747
+ resetView()
748
+ })
749
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
750
+ elements.contentDialog.addEventListener('click', event => {
751
+ const target = event.target
752
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
753
+ selectNodeById(target.dataset.nodeId)
754
+ return
755
+ }
756
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
757
+ })
758
+ window.addEventListener('wheel', handleWheelZoom, { passive: false })
759
+ canvas.addEventListener('dblclick', event => {
760
+ const rect = canvas.getBoundingClientRect()
761
+ const cursorX = event.clientX - rect.left
762
+ const cursorY = event.clientY - rect.top
763
+ zoomAtPoint(cursorX, cursorY, 1.25)
337
764
  })
338
- canvas.addEventListener('wheel', event => {
339
- event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
341
- }, { passive: false })
342
765
  canvas.addEventListener('pointerdown', event => {
343
766
  const point = worldPoint(event)
344
767
  const node = hitNode(point)
@@ -352,6 +775,7 @@ const bindEvents = () => {
352
775
  canvas.addEventListener('pointermove', event => {
353
776
  const point = worldPoint(event)
354
777
  state.hovered = hitNode(point)
778
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
355
779
  if (!state.pointer.down) return
356
780
  const dx = event.clientX - state.pointer.x
357
781
  const dy = event.clientY - state.pointer.y
@@ -367,11 +791,40 @@ const bindEvents = () => {
367
791
  state.transform.y += dy
368
792
  })
369
793
  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)
794
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
795
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
796
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
797
  canvas.releasePointerCapture(event.pointerId)
374
798
  })
799
+ canvas.addEventListener('pointercancel', () => {
800
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
801
+ })
802
+ canvas.addEventListener('pointerenter', event => {
803
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
804
+ })
805
+ canvas.addEventListener('pointerleave', event => {
806
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
807
+ })
808
+ window.addEventListener('keydown', event => {
809
+ if (event.key === '+' || event.key === '=') {
810
+ event.preventDefault()
811
+ const rect = canvas.getBoundingClientRect()
812
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
813
+ return
814
+ }
815
+
816
+ if (event.key === '-' || event.key === '_') {
817
+ event.preventDefault()
818
+ const rect = canvas.getBoundingClientRect()
819
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
820
+ return
821
+ }
822
+
823
+ if (event.key === '0') {
824
+ event.preventDefault()
825
+ resetView()
826
+ }
827
+ })
375
828
  }
376
829
 
377
830
  const loadAgents = async () => {
@@ -386,9 +839,10 @@ const loadAgents = async () => {
386
839
 
387
840
  state.agentId = selected
388
841
  if (signature !== state.agentsSignature) {
842
+ const formatAgentLabel = (agent) => agent.id
389
843
  elements.agent.innerHTML = agents.length
390
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
391
- : '<option value="shared">shared · 0</option>'
844
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
845
+ : '<option value="shared">shared</option>'
392
846
  state.agentsSignature = signature
393
847
  }
394
848
  elements.agent.value = selected
@@ -417,14 +871,29 @@ const loadGraph = async (options = { reset: false }) => {
417
871
  state.graph = graph
418
872
  state.nodes = layout.nodes
419
873
  state.edges = layout.edges
874
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
875
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
876
+ if (edge.target) {
877
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
878
+ }
879
+ return degrees
880
+ }, new Map())
881
+ state.nodeDetails = new Map()
882
+ resetContentFilter()
883
+ recomputeVisibility()
884
+ scheduleContentFilterSync()
420
885
  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'
886
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
887
  elements.nodeCount.textContent = graph.nodes.length
423
888
  elements.edgeCount.textContent = graph.edges.length
424
889
  elements.tagCount.textContent = tags.size
425
890
  resize()
426
891
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
892
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
893
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
894
+ if (!selectedNode && elements.contentDialog.open) {
895
+ elements.contentDialog.close()
896
+ }
428
897
  }
429
898
 
430
899
  bindEvents()
@@ -441,10 +910,7 @@ const refreshGraphLoop = () => {
441
910
  return
442
911
  }
443
912
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
913
+ loadGraph().catch(handleGraphRefreshError)
448
914
 
449
915
  tickCounter += 1
450
916
  if (tickCounter % 3 === 0) {
@@ -461,7 +927,6 @@ loadAgents()
461
927
  setInterval(refreshGraphLoop, pollIntervalMs)
462
928
  })
463
929
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
930
  console.error(error)
466
931
  })
467
932
 
@@ -470,9 +935,6 @@ document.addEventListener('visibilitychange', () => {
470
935
  return
471
936
  }
472
937
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
938
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
939
  })
478
940
  `;