@andespindola/brainlink 0.1.0-beta.0 → 0.1.0-beta.10
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 +252 -19
- 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 +255 -70
- package/dist/application/get-graph-layout.js +6 -3
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -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 +56 -1
- 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 +191 -7
- 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 +96 -8
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/paths.js +9 -1
- 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 +22 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +617 -21
- package/docs/AGENT_USAGE.md +95 -6
- package/docs/ARCHITECTURE.md +15 -1
- package/docs/QUICKSTART.md +104 -0
- package/docs/RELEASE.md +3 -3
- package/package.json +1 -1
|
@@ -7,11 +7,14 @@ const state = {
|
|
|
7
7
|
selected: null,
|
|
8
8
|
hovered: null,
|
|
9
9
|
query: '',
|
|
10
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
11
|
agentId: '',
|
|
11
12
|
agentsSignature: '',
|
|
13
|
+
nodeDetails: new Map(),
|
|
12
14
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
15
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
14
16
|
graphSignature: '',
|
|
17
|
+
graphStatus: '',
|
|
15
18
|
last: performance.now()
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -23,26 +26,40 @@ const escapeHtml = value => String(value)
|
|
|
23
26
|
.replaceAll('"', '"')
|
|
24
27
|
.replaceAll("'", ''')
|
|
25
28
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
29
|
search: byId('search'),
|
|
28
30
|
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
31
|
nodeCount: byId('nodeCount'),
|
|
37
32
|
edgeCount: byId('edgeCount'),
|
|
38
33
|
tagCount: byId('tagCount'),
|
|
39
34
|
zoomIn: byId('zoomIn'),
|
|
40
35
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
36
|
+
fit: byId('fit'),
|
|
37
|
+
reset: byId('reset'),
|
|
38
|
+
contentDialog: byId('contentDialog'),
|
|
39
|
+
contentTitle: byId('contentTitle'),
|
|
40
|
+
contentPath: byId('contentPath'),
|
|
41
|
+
contentTags: byId('contentTags'),
|
|
42
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
43
|
+
contentIncoming: byId('contentIncoming'),
|
|
44
|
+
contentBody: byId('contentBody'),
|
|
45
|
+
contentClose: byId('contentClose')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const zoomRange = {
|
|
49
|
+
min: 0.05,
|
|
50
|
+
max: 4.5
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
|
|
45
54
|
|
|
55
|
+
const setGraphStatus = text => {
|
|
56
|
+
state.graphStatus = text
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handleGraphRefreshError = error => {
|
|
60
|
+
console.error(error)
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
const graphTheme = {
|
|
47
64
|
node: '#aeb8c5',
|
|
48
65
|
nodeSelected: '#f3f7fb',
|
|
@@ -66,14 +83,23 @@ const resize = () => {
|
|
|
66
83
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
87
|
+
|
|
88
|
+
const localFilteredNodes = query =>
|
|
89
|
+
state.nodes.filter(node =>
|
|
73
90
|
node.title.toLowerCase().includes(query) ||
|
|
74
91
|
node.path.toLowerCase().includes(query) ||
|
|
75
92
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
93
|
)
|
|
94
|
+
|
|
95
|
+
const filteredNodes = () => {
|
|
96
|
+
const query = normalizeQuery(state.query)
|
|
97
|
+
if (!query) return state.nodes
|
|
98
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
99
|
+
return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return localFilteredNodes(query)
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
const visibleIds = () => new Set(filteredNodes().map(node => node.id))
|
|
@@ -85,11 +111,61 @@ const visibleEdges = () => {
|
|
|
85
111
|
|
|
86
112
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
87
113
|
|
|
88
|
-
const
|
|
114
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
115
|
+
|
|
116
|
+
const graphBounds = nodes => {
|
|
117
|
+
if (nodes.length === 0) return null
|
|
118
|
+
let minX = Number.POSITIVE_INFINITY
|
|
119
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
120
|
+
let minY = Number.POSITIVE_INFINITY
|
|
121
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
122
|
+
|
|
123
|
+
nodes.forEach(node => {
|
|
124
|
+
const radius = nodeRadius(node)
|
|
125
|
+
minX = Math.min(minX, node.x - radius)
|
|
126
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
127
|
+
minY = Math.min(minY, node.y - radius)
|
|
128
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
minX,
|
|
133
|
+
maxX,
|
|
134
|
+
minY,
|
|
135
|
+
maxY,
|
|
136
|
+
width: Math.max(maxX - minX, 1),
|
|
137
|
+
height: Math.max(maxY - minY, 1)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fitView = (options = { useFiltered: true }) => {
|
|
89
142
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
143
|
+
const width = Math.max(rect.width, 320)
|
|
144
|
+
const height = Math.max(rect.height, 320)
|
|
145
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
146
|
+
const bounds = graphBounds(nodes)
|
|
147
|
+
|
|
148
|
+
if (!bounds) {
|
|
149
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const padding = 100
|
|
154
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
155
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
156
|
+
const scale = clampScale(Math.min(scaleX, scaleY))
|
|
157
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
158
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
159
|
+
|
|
160
|
+
state.transform = {
|
|
161
|
+
x: width / 2 - centerX * scale,
|
|
162
|
+
y: height / 2 - centerY * scale,
|
|
163
|
+
scale
|
|
164
|
+
}
|
|
91
165
|
}
|
|
92
166
|
|
|
167
|
+
const resetView = () => fitView({ useFiltered: false })
|
|
168
|
+
|
|
93
169
|
const createLayout = graph => {
|
|
94
170
|
const nodes = graph.nodes.map(node => ({
|
|
95
171
|
...node,
|
|
@@ -111,14 +187,71 @@ const encodeEntityTag = (value) => {
|
|
|
111
187
|
binary += String.fromCharCode(utf8[index])
|
|
112
188
|
}
|
|
113
189
|
|
|
114
|
-
return btoa(binary).
|
|
190
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
191
|
}
|
|
116
192
|
|
|
117
193
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
194
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
195
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
196
|
})
|
|
121
197
|
|
|
198
|
+
const resetContentFilter = () => {
|
|
199
|
+
if (state.contentFilter.timer) {
|
|
200
|
+
clearTimeout(state.contentFilter.timer)
|
|
201
|
+
}
|
|
202
|
+
state.contentFilter = {
|
|
203
|
+
query: '',
|
|
204
|
+
ids: null,
|
|
205
|
+
token: state.contentFilter.token + 1,
|
|
206
|
+
timer: null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const syncContentFilter = async (query, token) => {
|
|
211
|
+
const response = await fetch(
|
|
212
|
+
'/api/graph-filter?q=' +
|
|
213
|
+
encodeURIComponent(query) +
|
|
214
|
+
'&limit=' +
|
|
215
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
216
|
+
agentQuery()
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const payload = await response.json()
|
|
224
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
225
|
+
if (token !== state.contentFilter.token) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
state.contentFilter.query = query
|
|
230
|
+
state.contentFilter.ids = new Set(nodeIds)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const scheduleContentFilterSync = () => {
|
|
234
|
+
const query = normalizeQuery(state.query)
|
|
235
|
+
if (!query) {
|
|
236
|
+
resetContentFilter()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (state.contentFilter.timer) {
|
|
241
|
+
clearTimeout(state.contentFilter.timer)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const token = state.contentFilter.token + 1
|
|
245
|
+
state.contentFilter = {
|
|
246
|
+
query: state.contentFilter.query,
|
|
247
|
+
ids: state.contentFilter.ids,
|
|
248
|
+
token,
|
|
249
|
+
timer: setTimeout(() => {
|
|
250
|
+
syncContentFilter(query, token).catch(() => {})
|
|
251
|
+
}, 180)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
122
255
|
const tick = delta => {
|
|
123
256
|
const nodes = filteredNodes()
|
|
124
257
|
const ids = new Set(nodes.map(node => node.id))
|
|
@@ -257,22 +390,7 @@ const list = items => items.length
|
|
|
257
390
|
? 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
391
|
: '<li><small>No links found.</small></li>'
|
|
259
392
|
|
|
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
|
-
}
|
|
393
|
+
const linkedNodes = node => {
|
|
276
394
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
395
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
396
|
...linkedNode,
|
|
@@ -288,56 +406,123 @@ const selectNode = node => {
|
|
|
288
406
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
407
|
.filter(Boolean)
|
|
290
408
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
409
|
+
return { outgoing, incoming }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const fetchNodeDetails = async node => {
|
|
413
|
+
const cached = state.nodeDetails.get(node.id)
|
|
414
|
+
if (cached) {
|
|
415
|
+
return cached
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
throw new Error('Failed to load graph node details')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const payload = await response.json()
|
|
424
|
+
const detail = payload?.node
|
|
425
|
+
if (!detail || !detail.id) {
|
|
426
|
+
throw new Error('Invalid graph node payload')
|
|
427
|
+
}
|
|
428
|
+
state.nodeDetails.set(detail.id, detail)
|
|
429
|
+
return detail
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const openContentDialog = async node => {
|
|
433
|
+
if (!node) return
|
|
434
|
+
const { outgoing, incoming } = linkedNodes(node)
|
|
435
|
+
elements.contentTitle.textContent = node.title
|
|
436
|
+
elements.contentPath.textContent = node.path
|
|
437
|
+
elements.contentTags.innerHTML = node.tags.length
|
|
294
438
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
439
|
: '<span>No tags</span>'
|
|
296
|
-
elements.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
440
|
+
elements.contentOutgoing.innerHTML = list(outgoing)
|
|
441
|
+
elements.contentIncoming.innerHTML = list(incoming)
|
|
442
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
443
|
+
if (!elements.contentDialog.open) {
|
|
444
|
+
elements.contentDialog.showModal()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
449
|
+
if (state.selected?.id !== node.id) {
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
elements.contentBody.textContent = detailedNode.content
|
|
453
|
+
} catch {
|
|
454
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
459
|
+
state.selected = node
|
|
460
|
+
if (node && options.openContent) {
|
|
461
|
+
openContentDialog(node).catch(() => {
|
|
462
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
463
|
+
})
|
|
464
|
+
}
|
|
300
465
|
}
|
|
301
466
|
|
|
302
467
|
const selectNodeById = id => {
|
|
303
468
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
469
|
+
if (node) selectNode(node, { openContent: true })
|
|
305
470
|
}
|
|
306
471
|
|
|
307
|
-
const
|
|
308
|
-
|
|
472
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
473
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
474
|
+
if (nextScale === state.transform.scale) return
|
|
475
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
476
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
477
|
+
state.transform.scale = nextScale
|
|
478
|
+
state.transform.x = screenX - worldX * nextScale
|
|
479
|
+
state.transform.y = screenY - worldY * nextScale
|
|
309
480
|
}
|
|
310
481
|
|
|
311
482
|
const bindEvents = () => {
|
|
312
483
|
window.addEventListener('resize', resize)
|
|
313
484
|
elements.search.addEventListener('input', event => {
|
|
314
485
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
? filteredNodes().length + ' filtered notes'
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
486
|
+
scheduleContentFilterSync()
|
|
318
487
|
})
|
|
319
488
|
elements.agent.addEventListener('change', event => {
|
|
320
489
|
state.agentId = event.target.value
|
|
321
490
|
state.selected = null
|
|
491
|
+
state.nodeDetails = new Map()
|
|
492
|
+
resetContentFilter()
|
|
493
|
+
scheduleContentFilterSync()
|
|
322
494
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
495
|
console.error(error)
|
|
325
496
|
})
|
|
326
497
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
498
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
499
|
+
const rect = canvas.getBoundingClientRect()
|
|
500
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
|
|
501
|
+
})
|
|
502
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
503
|
+
const rect = canvas.getBoundingClientRect()
|
|
504
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
|
|
505
|
+
})
|
|
506
|
+
if (elements.fit) {
|
|
507
|
+
elements.fit.addEventListener('click', () => fitView({ useFiltered: true }))
|
|
508
|
+
}
|
|
329
509
|
elements.reset.addEventListener('click', resetView)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
510
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
511
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
512
|
+
const target = event.target
|
|
513
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
514
|
+
selectNodeById(target.dataset.nodeId)
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
337
518
|
})
|
|
338
519
|
canvas.addEventListener('wheel', event => {
|
|
339
520
|
event.preventDefault()
|
|
340
|
-
|
|
521
|
+
const rect = canvas.getBoundingClientRect()
|
|
522
|
+
const cursorX = event.clientX - rect.left
|
|
523
|
+
const cursorY = event.clientY - rect.top
|
|
524
|
+
const factor = event.deltaY < 0 ? 1.08 : 0.92
|
|
525
|
+
zoomAtPoint(cursorX, cursorY, factor)
|
|
341
526
|
}, { passive: false })
|
|
342
527
|
canvas.addEventListener('pointerdown', event => {
|
|
343
528
|
const point = worldPoint(event)
|
|
@@ -367,8 +552,8 @@ const bindEvents = () => {
|
|
|
367
552
|
state.transform.y += dy
|
|
368
553
|
})
|
|
369
554
|
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)
|
|
555
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
556
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
557
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
558
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
559
|
})
|
|
@@ -417,14 +602,21 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
602
|
state.graph = graph
|
|
418
603
|
state.nodes = layout.nodes
|
|
419
604
|
state.edges = layout.edges
|
|
605
|
+
state.nodeDetails = new Map()
|
|
606
|
+
resetContentFilter()
|
|
607
|
+
scheduleContentFilterSync()
|
|
420
608
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
421
|
-
|
|
609
|
+
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
422
610
|
elements.nodeCount.textContent = graph.nodes.length
|
|
423
611
|
elements.edgeCount.textContent = graph.edges.length
|
|
424
612
|
elements.tagCount.textContent = tags.size
|
|
425
613
|
resize()
|
|
426
614
|
if (options.reset) resetView()
|
|
427
|
-
|
|
615
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
616
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
617
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
618
|
+
elements.contentDialog.close()
|
|
619
|
+
}
|
|
428
620
|
}
|
|
429
621
|
|
|
430
622
|
bindEvents()
|
|
@@ -441,10 +633,7 @@ const refreshGraphLoop = () => {
|
|
|
441
633
|
return
|
|
442
634
|
}
|
|
443
635
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
636
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
637
|
|
|
449
638
|
tickCounter += 1
|
|
450
639
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +650,6 @@ loadAgents()
|
|
|
461
650
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
651
|
})
|
|
463
652
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
653
|
console.error(error)
|
|
466
654
|
})
|
|
467
655
|
|
|
@@ -470,9 +658,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
658
|
return
|
|
471
659
|
}
|
|
472
660
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
661
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
662
|
})
|
|
478
663
|
`;
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
2
|
-
import {
|
|
3
|
+
import { getGraphSummary } from './get-graph-summary.js';
|
|
3
4
|
const graphLayoutCache = new Map();
|
|
4
5
|
const createGraphSignature = (graph) => {
|
|
5
6
|
const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
|
|
6
7
|
const edgesSignature = graph.edges
|
|
7
8
|
.map((edge) => `${edge.source}|${edge.target ?? ''}|${edge.targetTitle}|${edge.weight}|${edge.priority}`)
|
|
8
9
|
.join('\n');
|
|
9
|
-
return
|
|
10
|
+
return createHash('sha256')
|
|
11
|
+
.update(`${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`)
|
|
12
|
+
.digest('hex');
|
|
10
13
|
};
|
|
11
14
|
export const getGraphLayout = async (vaultPath, agentId) => {
|
|
12
|
-
const graph = await
|
|
15
|
+
const graph = await getGraphSummary(vaultPath, agentId);
|
|
13
16
|
const signature = createGraphSignature(graph);
|
|
14
17
|
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
15
18
|
const cached = graphLayoutCache.get(cacheKey);
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, extname, isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
import { ensureVault, isBucketVaultPath, listVaultFiles, resolveVaultPath, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
4
|
+
const directoryMode = 0o700;
|
|
5
|
+
const fileMode = 0o600;
|
|
6
|
+
const isMarkdownPath = (path) => extname(path).toLowerCase() === '.md';
|
|
7
|
+
const timestamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
|
8
|
+
const isPathInside = (parent, child) => {
|
|
9
|
+
const path = relative(parent, child);
|
|
10
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
11
|
+
};
|
|
12
|
+
const conflictPath = (targetPath) => {
|
|
13
|
+
const extension = extname(targetPath);
|
|
14
|
+
const base = extension ? targetPath.slice(0, -extension.length) : targetPath;
|
|
15
|
+
return `${base}.conflict-${timestamp()}${extension}`;
|
|
16
|
+
};
|
|
17
|
+
const writePreservedFile = async (absolutePath, content) => {
|
|
18
|
+
await mkdir(dirname(absolutePath), { recursive: true, mode: directoryMode });
|
|
19
|
+
await writeFile(absolutePath, content, { mode: fileMode });
|
|
20
|
+
await chmod(absolutePath, fileMode);
|
|
21
|
+
};
|
|
22
|
+
const writeMigratedFile = async (targetVault, targetRoot, absolutePath, content) => {
|
|
23
|
+
if (isBucketVaultPath(targetVault)) {
|
|
24
|
+
await writeMarkdownFile(targetVault, relative(targetRoot, absolutePath), content.toString('utf8'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await writePreservedFile(absolutePath, content);
|
|
28
|
+
};
|
|
29
|
+
export const planVaultMigration = async (source, target) => {
|
|
30
|
+
const sourceFiles = (await listVaultFiles(source)).filter(isMarkdownPath);
|
|
31
|
+
return sourceFiles.reduce(async (statePromise, sourceFile) => {
|
|
32
|
+
const state = await statePromise;
|
|
33
|
+
const targetFile = join(target, relative(source, sourceFile));
|
|
34
|
+
if (!isPathInside(target, targetFile)) {
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
const sourceContent = await readFile(sourceFile);
|
|
38
|
+
try {
|
|
39
|
+
const targetContent = await readFile(targetFile);
|
|
40
|
+
if (sourceContent.equals(targetContent)) {
|
|
41
|
+
return [...state, { kind: 'unchanged', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
|
|
42
|
+
}
|
|
43
|
+
return [...state, { kind: 'conflict', sourcePath: sourceFile, targetPath: conflictPath(targetFile), sourceContent }];
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return [...state, { kind: 'copy', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
|
|
50
|
+
}
|
|
51
|
+
}, Promise.resolve([]));
|
|
52
|
+
};
|
|
53
|
+
export const previewVaultMigration = async (sourceVault, targetVault) => {
|
|
54
|
+
const source = await ensureVault(sourceVault);
|
|
55
|
+
const target = await ensureVault(targetVault);
|
|
56
|
+
if (source === target) {
|
|
57
|
+
return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
|
|
58
|
+
}
|
|
59
|
+
const actions = await planVaultMigration(source, target);
|
|
60
|
+
const copied = actions.filter((action) => action.kind === 'copy').length;
|
|
61
|
+
const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
|
|
62
|
+
const conflicted = actions.filter((action) => action.kind === 'conflict').length;
|
|
63
|
+
return { source, target, copied, unchanged, conflicted };
|
|
64
|
+
};
|
|
65
|
+
export const migrateVaultContent = async (sourceVault, targetVault) => {
|
|
66
|
+
const source = await ensureVault(sourceVault);
|
|
67
|
+
const target = await ensureVault(targetVault);
|
|
68
|
+
if (source === target) {
|
|
69
|
+
return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
|
|
70
|
+
}
|
|
71
|
+
const actions = await planVaultMigration(source, target);
|
|
72
|
+
for (const action of actions) {
|
|
73
|
+
if (action.kind === 'unchanged') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
await writeMigratedFile(targetVault, target, action.targetPath, action.sourceContent);
|
|
77
|
+
}
|
|
78
|
+
const copied = actions.filter((action) => action.kind === 'copy').length;
|
|
79
|
+
const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
|
|
80
|
+
const conflicted = actions.filter((action) => action.kind === 'conflict').length;
|
|
81
|
+
return { source, target, copied, unchanged, conflicted };
|
|
82
|
+
};
|
|
83
|
+
export const shouldMigrateDefaultVault = async (sourceVault, targetVault) => {
|
|
84
|
+
const source = resolveVaultPath(sourceVault);
|
|
85
|
+
const target = resolveVaultPath(targetVault);
|
|
86
|
+
if (source === target) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const [sourceFiles, targetFiles] = await Promise.all([listVaultFiles(source), listVaultFiles(target)]);
|
|
90
|
+
return sourceFiles.filter(isMarkdownPath).length > 0 && targetFiles.filter(isMarkdownPath).length === 0;
|
|
91
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
3
|
+
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
|
|
4
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
6
|
+
try {
|
|
7
|
+
return index.searchGraphNodeIds(query, limit, agentId);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
index.close();
|
|
11
|
+
}
|
|
12
|
+
};
|