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

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 +525 -88
  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,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,35 +93,138 @@ 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.8
160
+ if (nodeCount <= 20) return 2.2
161
+ if (nodeCount <= 60) return 1.72
162
+ if (nodeCount <= 180) return 1.34
163
+ if (nodeCount <= 600) return 1.08
164
+ if (nodeCount <= 2000) return 0.9
165
+ if (nodeCount <= 6000) return 0.72
166
+ return 0.58
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 paddingByNodeCount = nodeCount => {
182
+ if (nodeCount <= 6) return 28
183
+ if (nodeCount <= 20) return 44
184
+ if (nodeCount <= 60) return 68
185
+ if (nodeCount <= 180) return 86
186
+ if (nodeCount <= 600) return 110
187
+ if (nodeCount <= 2000) return 140
188
+ return 180
189
+ }
190
+ const minFitScaleByNodeCount = nodeCount => {
191
+ if (nodeCount <= 6) return 2.4
192
+ if (nodeCount <= 20) return 1.8
193
+ if (nodeCount <= 60) return 1.2
194
+ if (nodeCount <= 180) return 0.86
195
+ if (nodeCount <= 600) return 0.58
196
+ if (nodeCount <= 2000) return 0.34
197
+ if (nodeCount <= 6000) return 0.2
198
+ return 0.13
199
+ }
200
+
201
+ const padding = paddingByNodeCount(nodes.length)
202
+ const scaleX = width / (bounds.width + padding * 2)
203
+ const scaleY = height / (bounds.height + padding * 2)
204
+ const fitScale = clampScale(Math.min(scaleX, scaleY))
205
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
206
+ const minimumScale = minFitScaleByNodeCount(nodes.length)
207
+ const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
208
+ const scale = Math.max(biasedScale, minimumScale, minimumLargeGraphScale)
209
+ const centerX = (bounds.minX + bounds.maxX) / 2
210
+ const centerY = (bounds.minY + bounds.maxY) / 2
211
+
212
+ state.transform = {
213
+ x: width / 2 - centerX * scale,
214
+ y: height / 2 - centerY * scale,
215
+ scale
216
+ }
91
217
  }
92
218
 
219
+ const resetView = () => fitView({ useFiltered: false })
220
+
93
221
  const createLayout = graph => {
94
222
  const nodes = graph.nodes.map(node => ({
95
223
  ...node,
96
224
  x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
225
+ y: Number.isFinite(node.y) ? node.y : 0,
226
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
227
+ vy: Number.isFinite(node.vy) ? node.vy : 0
98
228
  }))
99
229
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
230
  const edges = graph.edges
@@ -111,23 +241,88 @@ const encodeEntityTag = (value) => {
111
241
  binary += String.fromCharCode(utf8[index])
112
242
  }
113
243
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
244
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
245
  }
116
246
 
117
247
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
248
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
249
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
250
  })
121
251
 
252
+ const resetContentFilter = () => {
253
+ if (state.contentFilter.timer) {
254
+ clearTimeout(state.contentFilter.timer)
255
+ }
256
+ state.contentFilter = {
257
+ query: '',
258
+ ids: null,
259
+ token: state.contentFilter.token + 1,
260
+ timer: null
261
+ }
262
+ recomputeVisibility()
263
+ }
264
+
265
+ const syncContentFilter = async (query, token) => {
266
+ const response = await fetch(
267
+ '/api/graph-filter?q=' +
268
+ encodeURIComponent(query) +
269
+ '&limit=' +
270
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
271
+ agentQuery()
272
+ )
273
+
274
+ if (!response.ok || token !== state.contentFilter.token) {
275
+ return
276
+ }
277
+
278
+ const payload = await response.json()
279
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
280
+ if (token !== state.contentFilter.token) {
281
+ return
282
+ }
283
+
284
+ state.contentFilter.query = query
285
+ state.contentFilter.ids = new Set(nodeIds)
286
+ recomputeVisibility()
287
+ }
288
+
289
+ const scheduleContentFilterSync = () => {
290
+ const query = normalizeQuery(state.query)
291
+ if (!query) {
292
+ resetContentFilter()
293
+ return
294
+ }
295
+
296
+ if (state.contentFilter.timer) {
297
+ clearTimeout(state.contentFilter.timer)
298
+ }
299
+
300
+ const token = state.contentFilter.token + 1
301
+ state.contentFilter = {
302
+ query: state.contentFilter.query,
303
+ ids: state.contentFilter.ids,
304
+ token,
305
+ timer: setTimeout(() => {
306
+ syncContentFilter(query, token).catch(() => {})
307
+ }, 180)
308
+ }
309
+ }
310
+
122
311
  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))
