@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.40

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 (58) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +43 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +213 -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/build-context.js +56 -1
  10. package/dist/application/dedupe-notes.js +226 -0
  11. package/dist/application/frontend/client-css.js +214 -100
  12. package/dist/application/frontend/client-html.js +60 -45
  13. package/dist/application/frontend/client-js.js +656 -94
  14. package/dist/application/get-graph-layout.js +22 -7
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +11 -4
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/migrate-vault.js +91 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +75 -5
  25. package/dist/application/server/routes.js +27 -1
  26. package/dist/benchmarks/large-vault.js +1 -1
  27. package/dist/cli/commands/agent-commands.js +412 -0
  28. package/dist/cli/commands/config-commands.js +167 -0
  29. package/dist/cli/commands/read-commands.js +25 -8
  30. package/dist/cli/commands/write-commands.js +669 -9
  31. package/dist/cli/main.js +4 -0
  32. package/dist/cli/runtime.js +5 -2
  33. package/dist/domain/context.js +53 -11
  34. package/dist/domain/embeddings.js +2 -1
  35. package/dist/domain/graph-layout.js +20 -14
  36. package/dist/domain/markdown.js +36 -4
  37. package/dist/domain/middle-out.js +18 -0
  38. package/dist/infrastructure/config.js +94 -8
  39. package/dist/infrastructure/file-index.js +328 -0
  40. package/dist/infrastructure/file-system-vault.js +15 -0
  41. package/dist/infrastructure/paths.js +9 -1
  42. package/dist/infrastructure/private-pack-codec.js +73 -0
  43. package/dist/infrastructure/search-packs.js +348 -0
  44. package/dist/infrastructure/session-state.js +172 -0
  45. package/dist/mcp/main.js +11 -3
  46. package/dist/mcp/server.js +27 -2
  47. package/dist/mcp/startup.js +35 -0
  48. package/dist/mcp/tools.js +633 -19
  49. package/docs/AGENT_USAGE.md +144 -16
  50. package/docs/ARCHITECTURE.md +37 -26
  51. package/docs/QUICKSTART.md +111 -0
  52. package/package.json +6 -4
  53. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  54. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  55. package/dist/infrastructure/sqlite/schema.js +0 -111
  56. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  57. package/dist/infrastructure/sqlite/types.js +0 -1
  58. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,18 +1,36 @@
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
8
+ const worldCoordinateLimit = 5_000_000
9
+ const transformCoordinateLimit = 20_000_000
3
10
  const state = {
4
11
  graph: { nodes: [], edges: [] },
5
12
  nodes: [],
6
13
  edges: [],
14
+ visibleNodes: [],
15
+ visibleEdges: [],
16
+ renderNodes: [],
17
+ renderEdges: [],
18
+ nodeDegrees: new Map(),
7
19
  selected: null,
8
20
  hovered: null,
9
21
  query: '',
22
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
23
  agentId: '',
11
24
  agentsSignature: '',
25
+ nodeDetails: new Map(),
12
26
  transform: { x: 0, y: 0, scale: 1 },
13
27
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
28
+ cursor: { x: 0, y: 0, inCanvas: false },
14
29
  graphSignature: '',
15
- last: performance.now()
30
+ graphStatus: '',
31
+ last: performance.now(),
32
+ offscreenFrameCount: 0,
33
+ recoveringViewport: false
16
34
  }
17
35
 
18
36
  const byId = id => document.getElementById(id)
@@ -23,25 +41,39 @@ const escapeHtml = value => String(value)
23
41
  .replaceAll('"', '"')
