@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.
- package/CHANGELOG.md +46 -0
- package/README.md +241 -10
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +104 -9
- package/dist/application/frontend/client-css.js +154 -71
- package/dist/application/frontend/client-html.js +42 -33
- package/dist/application/frontend/client-js.js +316 -84
- package/dist/application/get-graph-layout.js +22 -7
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -0
- package/dist/application/index-vault.js +7 -0
- package/dist/application/migrate-vault.js +91 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +74 -4
- package/dist/application/server/routes.js +27 -1
- package/dist/cli/commands/agent-commands.js +412 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +173 -4
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +20 -14
- package/dist/domain/markdown.js +36 -4
- package/dist/infrastructure/config.js +94 -8
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/search-packs.js +151 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/infrastructure/sqlite/graph-reader.js +252 -105
- package/dist/infrastructure/sqlite/recovery.js +83 -0
- package/dist/infrastructure/sqlite/schema.js +4 -1
- package/dist/infrastructure/sqlite/search-reader.js +104 -72
- package/dist/infrastructure/sqlite-index.js +16 -3
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +17 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +571 -19
- package/docs/AGENT_USAGE.md +87 -3
- package/docs/ARCHITECTURE.md +16 -1
- package/docs/QUICKSTART.md +104 -0
- package/docs/RELEASE.md +3 -3
- 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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
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.
|
|
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 =
|
|
124
|
-
const
|
|
125
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
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?.
|
|
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),
|