@andespindola/brainlink 0.1.0-beta.1 → 0.1.0-beta.11

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +241 -10
  3. package/dist/application/add-note.js +62 -13
  4. package/dist/application/analyze-vault.js +104 -9
  5. package/dist/application/frontend/client-css.js +154 -71
  6. package/dist/application/frontend/client-html.js +42 -33
  7. package/dist/application/frontend/client-js.js +316 -84
  8. package/dist/application/get-graph-layout.js +22 -7
  9. package/dist/application/get-graph-node.js +12 -0
  10. package/dist/application/get-graph-summary.js +12 -0
  11. package/dist/application/index-vault.js +7 -0
  12. package/dist/application/migrate-vault.js +91 -0
  13. package/dist/application/search-graph-node-ids.js +12 -0
  14. package/dist/application/search-knowledge.js +74 -4
  15. package/dist/application/server/routes.js +27 -1
  16. package/dist/cli/commands/agent-commands.js +412 -0
  17. package/dist/cli/commands/config-commands.js +167 -0
  18. package/dist/cli/commands/read-commands.js +25 -8
  19. package/dist/cli/commands/write-commands.js +173 -4
  20. package/dist/cli/main.js +4 -0
  21. package/dist/cli/runtime.js +5 -2
  22. package/dist/domain/embeddings.js +2 -1
  23. package/dist/domain/graph-layout.js +20 -14
  24. package/dist/domain/markdown.js +36 -4
  25. package/dist/infrastructure/config.js +94 -8
  26. package/dist/infrastructure/file-system-vault.js +15 -0
  27. package/dist/infrastructure/paths.js +9 -1
  28. package/dist/infrastructure/search-packs.js +151 -0
  29. package/dist/infrastructure/session-state.js +172 -0
  30. package/dist/infrastructure/sqlite/graph-reader.js +252 -105
  31. package/dist/infrastructure/sqlite/recovery.js +83 -0
  32. package/dist/infrastructure/sqlite/schema.js +4 -1
  33. package/dist/infrastructure/sqlite/search-reader.js +104 -72
  34. package/dist/infrastructure/sqlite-index.js +16 -3
  35. package/dist/mcp/main.js +11 -3
  36. package/dist/mcp/server.js +17 -2
  37. package/dist/mcp/startup.js +35 -0
  38. package/dist/mcp/tools.js +571 -19
  39. package/docs/AGENT_USAGE.md +87 -3
  40. package/docs/ARCHITECTURE.md +16 -1
  41. package/docs/QUICKSTART.md +104 -0
  42. package/docs/RELEASE.md +3 -3
  43. package/package.json +1 -1
@@ -1,17 +1,25 @@
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
3
5
  const state = {
4
6
  graph: { nodes: [], edges: [] },
5
7
  nodes: [],
6
8
  edges: [],
9
+ visibleNodes: [],
10
+ visibleEdges: [],
11
+ nodeDegrees: new Map(),
7
12
  selected: null,
8
13
  hovered: null,
9
14
  query: '',
15
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
16
  agentId: '',
11
17
  agentsSignature: '',
18
+ nodeDetails: new Map(),
12
19
  transform: { x: 0, y: 0, scale: 1 },
13
20
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
14
21
  graphSignature: '',
22
+ graphStatus: '',
15
23
  last: performance.now()
16
24
  }
17
25
 
@@ -23,26 +31,40 @@ const escapeHtml = value => String(value)
23
31
  .replaceAll('"', '"')