312
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
313
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
314
+ if (nodes.length > 1200) {
315
+ return
316
+ }
126
317
  const strength = Math.min(delta / 16, 2)
127
318
 
128
319
  edges.forEach(edge => {
129
320
  const source = edge.sourceNode
130
321
  const target = edge.targetNode
322
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
323
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
324
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
325
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
326
  const dx = target.x - source.x
132
327
  const dy = target.y - source.y
133
328
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -144,6 +339,10 @@ const tick = delta => {
144
339
  for (let j = i + 1; j < nodes.length; j += 1) {
145
340
  const a = nodes[i]
146
341
  const b = nodes[j]
342
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
343
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
344
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
345
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
346
  const dx = b.x - a.x
148
347
  const dy = b.y - a.y
149
348
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +357,10 @@ const tick = delta => {
158
357
  }
159
358
 
160
359
  nodes.forEach(node => {
360
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
361
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
362
+ node.x = Number.isFinite(node.x) ? node.x : 0
363
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
364
  if (state.pointer.dragNode === node) {
162
365
  node.vx = 0
163
366
  node.vy = 0
@@ -181,7 +384,11 @@ const worldPoint = event => {
181
384
  }
182
385
 
183
386
  const hitNode = point => {
184
- const nodes = filteredNodes()
387
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
388
+ return null
389
+ }
390
+
391
+ const nodes = state.renderNodes
185
392
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
393
  const node = nodes[index]
187
394
  const radius = nodeRadius(node)
@@ -190,14 +397,118 @@ const hitNode = point => {
190
397
  return null
191
398
  }
192
399
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
400
+ const baseNodeRadius = node => {
401
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
402
  return 9 + Math.min(degree, 8) * 1.6
196
403
  }
197
404
 
405
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
406
+
407
+ const worldViewportBounds = () => {
408
+ const rect = canvas.getBoundingClientRect()
409
+ const width = Math.max(rect.width, 320)
410
+ const height = Math.max(rect.height, 320)
411
+ const padding = viewportPaddingPx
412
+
413
+ return {
414
+ minX: (-state.transform.x - padding) / state.transform.scale,
415
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
416
+ minY: (-state.transform.y - padding) / state.transform.scale,
417
+ maxY: (height - state.transform.y + padding) / state.transform.scale
418
+ }
419
+ }
420
+
421
+ const isNodeInViewport = (node, viewport) =>
422
+ node.x >= viewport.minX &&
423
+ node.x <= viewport.maxX &&
424
+ node.y >= viewport.minY &&
425
+ node.y <= viewport.maxY
426
+
427
+ const viewportNodeStride = () => {
428
+ if (state.nodes.length <= largeGraphNodeThreshold) {
429
+ return 1
430
+ }
431
+
432
+ if (state.transform.scale >= 0.95) {
433
+ return 1
434
+ }
435
+ if (state.transform.scale >= 0.7) {
436
+ return 2
437
+ }
438
+ if (state.transform.scale >= 0.48) {
439
+ return 3
440
+ }
441
+ if (state.transform.scale >= 0.28) {
442
+ return 5
443
+ }
444
+
445
+ return 8
446
+ }
447
+
448
+ const computeRenderVisibility = () => {
449
+ if (state.visibleNodes.length <= 2000) {
450
+ state.renderNodes = state.visibleNodes
451
+ const ids = new Set(state.renderNodes.map((node) => node.id))
452
+ state.renderEdges = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
453
+ return
454
+ }
455
+
456
+ const viewport = worldViewportBounds()
457
+ const stride = viewportNodeStride()
458
+ const picked = []
459
+
460
+ for (let index = 0; index < state.visibleNodes.length; index += 1) {
461
+ const node = state.visibleNodes[index]
462
+ if (!isNodeInViewport(node, viewport)) {
463
+ continue
464
+ }
465
+
466
+ const isPriority =
467
+ node.id === state.selected?.id ||
468
+ node.id === state.hovered?.id ||
469
+ node.id === state.pointer.dragNode?.id
470
+ if (isPriority || index % stride === 0) {
471
+ picked.push(node)
472
+ }
473
+ }
474
+
475
+ const nodes = picked.length > renderNodeBudget
476
+ ? picked.slice(0, renderNodeBudget)
477
+ : picked
478
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
479
+ const centerX = (viewport.minX + viewport.maxX) / 2
480
+ const centerY = (viewport.minY + viewport.maxY) / 2
481
+ const closest = [...state.visibleNodes]
482
+ .sort((left, right) => {
483
+ const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
484
+ const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
485
+ return leftDistance - rightDistance
486
+ })
487
+ .slice(0, Math.min(renderNodeBudget, 180))
488
+ const closestIds = new Set(closest.map((node) => node.id))
489
+
490
+ state.renderNodes = closest
491
+ state.renderEdges = state.visibleEdges.filter(
492
+ (edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
493
+ )
494
+ return
495
+ }
496
+
497
+ const nodeIds = new Set(nodes.map((node) => node.id))
498
+ const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
499
+
500
+ state.renderNodes = nodes
501
+ state.renderEdges = edges
502
+ }
503
+
198
504
  const render = now => {
199
505
  const delta = now - state.last
200
506
  state.last = now
507
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
508
+ if (delta < minFrameIntervalMs) {
509
+ requestAnimationFrame(render)
510
+ return
511
+ }
201
512
  const rect = canvas.getBoundingClientRect()
202
513
  const width = Math.max(rect.width, 320)
203
514
  const height = Math.max(rect.height, 320)
@@ -214,7 +525,11 @@ const render = now => {
214
525
  ctx.translate(state.transform.x, state.transform.y)
215
526
  ctx.scale(state.transform.scale, state.transform.scale)
216
527
 
217
- visibleEdges().forEach(edge => {
528
+ computeRenderVisibility()
529
+ tick(delta)
530
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
531
+ if (drawEdges) {
532
+ state.renderEdges.forEach(edge => {
218
533
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
534
  ctx.beginPath()
220
535
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +537,10 @@ const render = now => {
222
537
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
538
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
539
  ctx.stroke()
225
- })
540
+ })
541
+ }
226
542
 
227
- filteredNodes().forEach(node => {
543
+ state.renderNodes.forEach(node => {
228
544
  const radius = nodeRadius(node)
229
545
  const isSelected = state.selected?.id === node.id
230
546
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +556,11 @@ const render = now => {
240
556
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
557
  ctx.stroke()
242
558
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
559
+ const shouldDrawLabels =
560
+ isSelected ||
561
+ isHovered ||
562
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
563
+ if (shouldDrawLabels) {
244
564
  ctx.fillStyle = graphTheme.label
245
565
  ctx.font = '12px Inter, system-ui, sans-serif'
246
566
  ctx.textAlign = 'center'
@@ -250,6 +570,12 @@ const render = now => {
250
570
  })
251
571
 
252
572
  ctx.restore()
573
+ if (state.renderNodes.length === 0) {
574
+ ctx.fillStyle = '#99a5b5'
575
+ ctx.font = '12px Inter, system-ui, sans-serif'
576
+ ctx.textAlign = 'center'
577
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
578
+ }
253
579
  requestAnimationFrame(render)
254
580
  }
255
581
 
@@ -257,22 +583,7 @@ const list = items => items.length
257
583
  ? 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
584
  : '<li><small>No links found.</small></li>'
259
585
 
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
- }
586
+ const linkedNodes = node => {
276
587
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
588
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
589
  ...linkedNode,
@@ -288,57 +599,151 @@ const selectNode = node => {
288
599
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
600
  .filter(Boolean)
290
601
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
602
+ return { outgoing, incoming }
603
+ }
604
+
605
+ const fetchNodeDetails = async node => {
606
+ const cached = state.nodeDetails.get(node.id)
607
+ if (cached) {
608
+ return cached
609
+ }
610
+
611
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
612
+ if (!response.ok) {
613
+ throw new Error('Failed to load graph node details')
614
+ }
615
+
616
+ const payload = await response.json()
617
+ const detail = payload?.node
618
+ if (!detail || !detail.id) {
619
+ throw new Error('Invalid graph node payload')
620
+ }
621
+ state.nodeDetails.set(detail.id, detail)
622
+ return detail
623
+ }
624
+
625
+ const openContentDialog = async node => {
626
+ if (!node) return
627
+ const { outgoing, incoming } = linkedNodes(node)
628
+ elements.contentTitle.textContent = node.title
629
+ elements.contentPath.textContent = node.path
630
+ elements.contentTags.innerHTML = node.tags.length
294
631
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
632
  : '<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)
633
+ elements.contentOutgoing.innerHTML = list(outgoing)
634
+ elements.contentIncoming.innerHTML = list(incoming)
635
+ elements.contentBody.textContent = 'Loading note content...'
636
+ if (!elements.contentDialog.open) {
637
+ elements.contentDialog.showModal()
638
+ }
639
+
640
+ try {
641
+ const detailedNode = await fetchNodeDetails(node)
642
+ if (state.selected?.id !== node.id) {
643
+ return
644
+ }
645
+ elements.contentBody.textContent = detailedNode.content
646
+ } catch {
647
+ elements.contentBody.textContent = 'Unable to load note content.'
648
+ }
649
+ }
650
+
651
+ const selectNode = (node, options = { openContent: false }) => {
652
+ state.selected = node
653
+ if (node && options.openContent) {
654
+ openContentDialog(node).catch(() => {
655
+ elements.contentBody.textContent = 'Unable to load note content.'
656
+ })
657
+ }
300
658
  }
301
659
 
302
660
  const selectNodeById = id => {
303
661
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
662
+ if (node) selectNode(node, { openContent: true })
663
+ }
664
+
665
+ const zoomAtPoint = (screenX, screenY, factor) => {
666
+ const nextScale = clampScale(state.transform.scale * factor)
667
+ if (nextScale === state.transform.scale) return
668
+ const worldX = (screenX - state.transform.x) / state.transform.scale
669
+ const worldY = (screenY - state.transform.y) / state.transform.scale
670
+ state.transform.scale = nextScale
671
+ state.transform.x = screenX - worldX * nextScale
672
+ state.transform.y = screenY - worldY * nextScale
305
673
  }
306
674
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
675
+ const wheelZoomFactor = event => {
676
+ const isModifierZoom = event.metaKey || event.ctrlKey
677
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
678
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
679
+
680
+ if (absoluteDelta <= 0.0001) {
681
+ return 1
682
+ }
683
+
684
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
685
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
686
+
687
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
309
688
  }
310
689
 
311
690
  const bindEvents = () => {
312
691
  window.addEventListener('resize', resize)
313
692
  elements.search.addEventListener('input', event => {
314
693
  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'
694
+ recomputeVisibility()
695
+ scheduleContentFilterSync()
318
696
  })
319
697
  elements.agent.addEventListener('change', event => {
320
698
  state.agentId = event.target.value
321
699
  state.selected = null
700
+ state.nodeDetails = new Map()
701
+ resetContentFilter()
702
+ recomputeVisibility()
703
+ scheduleContentFilterSync()
322
704
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
705
  console.error(error)
325
706
  })
326
707
  })
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)
708
+ elements.zoomIn.addEventListener('click', () => {
709
+ const rect = canvas.getBoundingClientRect()
710
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
711
+ })
712
+ elements.zoomOut.addEventListener('click', () => {
713
+ const rect = canvas.getBoundingClientRect()
714
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
715
+ })
716
+ if (elements.fit) {
717
+ elements.fit.addEventListener('click', () => {
718
+ fitView({ useFiltered: true })
336
719
  })
720
+ }
721
+ elements.reset.addEventListener('click', () => {
722
+ resetView()
723
+ })
724
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
725
+ elements.contentDialog.addEventListener('click', event => {
726
+ const target = event.target
727
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
728
+ selectNodeById(target.dataset.nodeId)
729
+ return
730
+ }
731
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
337
732
  })
338
733
  canvas.addEventListener('wheel', event => {
339
734
  event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
735
+ const rect = canvas.getBoundingClientRect()
736
+ const cursorX = event.clientX - rect.left
737
+ const cursorY = event.clientY - rect.top
738
+ const factor = wheelZoomFactor(event)
739
+ zoomAtPoint(cursorX, cursorY, factor)
341
740
  }, { passive: false })
741
+ canvas.addEventListener('dblclick', event => {
742
+ const rect = canvas.getBoundingClientRect()
743
+ const cursorX = event.clientX - rect.left
744
+ const cursorY = event.clientY - rect.top
745
+ zoomAtPoint(cursorX, cursorY, 1.25)
746
+ })
342
747
  canvas.addEventListener('pointerdown', event => {
343
748
  const point = worldPoint(event)
344
749
  const node = hitNode(point)
@@ -367,11 +772,34 @@ const bindEvents = () => {
367
772
  state.transform.y += dy
368
773
  })
369
774
  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)
775
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
776
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
777
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
778
  canvas.releasePointerCapture(event.pointerId)
374
779
  })
780
+ canvas.addEventListener('pointercancel', () => {
781
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
782
+ })
783
+ window.addEventListener('keydown', event => {
784
+ if (event.key === '+' || event.key === '=') {
785
+ event.preventDefault()
786
+ const rect = canvas.getBoundingClientRect()
787
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
788
+ return
789
+ }
790
+
791
+ if (event.key === '-' || event.key === '_') {
792
+ event.preventDefault()
793
+ const rect = canvas.getBoundingClientRect()
794
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
795
+ return
796
+ }
797
+
798
+ if (event.key === '0') {
799
+ event.preventDefault()
800
+ resetView()
801
+ }
802
+ })
375
803
  }
376
804
 
377
805
  const loadAgents = async () => {
@@ -386,9 +814,10 @@ const loadAgents = async () => {
386
814
 
387
815
  state.agentId = selected
388
816
  if (signature !== state.agentsSignature) {
817
+ const formatAgentLabel = (agent) => agent.id
389
818
  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>'
819
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
820
+ : '<option value="shared">shared</option>'
392
821
  state.agentsSignature = signature
393
822
  }
394
823
  elements.agent.value = selected
@@ -417,14 +846,29 @@ const loadGraph = async (options = { reset: false }) => {
417
846
  state.graph = graph
418
847
  state.nodes = layout.nodes
419
848
  state.edges = layout.edges
849
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
850
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
851
+ if (edge.target) {
852
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
853
+ }
854
+ return degrees
855
+ }, new Map())
856
+ state.nodeDetails = new Map()
857
+ resetContentFilter()
858
+ recomputeVisibility()
859
+ scheduleContentFilterSync()
420
860
  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'
861
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
862
  elements.nodeCount.textContent = graph.nodes.length
423
863
  elements.edgeCount.textContent = graph.edges.length
424
864
  elements.tagCount.textContent = tags.size
425
865
  resize()
426
866
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
867
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
868
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
869
+ if (!selectedNode && elements.contentDialog.open) {
870
+ elements.contentDialog.close()
871
+ }
428
872
  }
429
873
 
430
874
  bindEvents()
@@ -441,10 +885,7 @@ const refreshGraphLoop = () => {
441
885
  return
442
886
  }
443
887
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
888
+ loadGraph().catch(handleGraphRefreshError)
448
889
 
449
890
  tickCounter += 1
450
891
  if (tickCounter % 3 === 0) {
@@ -461,7 +902,6 @@ loadAgents()
461
902
  setInterval(refreshGraphLoop, pollIntervalMs)
462
903
  })
463
904
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
905
  console.error(error)
466
906
  })
467
907
 
@@ -470,9 +910,6 @@ document.addEventListener('visibilitychange', () => {
470
910
  return
471
911
  }
472
912
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
913
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
914
  })
478
915
  `;