@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +252 -19
  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 +255 -70
  8. package/dist/application/get-graph-layout.js +6 -3
  9. package/dist/application/get-graph-node.js +12 -0
  10. package/dist/application/get-graph-summary.js +12 -0
  11. package/dist/application/migrate-vault.js +91 -0
  12. package/dist/application/search-graph-node-ids.js +12 -0
  13. package/dist/application/search-knowledge.js +56 -1
  14. package/dist/application/server/routes.js +27 -1
  15. package/dist/cli/commands/agent-commands.js +412 -0
  16. package/dist/cli/commands/config-commands.js +167 -0
  17. package/dist/cli/commands/read-commands.js +25 -8
  18. package/dist/cli/commands/write-commands.js +191 -7
  19. package/dist/cli/main.js +4 -0
  20. package/dist/cli/runtime.js +5 -2
  21. package/dist/domain/embeddings.js +2 -1
  22. package/dist/domain/graph-layout.js +20 -14
  23. package/dist/domain/markdown.js +36 -4
  24. package/dist/infrastructure/config.js +96 -8
  25. package/dist/infrastructure/file-system-vault.js +15 -0
  26. package/dist/infrastructure/paths.js +9 -1
  27. package/dist/infrastructure/session-state.js +172 -0
  28. package/dist/infrastructure/sqlite/graph-reader.js +252 -105
  29. package/dist/infrastructure/sqlite/recovery.js +83 -0
  30. package/dist/infrastructure/sqlite/schema.js +4 -1
  31. package/dist/infrastructure/sqlite/search-reader.js +104 -72
  32. package/dist/infrastructure/sqlite-index.js +16 -3
  33. package/dist/mcp/main.js +11 -3
  34. package/dist/mcp/server.js +22 -2
  35. package/dist/mcp/startup.js +35 -0
  36. package/dist/mcp/tools.js +617 -21
  37. package/docs/AGENT_USAGE.md +95 -6
  38. package/docs/ARCHITECTURE.md +15 -1
  39. package/docs/QUICKSTART.md +104 -0
  40. package/docs/RELEASE.md +3 -3
  41. 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
- reset: byId('reset')
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 filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
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 resetView = () => {
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
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
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).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
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.content, node.tags]),
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 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
- }
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
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
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.notes.innerHTML = allNotesList()
297
- elements.content.textContent = node.content
298
- elements.outgoing.innerHTML = list(outgoing)
299
- elements.incoming.innerHTML = list(incoming)
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 zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
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
- elements.stats.textContent = state.query
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', () => zoom(1.18))
328
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
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
- ;[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)
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
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
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
- elements.stats.textContent = state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live'
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
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
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((error) => {
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(error => {
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 { getGraph } from './get-graph.js';
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 `${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`;
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 getGraph(vaultPath, agentId);
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
+ };