24
32
  .replaceAll("'", ''')
25
33
  const elements = {
26
- stats: byId('stats'),
27
34
  search: byId('search'),
28
35
  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
36
  nodeCount: byId('nodeCount'),
37
37
  edgeCount: byId('edgeCount'),
38
38
  tagCount: byId('tagCount'),
39
39
  zoomIn: byId('zoomIn'),
40
40
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
41
+ fit: byId('fit'),
42
+ reset: byId('reset'),
43
+ contentDialog: byId('contentDialog'),
44
+ contentTitle: byId('contentTitle'),
45
+ contentPath: byId('contentPath'),
46
+ contentTags: byId('contentTags'),
47
+ contentOutgoing: byId('contentOutgoing'),
48
+ contentIncoming: byId('contentIncoming'),
49
+ contentBody: byId('contentBody'),
50
+ contentClose: byId('contentClose')
51
+ }
52
+
53
+ const zoomRange = {
54
+ min: 0.05,
55
+ max: 4.5
42
56
  }
43
57
 
44
58
  const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
45
59
 
60
+ const setGraphStatus = text => {
61
+ state.graphStatus = text
62
+ }
63
+
64
+ const handleGraphRefreshError = error => {
65
+ console.error(error)
66
+ }
67
+
46
68
  const graphTheme = {
47
69
  node: '#aeb8c5',
48
70
  nodeSelected: '#f3f7fb',
@@ -66,30 +88,96 @@ const resize = () => {
66
88
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
67
89
  }
68
90
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
91
+ const normalizeQuery = value => value.trim().toLowerCase()
92
+
93
+ const localFilteredNodes = query =>
94
+ state.nodes.filter(node =>
73
95
  node.title.toLowerCase().includes(query) ||
74
96
  node.path.toLowerCase().includes(query) ||
75
97
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
98
  )
77
- }
78
99
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
100
+ const filteredNodes = () => {
101
+ const query = normalizeQuery(state.query)
102
+ if (!query) return state.nodes
103
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
104
+ return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
105
+ }
106
+
107
+ return localFilteredNodes(query)
108
+ }
80
109
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
110
+ const recomputeVisibility = () => {
111
+ const nodes = filteredNodes()
112
+ const ids = new Set(nodes.map(node => node.id))
113
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
114
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
115
+ ? [...edges]
116
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
117
+ .slice(0, largeGraphEdgeRenderLimit)
118
+ : edges
119
+
120
+ state.visibleNodes = nodes
121
+ state.visibleEdges = limitedEdges
84
122
  }
85
123
 
86
124
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
87
125
 
88
- const resetView = () => {
126
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
127
+
128
+ const graphBounds = nodes => {
129
+ if (nodes.length === 0) return null
130
+ let minX = Number.POSITIVE_INFINITY
131
+ let maxX = Number.NEGATIVE_INFINITY
132
+ let minY = Number.POSITIVE_INFINITY
133
+ let maxY = Number.NEGATIVE_INFINITY
134
+
135
+ nodes.forEach(node => {
136
+ const radius = nodeRadius(node)
137
+ minX = Math.min(minX, node.x - radius)
138
+ maxX = Math.max(maxX, node.x + radius)
139
+ minY = Math.min(minY, node.y - radius)
140
+ maxY = Math.max(maxY, node.y + radius)
141
+ })
142
+
143
+ return {
144
+ minX,
145
+ maxX,
146
+ minY,
147
+ maxY,
148
+ width: Math.max(maxX - minX, 1),
149
+ height: Math.max(maxY - minY, 1)
150
+ }
151
+ }
152
+
153
+ const fitView = (options = { useFiltered: true }) => {
89
154
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
155
+ const width = Math.max(rect.width, 320)
156
+ const height = Math.max(rect.height, 320)
157
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
158
+ const bounds = graphBounds(nodes)
159
+
160
+ if (!bounds) {
161
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
162
+ return
163
+ }
164
+
165
+ const padding = 100
166
+ const scaleX = width / (bounds.width + padding * 2)
167
+ const scaleY = height / (bounds.height + padding * 2)
168
+ const scale = clampScale(Math.min(scaleX, scaleY))
169
+ const centerX = (bounds.minX + bounds.maxX) / 2
170
+ const centerY = (bounds.minY + bounds.maxY) / 2
171
+
172
+ state.transform = {
173
+ x: width / 2 - centerX * scale,
174
+ y: height / 2 - centerY * scale,
175
+ scale
176
+ }
91
177
  }
92
178
 
179
+ const resetView = () => fitView({ useFiltered: false })
180
+
93
181
  const createLayout = graph => {
94
182
  const nodes = graph.nodes.map(node => ({
95
183
  ...node,
@@ -111,18 +199,79 @@ const encodeEntityTag = (value) => {
111
199
  binary += String.fromCharCode(utf8[index])
112
200
  }
113
201
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
202
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
203
  }
116
204
 
117
205
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
206
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
207
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
208
  })
121
209
 
210
+ const resetContentFilter = () => {
211
+ if (state.contentFilter.timer) {
212
+ clearTimeout(state.contentFilter.timer)
213
+ }
214
+ state.contentFilter = {
215
+ query: '',
216
+ ids: null,
217
+ token: state.contentFilter.token + 1,
218
+ timer: null
219
+ }
220
+ recomputeVisibility()
221
+ }
222
+
223
+ const syncContentFilter = async (query, token) => {
224
+ const response = await fetch(
225
+ '/api/graph-filter?q=' +
226
+ encodeURIComponent(query) +
227
+ '&limit=' +
228
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
229
+ agentQuery()
230
+ )
231
+
232
+ if (!response.ok || token !== state.contentFilter.token) {
233
+ return
234
+ }
235
+
236
+ const payload = await response.json()
237
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
238
+ if (token !== state.contentFilter.token) {
239
+ return
240
+ }
241
+
242
+ state.contentFilter.query = query
243
+ state.contentFilter.ids = new Set(nodeIds)
244
+ recomputeVisibility()
245
+ }
246
+
247
+ const scheduleContentFilterSync = () => {
248
+ const query = normalizeQuery(state.query)
249
+ if (!query) {
250
+ resetContentFilter()
251
+ return
252
+ }
253
+
254
+ if (state.contentFilter.timer) {
255
+ clearTimeout(state.contentFilter.timer)
256
+ }
257
+
258
+ const token = state.contentFilter.token + 1
259
+ state.contentFilter = {
260
+ query: state.contentFilter.query,
261
+ ids: state.contentFilter.ids,
262
+ token,
263
+ timer: setTimeout(() => {
264
+ syncContentFilter(query, token).catch(() => {})
265
+ }, 180)
266
+ }
267
+ }
268
+
122
269
  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))
270
+ const nodes = state.visibleNodes
271
+ const edges = state.visibleEdges
272
+ if (nodes.length > 1200) {
273
+ return
274
+ }
126
275
  const strength = Math.min(delta / 16, 2)
127
276
 
128
277
  edges.forEach(edge => {
@@ -181,7 +330,11 @@ const worldPoint = event => {
181
330
  }
182
331
 
183
332
  const hitNode = point => {
184
- const nodes = filteredNodes()
333
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
334
+ return null
335
+ }
336
+
337
+ const nodes = state.visibleNodes
185
338
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
339
  const node = nodes[index]
187
340
  const radius = nodeRadius(node)
@@ -191,13 +344,18 @@ const hitNode = point => {
191
344
  }
192
345
 
193
346
  const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
347
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
348
  return 9 + Math.min(degree, 8) * 1.6
196
349
  }
197
350
 
198
351
  const render = now => {
199
352
  const delta = now - state.last
200
353
  state.last = now
354
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 180 : 16
355
+ if (delta < minFrameIntervalMs) {
356
+ requestAnimationFrame(render)
357
+ return
358
+ }
201
359
  const rect = canvas.getBoundingClientRect()
202
360
  const width = Math.max(rect.width, 320)
203
361
  const height = Math.max(rect.height, 320)
@@ -214,7 +372,10 @@ const render = now => {
214
372
  ctx.translate(state.transform.x, state.transform.y)
215
373
  ctx.scale(state.transform.scale, state.transform.scale)
216
374
 
217
- visibleEdges().forEach(edge => {
375
+ tick(delta)
376
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
377
+ if (drawEdges) {
378
+ state.visibleEdges.forEach(edge => {
218
379
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
380
  ctx.beginPath()
220
381
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +383,10 @@ const render = now => {
222
383
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
384
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
385
  ctx.stroke()
225
- })
386
+ })
387
+ }
226
388
 
227
- filteredNodes().forEach(node => {
389
+ state.visibleNodes.forEach(node => {
228
390
  const radius = nodeRadius(node)
229
391
  const isSelected = state.selected?.id === node.id
230
392
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +402,11 @@ const render = now => {
240
402
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
403
  ctx.stroke()
242
404
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
405
+ const shouldDrawLabels =
406
+ isSelected ||
407
+ isHovered ||
408
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
409
+ if (shouldDrawLabels) {
244
410
  ctx.fillStyle = graphTheme.label
245
411
  ctx.font = '12px Inter, system-ui, sans-serif'
246
412
  ctx.textAlign = 'center'
@@ -257,22 +423,7 @@ const list = items => items.length
257
423
  ? 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
424
  : '<li><small>No links found.</small></li>'
259
425
 
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
- }
426
+ const linkedNodes = node => {
276
427
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
428
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
429
  ...linkedNode,
@@ -288,56 +439,129 @@ const selectNode = node => {
288
439
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
440
  .filter(Boolean)
290
441
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
442
+ return { outgoing, incoming }
443
+ }
444
+
445
+ const fetchNodeDetails = async node => {
446
+ const cached = state.nodeDetails.get(node.id)
447
+ if (cached) {
448
+ return cached
449
+ }
450
+
451
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
452
+ if (!response.ok) {
453
+ throw new Error('Failed to load graph node details')
454
+ }
455
+
456
+ const payload = await response.json()
457
+ const detail = payload?.node
458
+ if (!detail || !detail.id) {
459
+ throw new Error('Invalid graph node payload')
460
+ }
461
+ state.nodeDetails.set(detail.id, detail)
462
+ return detail
463
+ }
464
+
465
+ const openContentDialog = async node => {
466
+ if (!node) return
467
+ const { outgoing, incoming } = linkedNodes(node)
468
+ elements.contentTitle.textContent = node.title
469
+ elements.contentPath.textContent = node.path
470
+ elements.contentTags.innerHTML = node.tags.length
294
471
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
472
  : '<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)
473
+ elements.contentOutgoing.innerHTML = list(outgoing)
474
+ elements.contentIncoming.innerHTML = list(incoming)
475
+ elements.contentBody.textContent = 'Loading note content...'
476
+ if (!elements.contentDialog.open) {
477
+ elements.contentDialog.showModal()
478
+ }
479
+
480
+ try {
481
+ const detailedNode = await fetchNodeDetails(node)
482
+ if (state.selected?.id !== node.id) {
483
+ return
484
+ }
485
+ elements.contentBody.textContent = detailedNode.content
486
+ } catch {
487
+ elements.contentBody.textContent = 'Unable to load note content.'
488
+ }
489
+ }
490
+
491
+ const selectNode = (node, options = { openContent: false }) => {
492
+ state.selected = node
493
+ if (node && options.openContent) {
494
+ openContentDialog(node).catch(() => {
495
+ elements.contentBody.textContent = 'Unable to load note content.'
496
+ })
497
+ }
300
498
  }
301
499
 
302
500
  const selectNodeById = id => {
303
501
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
502
+ if (node) selectNode(node, { openContent: true })
305
503
  }
306
504
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
505
+ const zoomAtPoint = (screenX, screenY, factor) => {
506
+ const nextScale = clampScale(state.transform.scale * factor)
507
+ if (nextScale === state.transform.scale) return
508
+ const worldX = (screenX - state.transform.x) / state.transform.scale
509
+ const worldY = (screenY - state.transform.y) / state.transform.scale
510
+ state.transform.scale = nextScale
511
+ state.transform.x = screenX - worldX * nextScale
512
+ state.transform.y = screenY - worldY * nextScale
309
513
  }
310
514
 
311
515
  const bindEvents = () => {
312
516
  window.addEventListener('resize', resize)
313
517
  elements.search.addEventListener('input', event => {
314
518
  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'
519
+ recomputeVisibility()
520
+ scheduleContentFilterSync()
318
521
  })
319
522
  elements.agent.addEventListener('change', event => {
320
523
  state.agentId = event.target.value
321
524
  state.selected = null
525
+ state.nodeDetails = new Map()
526
+ resetContentFilter()
527
+ recomputeVisibility()
528
+ scheduleContentFilterSync()
322
529
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
530
  console.error(error)
325
531
  })
326
532
  })
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)
533
+ elements.zoomIn.addEventListener('click', () => {
534
+ const rect = canvas.getBoundingClientRect()
535
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
536
+ })
537
+ elements.zoomOut.addEventListener('click', () => {
538
+ const rect = canvas.getBoundingClientRect()
539
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
540
+ })
541
+ if (elements.fit) {
542
+ elements.fit.addEventListener('click', () => {
543
+ fitView({ useFiltered: true })
336
544
  })
545
+ }
546
+ elements.reset.addEventListener('click', () => {
547
+ resetView()
548
+ })
549
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
550
+ elements.contentDialog.addEventListener('click', event => {
551
+ const target = event.target
552
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
553
+ selectNodeById(target.dataset.nodeId)
554
+ return
555
+ }
556
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
337
557
  })
338
558
  canvas.addEventListener('wheel', event => {
339
559
  event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
560
+ const rect = canvas.getBoundingClientRect()
561
+ const cursorX = event.clientX - rect.left
562
+ const cursorY = event.clientY - rect.top
563
+ const factor = event.deltaY < 0 ? 1.08 : 0.92
564
+ zoomAtPoint(cursorX, cursorY, factor)
341
565
  }, { passive: false })
342
566
  canvas.addEventListener('pointerdown', event => {
343
567
  const point = worldPoint(event)
@@ -367,8 +591,8 @@ const bindEvents = () => {
367
591
  state.transform.y += dy
368
592
  })
369
593
  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)
594
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
595
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
596
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
597
  canvas.releasePointerCapture(event.pointerId)
374
598
  })
@@ -417,14 +641,29 @@ const loadGraph = async (options = { reset: false }) => {
417
641
  state.graph = graph
418
642
  state.nodes = layout.nodes
419
643
  state.edges = layout.edges
644
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
645
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
646
+ if (edge.target) {
647
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
648
+ }
649
+ return degrees
650
+ }, new Map())
651
+ state.nodeDetails = new Map()
652
+ resetContentFilter()
653
+ recomputeVisibility()
654
+ scheduleContentFilterSync()
420
655
  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'
656
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
657
  elements.nodeCount.textContent = graph.nodes.length
423
658
  elements.edgeCount.textContent = graph.edges.length
424
659
  elements.tagCount.textContent = tags.size
425
660
  resize()
426
661
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
662
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
663
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
664
+ if (!selectedNode && elements.contentDialog.open) {
665
+ elements.contentDialog.close()
666
+ }
428
667
  }
429
668
 
430
669
  bindEvents()
@@ -441,10 +680,7 @@ const refreshGraphLoop = () => {
441
680
  return
442
681
  }
443
682
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
683
+ loadGraph().catch(handleGraphRefreshError)
448
684
 
449
685
  tickCounter += 1
450
686
  if (tickCounter % 3 === 0) {
@@ -461,7 +697,6 @@ loadAgents()
461
697
  setInterval(refreshGraphLoop, pollIntervalMs)
462
698
  })
463
699
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
700
  console.error(error)
466
701
  })
467
702
 
@@ -470,9 +705,6 @@ document.addEventListener('visibilitychange', () => {
470
705
  return
471
706
  }
472
707
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
708
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
709
  })
478
710
  `;
@@ -1,26 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
2
- import { getGraph } from './get-graph.js';
5
+ import { getGraphSummary } from './get-graph-summary.js';
3
6
  const graphLayoutCache = new Map();
7
+ const readDatabaseSignature = async (vaultPath) => {
8
+ try {
9
+ const info = await stat(join(vaultPath, '.brainlink', 'brainlink.db'));
10
+ return `${Math.floor(info.mtimeMs)}:${info.size}`;
11
+ }
12
+ catch {
13
+ return '0:0';
14
+ }
15
+ };
4
16
  const createGraphSignature = (graph) => {
5
17
  const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
6
18
  const edgesSignature = graph.edges
7
19
  .map((edge) => `${edge.source}|${edge.target ?? ''}|${edge.targetTitle}|${edge.weight}|${edge.priority}`)
8
20
  .join('\n');
9
- return `${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`;
21
+ return createHash('sha256')
22
+ .update(`${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`)
23
+ .digest('hex');
10
24
  };
11
25
  export const getGraphLayout = async (vaultPath, agentId) => {
12
- const graph = await getGraph(vaultPath, agentId);
13
- const signature = createGraphSignature(graph);
26
+ const databaseSignature = await readDatabaseSignature(vaultPath);
14
27
  const cacheKey = `${vaultPath}:${agentId ?? ''}`;
15
28
  const cached = graphLayoutCache.get(cacheKey);
16
- if (cached?.signature === signature) {
29
+ if (cached?.databaseSignature === databaseSignature) {
17
30
  return {
18
- signature,
31
+ signature: cached.signature,
19
32
  layout: cached.layout
20
33
  };
21
34
  }
35
+ const graph = await getGraphSummary(vaultPath, agentId);
36
+ const signature = createGraphSignature(graph);
22
37
  const layout = createCauliflowerGraphLayout(graph);
23
- graphLayoutCache.set(cacheKey, { signature, layout });
38
+ graphLayoutCache.set(cacheKey, { databaseSignature, signature, layout });
24
39
  return {
25
40
  signature,
26
41
  layout
@@ -0,0 +1,12 @@
1
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
2
+ import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
3
+ export const getGraphNode = async (vaultPath, id, agentId) => {
4
+ const absoluteVaultPath = await ensureVault(vaultPath);
5
+ const index = openSqliteIndex(absoluteVaultPath);
6
+ try {
7
+ return index.getGraphNode(id, agentId);
8
+ }
9
+ finally {
10
+ index.close();
11
+ }
12
+ };
@@ -0,0 +1,12 @@
1
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
2
+ import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
3
+ export const getGraphSummary = async (vaultPath, agentId) => {
4
+ const absoluteVaultPath = await ensureVault(vaultPath);
5
+ const index = openSqliteIndex(absoluteVaultPath);
6
+ try {
7
+ return index.getGraphSummary(agentId);
8
+ }
9
+ finally {
10
+ index.close();
11
+ }
12
+ };
@@ -3,6 +3,7 @@ import { sharedAgentId } from '../domain/agents.js';
3
3
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
4
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
5
5
  import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
+ import { buildSearchPacks } from '../infrastructure/search-packs.js';
6
7
  import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
7
8
  const toTitleKey = (title) => title.toLowerCase();
8
9
  const appendTitleEntry = (map, document) => {
@@ -63,6 +64,12 @@ export const indexVault = async (vaultPath) => {
63
64
  try {
64
65
  index.reset();
65
66
  index.saveDocuments(indexedDocuments);
67
+ try {
68
+ await buildSearchPacks(absoluteVaultPath, indexedDocuments);
69
+ }
70
+ catch {
71
+ // Pack generation is best-effort. SQLite index remains the primary path.
72
+ }
66
73
  return {
67
74
  documentCount: indexedDocuments.length,
68
75
  chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),