24
42
  .replaceAll("'", ''')
25
43
  const elements = {
26
- stats: byId('stats'),
27
44
  search: byId('search'),
28
45
  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
46
  nodeCount: byId('nodeCount'),
37
47
  edgeCount: byId('edgeCount'),
38
48
  tagCount: byId('tagCount'),
39
49
  zoomIn: byId('zoomIn'),
40
50
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
51
+ fit: byId('fit'),
52
+ reset: byId('reset'),
53
+ contentDialog: byId('contentDialog'),
54
+ contentTitle: byId('contentTitle'),
55
+ contentPath: byId('contentPath'),
56
+ contentTags: byId('contentTags'),
57
+ contentOutgoing: byId('contentOutgoing'),
58
+ contentIncoming: byId('contentIncoming'),
59
+ contentBody: byId('contentBody'),
60
+ contentClose: byId('contentClose')
42
61
  }
43
62
 
44
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
63
+ const zoomRange = {
64
+ min: 0.05,
65
+ max: 4.5
66
+ }
67
+
68
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
69
+
70
+ const setGraphStatus = text => {
71
+ state.graphStatus = text
72
+ }
73
+
74
+ const handleGraphRefreshError = error => {
75
+ console.error(error)
76
+ }
45
77
 
46
78
  const graphTheme = {
47
79
  node: '#aeb8c5',
@@ -66,35 +98,178 @@ const resize = () => {
66
98
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
67
99
  }
68
100
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
101
+ const normalizeQuery = value => value.trim().toLowerCase()
102
+ const hubNodeRetentionLimit = 2
103
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
104
+
105
+ const localFilteredNodes = query =>
106
+ state.nodes.filter(node =>
73
107
  node.title.toLowerCase().includes(query) ||
74
108
  node.path.toLowerCase().includes(query) ||
75
109
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
110
  )
111
+
112
+ const rankedHubNodes = () => {
113
+ if (state.nodes.length === 0) {
114
+ return []
115
+ }
116
+
117
+ const byTitleAndDegree = [...state.nodes]
118
+ .filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
119
+ .sort((left, right) => {
120
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
121
+ if (byDegree !== 0) return byDegree
122
+ return left.title.localeCompare(right.title)
123
+ })
124
+
125
+ if (byTitleAndDegree.length > 0) {
126
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
127
+ }
128
+
129
+ return [...state.nodes]
130
+ .sort((left, right) => {
131
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
132
+ if (byDegree !== 0) return byDegree
133
+ return left.title.localeCompare(right.title)
134
+ })
135
+ .slice(0, 1)
136
+ }
137
+
138
+ const withPersistentHubNodes = nodes => {
139
+ if (nodes.length === 0) {
140
+ return rankedHubNodes()
141
+ }
142
+
143
+ const ids = new Set(nodes.map(node => node.id))
144
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
145
+ return nodes.concat(hubsToKeep)
146
+ }
147
+
148
+ const filteredNodes = () => {
149
+ const query = normalizeQuery(state.query)
150
+ if (!query) return state.nodes
151
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
152
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
153
+ return withPersistentHubNodes(matched)
154
+ }
155
+
156
+ return withPersistentHubNodes(localFilteredNodes(query))
77
157
  }
78
158
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
159
+ const recomputeVisibility = () => {
160
+ const nodes = filteredNodes()
161
+ const ids = new Set(nodes.map(node => node.id))
162
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
163
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
164
+ ? [...edges]
165
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
166
+ .slice(0, largeGraphEdgeRenderLimit)
167
+ : edges
80
168
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
169
+ state.visibleNodes = nodes
170
+ state.visibleEdges = limitedEdges
84
171
  }
85
172
 
86
173
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
87
174
 
88
- const resetView = () => {
175
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
176
+ const isFiniteNumber = value => Number.isFinite(value)
177
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
178
+
179
+ const graphBounds = nodes => {
180
+ if (nodes.length === 0) return null
181
+ let minX = Number.POSITIVE_INFINITY
182
+ let maxX = Number.NEGATIVE_INFINITY
183
+ let minY = Number.POSITIVE_INFINITY
184
+ let maxY = Number.NEGATIVE_INFINITY
185
+
186
+ nodes.forEach(node => {
187
+ const radius = baseNodeRadius(node)
188
+ minX = Math.min(minX, node.x - radius)
189
+ maxX = Math.max(maxX, node.x + radius)
190
+ minY = Math.min(minY, node.y - radius)
191
+ maxY = Math.max(maxY, node.y + radius)
192
+ })
193
+
194
+ return {
195
+ minX,
196
+ maxX,
197
+ minY,
198
+ maxY,
199
+ width: Math.max(maxX - minX, 1),
200
+ height: Math.max(maxY - minY, 1)
201
+ }
202
+ }
203
+
204
+ const fitScaleBiasByNodeCount = nodeCount => {
205
+ if (nodeCount <= 6) return 1.22
206
+ if (nodeCount <= 20) return 1.12
207
+ if (nodeCount <= 60) return 1.04
208
+ if (nodeCount <= 180) return 1
209
+ if (nodeCount <= 600) return 0.94
210
+ if (nodeCount <= 2000) return 0.82
211
+ if (nodeCount <= 6000) return 0.68
212
+ return 0.56
213
+ }
214
+
215
+ const autoFitScaleRangeByNodeCount = nodeCount => {
216
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
217
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
218
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
219
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
220
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
221
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
222
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
223
+ return { min: zoomRange.min, max: 0.24 }
224
+ }
225
+
226
+ const fitView = (options = { useFiltered: true }) => {
89
227
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
228
+ const width = Math.max(rect.width, 320)
229
+ const height = Math.max(rect.height, 320)
230
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
231
+ const bounds = graphBounds(nodes)
232
+
233
+ if (!bounds) {
234
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
235
+ return
236
+ }
237
+
238
+ const paddingByNodeCount = nodeCount => {
239
+ if (nodeCount <= 6) return 28
240
+ if (nodeCount <= 20) return 44
241
+ if (nodeCount <= 60) return 68
242
+ if (nodeCount <= 180) return 86
243
+ if (nodeCount <= 600) return 110
244
+ if (nodeCount <= 2000) return 140
245
+ return 180
246
+ }
247
+ const padding = paddingByNodeCount(nodes.length)
248
+ const scaleX = width / (bounds.width + padding * 2)
249
+ const scaleY = height / (bounds.height + padding * 2)
250
+ const fitScale = Math.min(scaleX, scaleY)
251
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
252
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
253
+ const scale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
254
+ const centerX = (bounds.minX + bounds.maxX) / 2
255
+ const centerY = (bounds.minY + bounds.maxY) / 2
256
+
257
+ state.transform = {
258
+ x: width / 2 - centerX * scale,
259
+ y: height / 2 - centerY * scale,
260
+ scale
261
+ }
91
262
  }
92
263
 
264
+ const resetView = () => fitView({ useFiltered: false })
265
+
93
266
  const createLayout = graph => {
94
267
  const nodes = graph.nodes.map(node => ({
95
268
  ...node,
96
269
  x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
270
+ y: Number.isFinite(node.y) ? node.y : 0,
271
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
272
+ vy: Number.isFinite(node.vy) ? node.vy : 0
98
273
  }))
99
274
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
275
  const edges = graph.edges
@@ -111,29 +286,94 @@ const encodeEntityTag = (value) => {
111
286
  binary += String.fromCharCode(utf8[index])
112
287
  }
113
288
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
289
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
290
  }
116
291
 
117
292
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
293
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
294
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
295
  })
121
296
 
297
+ const resetContentFilter = () => {
298
+ if (state.contentFilter.timer) {
299
+ clearTimeout(state.contentFilter.timer)
300
+ }
301
+ state.contentFilter = {
302
+ query: '',
303
+ ids: null,
304
+ token: state.contentFilter.token + 1,
305
+ timer: null
306
+ }
307
+ recomputeVisibility()
308
+ }
309
+
310
+ const syncContentFilter = async (query, token) => {
311
+ const response = await fetch(
312
+ '/api/graph-filter?q=' +
313
+ encodeURIComponent(query) +
314
+ '&limit=' +
315
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
316
+ agentQuery('&')
317
+ )
318
+
319
+ if (!response.ok || token !== state.contentFilter.token) {
320
+ return
321
+ }
322
+
323
+ const payload = await response.json()
324
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
325
+ if (token !== state.contentFilter.token) {
326
+ return
327
+ }
328
+
329
+ state.contentFilter.query = query
330
+ state.contentFilter.ids = new Set(nodeIds)
331
+ recomputeVisibility()
332
+ }
333
+
334
+ const scheduleContentFilterSync = () => {
335
+ const query = normalizeQuery(state.query)
336
+ if (!query) {
337
+ resetContentFilter()
338
+ return
339
+ }
340
+
341
+ if (state.contentFilter.timer) {
342
+ clearTimeout(state.contentFilter.timer)
343
+ }
344
+
345
+ const token = state.contentFilter.token + 1
346
+ state.contentFilter = {
347
+ query: state.contentFilter.query,
348
+ ids: state.contentFilter.ids,
349
+ token,
350
+ timer: setTimeout(() => {
351
+ syncContentFilter(query, token).catch(() => {})
352
+ }, 180)
353
+ }
354
+ }
355
+
122
356
  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))
357
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
358
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
359
+ if (nodes.length > 1200) {
360
+ return
361
+ }
126
362
  const strength = Math.min(delta / 16, 2)
127
363
 
128
364
  edges.forEach(edge => {
129
365
  const source = edge.sourceNode
130
366
  const target = edge.targetNode
367
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
368
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
369
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
370
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
371
  const dx = target.x - source.x
132
372
  const dy = target.y - source.y
133
373
  const distance = Math.max(Math.hypot(dx, dy), 1)
134
374
  const force = (distance - 150) * 0.002 * strength
135
- const fx = dx * force
136
- const fy = dy * force
375
+ const fx = (dx / distance) * force
376
+ const fy = (dy / distance) * force
137
377
  source.vx += fx
138
378
  source.vy += fy
139
379
  target.vx -= fx
@@ -144,6 +384,10 @@ const tick = delta => {
144
384
  for (let j = i + 1; j < nodes.length; j += 1) {
145
385
  const a = nodes[i]
146
386
  const b = nodes[j]
387
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
388
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
389
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
390
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
391
  const dx = b.x - a.x
148
392
  const dy = b.y - a.y
149
393
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +402,10 @@ const tick = delta => {
158
402
  }
159
403
 
160
404
  nodes.forEach(node => {
405
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
406
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
407
+ node.x = Number.isFinite(node.x) ? node.x : 0
408
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
409
  if (state.pointer.dragNode === node) {
162
410
  node.vx = 0
163
411
  node.vy = 0
@@ -181,7 +429,11 @@ const worldPoint = event => {
181
429
  }
182
430
 
183
431
  const hitNode = point => {
184
- const nodes = filteredNodes()
432
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
433
+ return null
434
+ }
435
+
436
+ const nodes = state.renderNodes
185
437
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
438
  const node = nodes[index]
187
439
  const radius = nodeRadius(node)
@@ -190,17 +442,159 @@ const hitNode = point => {
190
442
  return null
191
443
  }
192
444
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
445
+ const baseNodeRadius = node => {
446
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
447
  return 9 + Math.min(degree, 8) * 1.6
196
448
  }
197
449
 
450
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
451
+
452
+ const worldViewportBounds = () => {
453
+ const rect = canvas.getBoundingClientRect()
454
+ const width = Math.max(rect.width, 320)
455
+ const height = Math.max(rect.height, 320)
456
+ const padding = viewportPaddingPx
457
+
458
+ return {
459
+ minX: (-state.transform.x - padding) / state.transform.scale,
460
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
461
+ minY: (-state.transform.y - padding) / state.transform.scale,
462
+ maxY: (height - state.transform.y + padding) / state.transform.scale
463
+ }
464
+ }
465
+
466
+ const isNodeInViewport = (node, viewport) =>
467
+ node.x >= viewport.minX &&
468
+ node.x <= viewport.maxX &&
469
+ node.y >= viewport.minY &&
470
+ node.y <= viewport.maxY
471
+
472
+ const viewportNodeStride = () => {
473
+ if (state.nodes.length <= largeGraphNodeThreshold) {
474
+ return 1
475
+ }
476
+
477
+ if (state.transform.scale >= 0.95) {
478
+ return 1
479
+ }
480
+ if (state.transform.scale >= 0.7) {
481
+ return 2
482
+ }
483
+ if (state.transform.scale >= 0.48) {
484
+ return 3
485
+ }
486
+ if (state.transform.scale >= 0.28) {
487
+ return 5
488
+ }
489
+
490
+ return 8
491
+ }
492
+
493
+ const computeRenderVisibility = () => {
494
+ if (state.visibleNodes.length <= 2000) {
495
+ state.renderNodes = state.visibleNodes
496
+ const ids = new Set(state.renderNodes.map((node) => node.id))
497
+ state.renderEdges = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
498
+ return
499
+ }
500
+
501
+ const viewport = worldViewportBounds()
502
+ const stride = viewportNodeStride()
503
+ const picked = []
504
+
505
+ for (let index = 0; index < state.visibleNodes.length; index += 1) {
506
+ const node = state.visibleNodes[index]
507
+ if (!isNodeInViewport(node, viewport)) {
508
+ continue
509
+ }
510
+
511
+ const isPriority =
512
+ node.id === state.selected?.id ||
513
+ node.id === state.hovered?.id ||
514
+ node.id === state.pointer.dragNode?.id
515
+ if (isPriority || index % stride === 0) {
516
+ picked.push(node)
517
+ }
518
+ }
519
+
520
+ const nodes = picked.length > renderNodeBudget
521
+ ? picked.slice(0, renderNodeBudget)
522
+ : picked
523
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
524
+ const centerX = (viewport.minX + viewport.maxX) / 2
525
+ const centerY = (viewport.minY + viewport.maxY) / 2
526
+ const closest = [...state.visibleNodes]
527
+ .sort((left, right) => {
528
+ const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
529
+ const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
530
+ return leftDistance - rightDistance
531
+ })
532
+ .slice(0, Math.min(renderNodeBudget, 180))
533
+ const closestIds = new Set(closest.map((node) => node.id))
534
+
535
+ state.renderNodes = closest
536
+ state.renderEdges = state.visibleEdges.filter(
537
+ (edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
538
+ )
539
+ return
540
+ }
541
+
542
+ const nodeIds = new Set(nodes.map((node) => node.id))
543
+ const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
544
+
545
+ state.renderNodes = nodes
546
+ state.renderEdges = edges
547
+ }
548
+
549
+ const isNodeVisibleOnScreen = (node, width, height) => {
550
+ const radius = nodeRadius(node) * state.transform.scale
551
+ const screenX = node.x * state.transform.scale + state.transform.x
552
+ const screenY = node.y * state.transform.scale + state.transform.y
553
+
554
+ return (
555
+ screenX + radius >= 0 &&
556
+ screenX - radius <= width &&
557
+ screenY + radius >= 0 &&
558
+ screenY - radius <= height
559
+ )
560
+ }
561
+
562
+ const hasValidTransform = () =>
563
+ isFiniteNumber(state.transform.x) &&
564
+ isFiniteNumber(state.transform.y) &&
565
+ isFiniteNumber(state.transform.scale) &&
566
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
567
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
568
+ state.transform.scale > 0
569
+
570
+ const sanitizeNodePosition = node => {
571
+ if (!isReasonableCoordinate(node.x)) node.x = 0
572
+ if (!isReasonableCoordinate(node.y)) node.y = 0
573
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
574
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
575
+ }
576
+
577
+ const sanitizeGraphState = () => {
578
+ state.nodes.forEach(sanitizeNodePosition)
579
+ state.visibleNodes.forEach(sanitizeNodePosition)
580
+ state.renderNodes.forEach(sanitizeNodePosition)
581
+ }
582
+
198
583
  const render = now => {
199
584
  const delta = now - state.last
200
585
  state.last = now
586
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
587
+ if (delta < minFrameIntervalMs) {
588
+ requestAnimationFrame(render)
589
+ return
590
+ }
201
591
  const rect = canvas.getBoundingClientRect()
202
592
  const width = Math.max(rect.width, 320)
203
593
  const height = Math.max(rect.height, 320)
594
+ sanitizeGraphState()
595
+ if (!hasValidTransform()) {
596
+ resetView()
597
+ }
204
598
  ctx.clearRect(0, 0, width, height)
205
599
  if (state.nodes.length === 0) {
206
600
  ctx.fillStyle = '#99a5b5'
@@ -214,7 +608,25 @@ const render = now => {
214
608
  ctx.translate(state.transform.x, state.transform.y)
215
609
  ctx.scale(state.transform.scale, state.transform.scale)
216
610
 
217
- visibleEdges().forEach(edge => {
611
+ computeRenderVisibility()
612
+ tick(delta)
613
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
614
+ if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0) {
615
+ state.offscreenFrameCount += 1
616
+ if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
617
+ state.recoveringViewport = true
618
+ fitView({ useFiltered: true })
619
+ state.offscreenFrameCount = 0
620
+ requestAnimationFrame(() => {
621
+ state.recoveringViewport = false
622
+ })
623
+ }
624
+ } else {
625
+ state.offscreenFrameCount = 0
626
+ }
627
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
628
+ if (drawEdges) {
629
+ state.renderEdges.forEach(edge => {
218
630
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
631
  ctx.beginPath()
220
632
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +634,10 @@ const render = now => {
222
634
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
635
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
636
  ctx.stroke()
225
- })
637
+ })
638
+ }
226
639
 
227
- filteredNodes().forEach(node => {
640
+ state.renderNodes.forEach(node => {
228
641
  const radius = nodeRadius(node)
229
642
  const isSelected = state.selected?.id === node.id
230
643
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +653,11 @@ const render = now => {
240
653
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
654
  ctx.stroke()
242
655
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
656
+ const shouldDrawLabels =
657
+ isSelected ||
658
+ isHovered ||
659
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
660
+ if (shouldDrawLabels) {
244
661
  ctx.fillStyle = graphTheme.label
245
662
  ctx.font = '12px Inter, system-ui, sans-serif'
246
663
  ctx.textAlign = 'center'
@@ -250,6 +667,12 @@ const render = now => {
250
667
  })
251
668
 
252
669
  ctx.restore()
670
+ if (state.renderNodes.length === 0) {
671
+ ctx.fillStyle = '#99a5b5'
672
+ ctx.font = '12px Inter, system-ui, sans-serif'
673
+ ctx.textAlign = 'center'
674
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
675
+ }
253
676
  requestAnimationFrame(render)
254
677
  }
255
678
 
@@ -257,22 +680,7 @@ const list = items => items.length
257
680
  ? 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
681
  : '<li><small>No links found.</small></li>'
259
682
 
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
- }
683
+ const linkedNodes = node => {
276
684
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
685
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
686
  ...linkedNode,
@@ -288,57 +696,172 @@ const selectNode = node => {
288
696
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
697
  .filter(Boolean)
290
698
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
699
+ return { outgoing, incoming }
700
+ }
701
+
702
+ const fetchNodeDetails = async node => {
703
+ const cached = state.nodeDetails.get(node.id)
704
+ if (cached) {
705
+ return cached
706
+ }
707
+
708
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
709
+ if (!response.ok) {
710
+ throw new Error('Failed to load graph node details')
711
+ }
712
+
713
+ const payload = await response.json()
714
+ const detail = payload?.node
715
+ if (!detail || !detail.id) {
716
+ throw new Error('Invalid graph node payload')
717
+ }
718
+ state.nodeDetails.set(detail.id, detail)
719
+ return detail
720
+ }
721
+
722
+ const openContentDialog = async node => {
723
+ if (!node) return
724
+ const { outgoing, incoming } = linkedNodes(node)
725
+ elements.contentTitle.textContent = node.title
726
+ elements.contentPath.textContent = node.path
727
+ elements.contentTags.innerHTML = node.tags.length
294
728
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
729
  : '<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)
730
+ elements.contentOutgoing.innerHTML = list(outgoing)
731
+ elements.contentIncoming.innerHTML = list(incoming)
732
+ elements.contentBody.textContent = 'Loading note content...'
733
+ if (!elements.contentDialog.open) {
734
+ elements.contentDialog.showModal()
735
+ }
736
+
737
+ try {
738
+ const detailedNode = await fetchNodeDetails(node)
739
+ if (state.selected?.id !== node.id) {
740
+ return
741
+ }
742
+ elements.contentBody.textContent = detailedNode.content
743
+ } catch {
744
+ elements.contentBody.textContent = 'Unable to load note content.'
745
+ }
746
+ }
747
+
748
+ const selectNode = (node, options = { openContent: false }) => {
749
+ state.selected = node
750
+ if (node && options.openContent) {
751
+ openContentDialog(node).catch(() => {
752
+ elements.contentBody.textContent = 'Unable to load note content.'
753
+ })
754
+ }
300
755
  }
301
756
 
302
757
  const selectNodeById = id => {
303
758
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
759
+ if (node) selectNode(node, { openContent: true })
760
+ }
761
+
762
+ const zoomAtPoint = (screenX, screenY, factor) => {
763
+ const nextScale = clampScale(state.transform.scale * factor)
764
+ if (nextScale === state.transform.scale) return
765
+ const worldX = (screenX - state.transform.x) / state.transform.scale
766
+ const worldY = (screenY - state.transform.y) / state.transform.scale
767
+ state.transform.scale = nextScale
768
+ state.transform.x = screenX - worldX * nextScale
769
+ state.transform.y = screenY - worldY * nextScale
305
770
  }
306
771
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
772
+ const wheelZoomFactor = event => {
773
+ const isModifierZoom = event.metaKey || event.ctrlKey
774
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
775
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
776
+
777
+ if (absoluteDelta <= 0.0001) {
778
+ return 1
779
+ }
780
+
781
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
782
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
783
+
784
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
785
+ }
786
+
787
+ const isScreenPointInsideCanvas = (screenX, screenY) => {
788
+ const rect = canvas.getBoundingClientRect()
789
+
790
+ return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
791
+ }
792
+
793
+ const handleWheelZoom = event => {
794
+ if (elements.contentDialog?.open) {
795
+ return
796
+ }
797
+
798
+ if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
799
+ return
800
+ }
801
+
802
+ event.preventDefault()
803
+ const rect = canvas.getBoundingClientRect()
804
+ const cursorX = event.clientX - rect.left
805
+ const cursorY = event.clientY - rect.top
806
+ const factor = wheelZoomFactor(event)
807
+
808
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
809
+ return
810
+ }
811
+
812
+ zoomAtPoint(cursorX, cursorY, factor)
309
813
  }
310
814
 
311
815
  const bindEvents = () => {
312
816
  window.addEventListener('resize', resize)
313
817
  elements.search.addEventListener('input', event => {
314
818
  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'
819
+ recomputeVisibility()
820
+ scheduleContentFilterSync()
318
821
  })
319
822
  elements.agent.addEventListener('change', event => {
320
823
  state.agentId = event.target.value
321
824
  state.selected = null
825
+ state.nodeDetails = new Map()
826
+ resetContentFilter()
827
+ recomputeVisibility()
828
+ scheduleContentFilterSync()
322
829
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
830
  console.error(error)
325
831
  })
326
832
  })
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)
833
+ elements.zoomIn.addEventListener('click', () => {
834
+ const rect = canvas.getBoundingClientRect()
835
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
836
+ })
837
+ elements.zoomOut.addEventListener('click', () => {
838
+ const rect = canvas.getBoundingClientRect()
839
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
840
+ })
841
+ if (elements.fit) {
842
+ elements.fit.addEventListener('click', () => {
843
+ fitView({ useFiltered: true })
336
844
  })
845
+ }
846
+ elements.reset.addEventListener('click', () => {
847
+ resetView()
848
+ })
849
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
850
+ elements.contentDialog.addEventListener('click', event => {
851
+ const target = event.target
852
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
853
+ selectNodeById(target.dataset.nodeId)
854
+ return
855
+ }
856
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
857
+ })
858
+ window.addEventListener('wheel', handleWheelZoom, { passive: false })
859
+ canvas.addEventListener('dblclick', event => {
860
+ const rect = canvas.getBoundingClientRect()
861
+ const cursorX = event.clientX - rect.left
862
+ const cursorY = event.clientY - rect.top
863
+ zoomAtPoint(cursorX, cursorY, 1.25)
337
864
  })
338
- canvas.addEventListener('wheel', event => {
339
- event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
341
- }, { passive: false })
342
865
  canvas.addEventListener('pointerdown', event => {
343
866
  const point = worldPoint(event)
344
867
  const node = hitNode(point)
@@ -352,6 +875,7 @@ const bindEvents = () => {
352
875
  canvas.addEventListener('pointermove', event => {
353
876
  const point = worldPoint(event)
354
877
  state.hovered = hitNode(point)
878
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
355
879
  if (!state.pointer.down) return
356
880
  const dx = event.clientX - state.pointer.x
357
881
  const dy = event.clientY - state.pointer.y
@@ -367,11 +891,40 @@ const bindEvents = () => {
367
891
  state.transform.y += dy
368
892
  })
369
893
  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)
894
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
895
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
896
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
897
  canvas.releasePointerCapture(event.pointerId)
374
898
  })
899
+ canvas.addEventListener('pointercancel', () => {
900
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
901
+ })
902
+ canvas.addEventListener('pointerenter', event => {
903
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
904
+ })
905
+ canvas.addEventListener('pointerleave', event => {
906
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
907
+ })
908
+ window.addEventListener('keydown', event => {
909
+ if (event.key === '+' || event.key === '=') {
910
+ event.preventDefault()
911
+ const rect = canvas.getBoundingClientRect()
912
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
913
+ return
914
+ }
915
+
916
+ if (event.key === '-' || event.key === '_') {
917
+ event.preventDefault()
918
+ const rect = canvas.getBoundingClientRect()
919
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
920
+ return
921
+ }
922
+
923
+ if (event.key === '0') {
924
+ event.preventDefault()
925
+ resetView()
926
+ }
927
+ })
375
928
  }
376
929
 
377
930
  const loadAgents = async () => {
@@ -386,9 +939,10 @@ const loadAgents = async () => {
386
939
 
387
940
  state.agentId = selected
388
941
  if (signature !== state.agentsSignature) {
942
+ const formatAgentLabel = (agent) => agent.id
389
943
  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>'
944
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
945
+ : '<option value="shared">shared</option>'
392
946
  state.agentsSignature = signature
393
947
  }
394
948
  elements.agent.value = selected
@@ -417,14 +971,29 @@ const loadGraph = async (options = { reset: false }) => {
417
971
  state.graph = graph
418
972
  state.nodes = layout.nodes
419
973
  state.edges = layout.edges
974
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
975
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
976
+ if (edge.target) {
977
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
978
+ }
979
+ return degrees
980
+ }, new Map())
981
+ state.nodeDetails = new Map()
982
+ resetContentFilter()
983
+ recomputeVisibility()
984
+ scheduleContentFilterSync()
420
985
  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'
986
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
987
  elements.nodeCount.textContent = graph.nodes.length
423
988
  elements.edgeCount.textContent = graph.edges.length
424
989
  elements.tagCount.textContent = tags.size
425
990
  resize()
426
991
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
992
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
993
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
994
+ if (!selectedNode && elements.contentDialog.open) {
995
+ elements.contentDialog.close()
996
+ }
428
997
  }
429
998
 
430
999
  bindEvents()
@@ -441,10 +1010,7 @@ const refreshGraphLoop = () => {
441
1010
  return
442
1011
  }
443
1012
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
1013
+ loadGraph().catch(handleGraphRefreshError)
448
1014
 
449
1015
  tickCounter += 1
450
1016
  if (tickCounter % 3 === 0) {
@@ -461,7 +1027,6 @@ loadAgents()
461
1027
  setInterval(refreshGraphLoop, pollIntervalMs)
462
1028
  })
463
1029
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
1030
  console.error(error)
466
1031
  })
467
1032
 
@@ -470,9 +1035,6 @@ document.addEventListener('visibilitychange', () => {
470
1035
  return
471
1036
  }
472
1037
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
1038
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
1039
  })
478
1040
  `;