@andespindola/brainlink 0.1.0-alpha.0
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/AGENTS.md +142 -0
- package/CHANGELOG.md +13 -0
- package/CONTRIBUTING.md +28 -0
- package/LICENSE +23 -0
- package/README.md +715 -0
- package/SECURITY.md +35 -0
- package/dist/application/add-note.js +30 -0
- package/dist/application/analyze-vault.js +28 -0
- package/dist/application/build-context.js +15 -0
- package/dist/application/frontend/client-css.js +294 -0
- package/dist/application/frontend/client-html.js +66 -0
- package/dist/application/frontend/client-js.js +416 -0
- package/dist/application/get-graph-layout.js +3 -0
- package/dist/application/get-graph.js +12 -0
- package/dist/application/index-vault.js +67 -0
- package/dist/application/list-agents.js +12 -0
- package/dist/application/list-links.js +22 -0
- package/dist/application/search-knowledge.js +19 -0
- package/dist/application/server/host-security.js +6 -0
- package/dist/application/server/http.js +13 -0
- package/dist/application/server/routes.js +88 -0
- package/dist/application/server/types.js +1 -0
- package/dist/application/start-server.js +54 -0
- package/dist/application/watch-vault.js +36 -0
- package/dist/benchmarks/large-vault.js +88 -0
- package/dist/cli/commands/read-commands.js +149 -0
- package/dist/cli/commands/write-commands.js +107 -0
- package/dist/cli/main.js +21 -0
- package/dist/cli/runtime.js +18 -0
- package/dist/cli/types.js +1 -0
- package/dist/domain/agents.js +11 -0
- package/dist/domain/context.js +44 -0
- package/dist/domain/embeddings.js +117 -0
- package/dist/domain/graph-analysis.js +48 -0
- package/dist/domain/graph-layout.js +187 -0
- package/dist/domain/ids.js +2 -0
- package/dist/domain/markdown.js +100 -0
- package/dist/domain/note-safety.js +54 -0
- package/dist/domain/tokens.js +1 -0
- package/dist/domain/types.js +1 -0
- package/dist/infrastructure/config.js +60 -0
- package/dist/infrastructure/file-system-vault.js +62 -0
- package/dist/infrastructure/sqlite/document-writer.js +50 -0
- package/dist/infrastructure/sqlite/graph-reader.js +108 -0
- package/dist/infrastructure/sqlite/schema.js +87 -0
- package/dist/infrastructure/sqlite/search-reader.js +156 -0
- package/dist/infrastructure/sqlite/types.js +1 -0
- package/dist/infrastructure/sqlite-index.js +20 -0
- package/docs/AGENT_USAGE.md +477 -0
- package/docs/ARCHITECTURE.md +286 -0
- package/docs/RELEASE.md +67 -0
- package/package.json +67 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
|
+
const ctx = canvas.getContext('2d')
|
|
3
|
+
const state = {
|
|
4
|
+
graph: { nodes: [], edges: [] },
|
|
5
|
+
nodes: [],
|
|
6
|
+
edges: [],
|
|
7
|
+
selected: null,
|
|
8
|
+
hovered: null,
|
|
9
|
+
query: '',
|
|
10
|
+
agentId: '',
|
|
11
|
+
agentsSignature: '',
|
|
12
|
+
transform: { x: 0, y: 0, scale: 1 },
|
|
13
|
+
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
14
|
+
graphSignature: '',
|
|
15
|
+
last: performance.now()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const byId = id => document.getElementById(id)
|
|
19
|
+
const escapeHtml = value => String(value)
|
|
20
|
+
.replaceAll('&', '&')
|
|
21
|
+
.replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>')
|
|
23
|
+
.replaceAll('"', '"')
|
|
24
|
+
.replaceAll("'", ''')
|
|
25
|
+
const elements = {
|
|
26
|
+
stats: byId('stats'),
|
|
27
|
+
search: byId('search'),
|
|
28
|
+
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
|
+
nodeCount: byId('nodeCount'),
|
|
37
|
+
edgeCount: byId('edgeCount'),
|
|
38
|
+
tagCount: byId('tagCount'),
|
|
39
|
+
zoomIn: byId('zoomIn'),
|
|
40
|
+
zoomOut: byId('zoomOut'),
|
|
41
|
+
reset: byId('reset')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
|
|
45
|
+
|
|
46
|
+
const graphTheme = {
|
|
47
|
+
node: '#aeb8c5',
|
|
48
|
+
nodeSelected: '#f3f7fb',
|
|
49
|
+
nodeHover: '#cbd5e1',
|
|
50
|
+
nodeHalo: 'rgba(203, 213, 225, 0.14)',
|
|
51
|
+
nodeHaloActive: 'rgba(243, 247, 251, 0.2)',
|
|
52
|
+
nodeStroke: '#0d0f12',
|
|
53
|
+
nodeStrokeActive: '#ffffff',
|
|
54
|
+
edge: 'rgba(153, 165, 181, 0.16)',
|
|
55
|
+
edgeActive: 'rgba(226, 232, 240, 0.52)',
|
|
56
|
+
label: '#edf2f7'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resize = () => {
|
|
60
|
+
const rect = canvas.getBoundingClientRect()
|
|
61
|
+
const width = Math.max(rect.width, 320)
|
|
62
|
+
const height = Math.max(rect.height, 320)
|
|
63
|
+
const ratio = window.devicePixelRatio || 1
|
|
64
|
+
canvas.width = Math.floor(width * ratio)
|
|
65
|
+
canvas.height = Math.floor(height * ratio)
|
|
66
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const filteredNodes = () => {
|
|
70
|
+
const query = state.query.trim().toLowerCase()
|
|
71
|
+
if (!query) return state.nodes
|
|
72
|
+
return state.nodes.filter(node =>
|
|
73
|
+
node.title.toLowerCase().includes(query) ||
|
|
74
|
+
node.path.toLowerCase().includes(query) ||
|
|
75
|
+
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const visibleIds = () => new Set(filteredNodes().map(node => node.id))
|
|
80
|
+
|
|
81
|
+
const visibleEdges = () => {
|
|
82
|
+
const ids = visibleIds()
|
|
83
|
+
return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const resetView = () => {
|
|
87
|
+
const rect = canvas.getBoundingClientRect()
|
|
88
|
+
state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const createLayout = graph => {
|
|
92
|
+
const nodes = graph.nodes.map(node => ({
|
|
93
|
+
...node,
|
|
94
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
95
|
+
y: Number.isFinite(node.y) ? node.y : 0
|
|
96
|
+
}))
|
|
97
|
+
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
98
|
+
const edges = graph.edges
|
|
99
|
+
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
100
|
+
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
101
|
+
return { nodes, edges }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const graphSignature = graph => JSON.stringify({
|
|
105
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
|
|
106
|
+
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const tick = delta => {
|
|
110
|
+
const nodes = filteredNodes()
|
|
111
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
112
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
113
|
+
const strength = Math.min(delta / 16, 2)
|
|
114
|
+
|
|
115
|
+
edges.forEach(edge => {
|
|
116
|
+
const source = edge.sourceNode
|
|
117
|
+
const target = edge.targetNode
|
|
118
|
+
const dx = target.x - source.x
|
|
119
|
+
const dy = target.y - source.y
|
|
120
|
+
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
121
|
+
const force = (distance - 150) * 0.002 * strength
|
|
122
|
+
const fx = dx * force
|
|
123
|
+
const fy = dy * force
|
|
124
|
+
source.vx += fx
|
|
125
|
+
source.vy += fy
|
|
126
|
+
target.vx -= fx
|
|
127
|
+
target.vy -= fy
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
131
|
+
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
132
|
+
const a = nodes[i]
|
|
133
|
+
const b = nodes[j]
|
|
134
|
+
const dx = b.x - a.x
|
|
135
|
+
const dy = b.y - a.y
|
|
136
|
+
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
137
|
+
const force = Math.min(2600 / (distance * distance), 0.12) * strength
|
|
138
|
+
const fx = (dx / distance) * force
|
|
139
|
+
const fy = (dy / distance) * force
|
|
140
|
+
a.vx -= fx
|
|
141
|
+
a.vy -= fy
|
|
142
|
+
b.vx += fx
|
|
143
|
+
b.vy += fy
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
nodes.forEach(node => {
|
|
148
|
+
if (state.pointer.dragNode === node) {
|
|
149
|
+
node.vx = 0
|
|
150
|
+
node.vy = 0
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
node.vx += -node.x * 0.0008 * strength
|
|
154
|
+
node.vy += -node.y * 0.0008 * strength
|
|
155
|
+
node.vx *= 0.88
|
|
156
|
+
node.vy *= 0.88
|
|
157
|
+
node.x += node.vx * strength
|
|
158
|
+
node.y += node.vy * strength
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const worldPoint = event => {
|
|
163
|
+
const rect = canvas.getBoundingClientRect()
|
|
164
|
+
return {
|
|
165
|
+
x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
|
|
166
|
+
y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const hitNode = point => {
|
|
171
|
+
const nodes = filteredNodes()
|
|
172
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
173
|
+
const node = nodes[index]
|
|
174
|
+
const radius = nodeRadius(node)
|
|
175
|
+
if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nodeRadius = node => {
|
|
181
|
+
const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
|
|
182
|
+
return 9 + Math.min(degree, 8) * 1.6
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const render = now => {
|
|
186
|
+
const delta = now - state.last
|
|
187
|
+
state.last = now
|
|
188
|
+
const rect = canvas.getBoundingClientRect()
|
|
189
|
+
const width = Math.max(rect.width, 320)
|
|
190
|
+
const height = Math.max(rect.height, 320)
|
|
191
|
+
ctx.clearRect(0, 0, width, height)
|
|
192
|
+
if (state.nodes.length === 0) {
|
|
193
|
+
ctx.fillStyle = '#99a5b5'
|
|
194
|
+
ctx.font = '14px Inter, system-ui, sans-serif'
|
|
195
|
+
ctx.textAlign = 'center'
|
|
196
|
+
ctx.fillText('No indexed notes found', width / 2, height / 2)
|
|
197
|
+
requestAnimationFrame(render)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
ctx.save()
|
|
201
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
202
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
203
|
+
|
|
204
|
+
visibleEdges().forEach(edge => {
|
|
205
|
+
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
206
|
+
ctx.beginPath()
|
|
207
|
+
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
208
|
+
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
209
|
+
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
210
|
+
ctx.lineWidth = selectedEdge ? 1.8 : 1
|
|
211
|
+
ctx.stroke()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
filteredNodes().forEach(node => {
|
|
215
|
+
const radius = nodeRadius(node)
|
|
216
|
+
const isSelected = state.selected?.id === node.id
|
|
217
|
+
const isHovered = state.hovered?.id === node.id
|
|
218
|
+
ctx.beginPath()
|
|
219
|
+
ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
|
|
220
|
+
ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
|
|
221
|
+
ctx.fill()
|
|
222
|
+
ctx.beginPath()
|
|
223
|
+
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
224
|
+
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
225
|
+
ctx.fill()
|
|
226
|
+
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
227
|
+
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
228
|
+
ctx.stroke()
|
|
229
|
+
|
|
230
|
+
if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
|
|
231
|
+
ctx.fillStyle = graphTheme.label
|
|
232
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
233
|
+
ctx.textAlign = 'center'
|
|
234
|
+
ctx.textBaseline = 'top'
|
|
235
|
+
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
ctx.restore()
|
|
240
|
+
requestAnimationFrame(render)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const list = items => items.length
|
|
244
|
+
? 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) + '</small></li>').join('')
|
|
245
|
+
: '<li><small>No links found.</small></li>'
|
|
246
|
+
|
|
247
|
+
const allNotesList = () => state.nodes.length
|
|
248
|
+
? 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('')
|
|
249
|
+
: '<li><small>No notes indexed.</small></li>'
|
|
250
|
+
|
|
251
|
+
const selectNode = node => {
|
|
252
|
+
state.selected = node
|
|
253
|
+
if (!node) {
|
|
254
|
+
elements.title.textContent = 'Graph Overview'
|
|
255
|
+
elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
|
|
256
|
+
elements.tags.innerHTML = ''
|
|
257
|
+
elements.notes.innerHTML = allNotesList()
|
|
258
|
+
elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
|
|
259
|
+
elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
|
|
260
|
+
elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
264
|
+
const outgoing = state.graph.edges
|
|
265
|
+
.filter(edge => edge.source === node.id)
|
|
266
|
+
.map(edge => edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' })
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
const incoming = state.graph.edges
|
|
269
|
+
.filter(edge => edge.target === node.id)
|
|
270
|
+
.map(edge => nodeById.get(edge.source))
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
|
|
273
|
+
elements.title.textContent = node.title
|
|
274
|
+
elements.path.textContent = node.path
|
|
275
|
+
elements.tags.innerHTML = node.tags.length
|
|
276
|
+
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
277
|
+
: '<span>No tags</span>'
|
|
278
|
+
elements.notes.innerHTML = allNotesList()
|
|
279
|
+
elements.content.textContent = node.content
|
|
280
|
+
elements.outgoing.innerHTML = list(outgoing)
|
|
281
|
+
elements.incoming.innerHTML = list(incoming)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const selectNodeById = id => {
|
|
285
|
+
const node = state.nodes.find(item => item.id === id)
|
|
286
|
+
if (node) selectNode(node)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const zoom = factor => {
|
|
290
|
+
state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const bindEvents = () => {
|
|
294
|
+
window.addEventListener('resize', resize)
|
|
295
|
+
elements.search.addEventListener('input', event => {
|
|
296
|
+
state.query = event.target.value
|
|
297
|
+
elements.stats.textContent = state.query
|
|
298
|
+
? filteredNodes().length + ' filtered notes'
|
|
299
|
+
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
300
|
+
})
|
|
301
|
+
elements.agent.addEventListener('change', event => {
|
|
302
|
+
state.agentId = event.target.value
|
|
303
|
+
state.selected = null
|
|
304
|
+
loadGraph({ reset: true }).catch(error => {
|
|
305
|
+
elements.stats.textContent = 'Failed to load agent graph'
|
|
306
|
+
console.error(error)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
elements.zoomIn.addEventListener('click', () => zoom(1.18))
|
|
310
|
+
elements.zoomOut.addEventListener('click', () => zoom(0.84))
|
|
311
|
+
elements.reset.addEventListener('click', resetView)
|
|
312
|
+
;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
|
|
313
|
+
element.addEventListener('click', event => {
|
|
314
|
+
const target = event.target
|
|
315
|
+
if (!(target instanceof HTMLElement)) return
|
|
316
|
+
const nodeId = target.dataset.nodeId
|
|
317
|
+
if (nodeId) selectNodeById(nodeId)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
canvas.addEventListener('wheel', event => {
|
|
321
|
+
event.preventDefault()
|
|
322
|
+
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
323
|
+
}, { passive: false })
|
|
324
|
+
canvas.addEventListener('pointerdown', event => {
|
|
325
|
+
const point = worldPoint(event)
|
|
326
|
+
const node = hitNode(point)
|
|
327
|
+
state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
|
|
328
|
+
if (node) {
|
|
329
|
+
node.x = point.x
|
|
330
|
+
node.y = point.y
|
|
331
|
+
}
|
|
332
|
+
canvas.setPointerCapture(event.pointerId)
|
|
333
|
+
})
|
|
334
|
+
canvas.addEventListener('pointermove', event => {
|
|
335
|
+
const point = worldPoint(event)
|
|
336
|
+
state.hovered = hitNode(point)
|
|
337
|
+
if (!state.pointer.down) return
|
|
338
|
+
const dx = event.clientX - state.pointer.x
|
|
339
|
+
const dy = event.clientY - state.pointer.y
|
|
340
|
+
state.pointer.x = event.clientX
|
|
341
|
+
state.pointer.y = event.clientY
|
|
342
|
+
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
343
|
+
if (state.pointer.dragNode) {
|
|
344
|
+
state.pointer.dragNode.x = point.x
|
|
345
|
+
state.pointer.dragNode.y = point.y
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
state.transform.x += dx
|
|
349
|
+
state.transform.y += dy
|
|
350
|
+
})
|
|
351
|
+
canvas.addEventListener('pointerup', event => {
|
|
352
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
|
|
353
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
|
|
354
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
355
|
+
canvas.releasePointerCapture(event.pointerId)
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const loadAgents = async () => {
|
|
360
|
+
const response = await fetch('/api/agents')
|
|
361
|
+
const payload = await response.json()
|
|
362
|
+
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
363
|
+
const currentExists = agents.some(agent => agent.id === state.agentId)
|
|
364
|
+
const selected = currentExists
|
|
365
|
+
? state.agentId
|
|
366
|
+
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
367
|
+
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
368
|
+
|
|
369
|
+
state.agentId = selected
|
|
370
|
+
if (signature !== state.agentsSignature) {
|
|
371
|
+
elements.agent.innerHTML = agents.length
|
|
372
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
|
|
373
|
+
: '<option value="shared">shared · 0</option>'
|
|
374
|
+
state.agentsSignature = signature
|
|
375
|
+
}
|
|
376
|
+
elements.agent.value = selected
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const loadGraph = async (options = { reset: false }) => {
|
|
380
|
+
const response = await fetch('/api/graph-layout' + agentQuery())
|
|
381
|
+
const graph = await response.json()
|
|
382
|
+
const signature = graphSignature(graph)
|
|
383
|
+
if (!options.reset && signature === state.graphSignature) return
|
|
384
|
+
const selectedId = state.selected?.id
|
|
385
|
+
const layout = createLayout(graph)
|
|
386
|
+
state.graphSignature = signature
|
|
387
|
+
state.graph = graph
|
|
388
|
+
state.nodes = layout.nodes
|
|
389
|
+
state.edges = layout.edges
|
|
390
|
+
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
391
|
+
elements.stats.textContent = state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live'
|
|
392
|
+
elements.nodeCount.textContent = graph.nodes.length
|
|
393
|
+
elements.edgeCount.textContent = graph.edges.length
|
|
394
|
+
elements.tagCount.textContent = tags.size
|
|
395
|
+
resize()
|
|
396
|
+
if (options.reset) resetView()
|
|
397
|
+
selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
bindEvents()
|
|
401
|
+
requestAnimationFrame(() => {
|
|
402
|
+
resize()
|
|
403
|
+
resetView()
|
|
404
|
+
})
|
|
405
|
+
loadAgents().then(() => loadGraph({ reset: true })).then(() => {
|
|
406
|
+
requestAnimationFrame(render)
|
|
407
|
+
setInterval(() => {
|
|
408
|
+
loadAgents().then(() => loadGraph()).catch(error => {
|
|
409
|
+
elements.stats.textContent = 'Failed to refresh graph'
|
|
410
|
+
console.error(error)
|
|
411
|
+
})
|
|
412
|
+
}, 2000)
|
|
413
|
+
}).catch(error => {
|
|
414
|
+
elements.stats.textContent = 'Failed to load graph'
|
|
415
|
+
console.error(error)
|
|
416
|
+
})`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
3
|
+
export const getGraph = async (vaultPath, agentId) => {
|
|
4
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
6
|
+
try {
|
|
7
|
+
return index.getGraph(agentId);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
index.close();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
|
|
2
|
+
import { sharedAgentId } from '../domain/agents.js';
|
|
3
|
+
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
4
|
+
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
5
|
+
import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
|
|
6
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
7
|
+
const toTitleKey = (title) => title.toLowerCase();
|
|
8
|
+
const appendTitleEntry = (map, document) => {
|
|
9
|
+
map.set(toTitleKey(document.title), document.id);
|
|
10
|
+
return map;
|
|
11
|
+
};
|
|
12
|
+
const createTitleMaps = (documents) => documents.reduce((state, document) => {
|
|
13
|
+
const agentMap = state.byAgent.get(document.agentId) ?? new Map();
|
|
14
|
+
appendTitleEntry(agentMap, document);
|
|
15
|
+
state.byAgent.set(document.agentId, agentMap);
|
|
16
|
+
if (document.agentId === sharedAgentId) {
|
|
17
|
+
appendTitleEntry(state.shared, document);
|
|
18
|
+
}
|
|
19
|
+
return state;
|
|
20
|
+
}, {
|
|
21
|
+
shared: new Map(),
|
|
22
|
+
byAgent: new Map()
|
|
23
|
+
});
|
|
24
|
+
const createScopedTitleResolver = (document, titleMaps) => ({
|
|
25
|
+
get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title) ?? titleMaps.shared.get(title)
|
|
26
|
+
});
|
|
27
|
+
const embedIndexedDocuments = async (documents, providerName) => {
|
|
28
|
+
const provider = createEmbeddingProvider(providerName);
|
|
29
|
+
const chunks = documents.flatMap((document) => document.chunks);
|
|
30
|
+
const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
|
|
31
|
+
const embeddingByChunkId = new Map(chunks.map((chunk, index) => [chunk.id, embeddings[index] ?? []]));
|
|
32
|
+
return documents.map((indexedDocument) => ({
|
|
33
|
+
...indexedDocument,
|
|
34
|
+
chunks: indexedDocument.chunks.map((chunk) => ({
|
|
35
|
+
...chunk,
|
|
36
|
+
embeddingProvider: provider.name,
|
|
37
|
+
embedding: embeddingByChunkId.get(chunk.id) ?? []
|
|
38
|
+
}))
|
|
39
|
+
}));
|
|
40
|
+
};
|
|
41
|
+
export const indexVault = async (vaultPath) => {
|
|
42
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
43
|
+
const config = await loadBrainlinkConfig();
|
|
44
|
+
const files = await readMarkdownFiles(absoluteVaultPath);
|
|
45
|
+
const documents = files.map((file) => parseMarkdownDocument({
|
|
46
|
+
absolutePath: file.absolutePath,
|
|
47
|
+
vaultPath: absoluteVaultPath,
|
|
48
|
+
content: file.content,
|
|
49
|
+
createdAt: file.createdAt,
|
|
50
|
+
updatedAt: file.updatedAt
|
|
51
|
+
}));
|
|
52
|
+
const titleMaps = createTitleMaps(documents);
|
|
53
|
+
const indexedDocuments = await embedIndexedDocuments(documents.map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider);
|
|
54
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
55
|
+
try {
|
|
56
|
+
index.reset();
|
|
57
|
+
index.saveDocuments(indexedDocuments);
|
|
58
|
+
return {
|
|
59
|
+
documentCount: indexedDocuments.length,
|
|
60
|
+
chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
|
|
61
|
+
linkCount: indexedDocuments.reduce((total, document) => total + document.links.length, 0)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
index.close();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
3
|
+
export const listAgents = async (vaultPath) => {
|
|
4
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
6
|
+
try {
|
|
7
|
+
return index.listAgents();
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
index.close();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
3
|
+
export const listLinks = async (vaultPath, agentId) => {
|
|
4
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
6
|
+
try {
|
|
7
|
+
return index.listLinks(agentId);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
index.close();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
export const listBacklinks = async (vaultPath, title, agentId) => {
|
|
14
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
15
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
16
|
+
try {
|
|
17
|
+
return index.listBacklinks(title, agentId);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
index.close();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
+
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
3
|
+
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
4
|
+
import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
|
|
5
|
+
export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) => {
|
|
6
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
7
|
+
const config = await loadBrainlinkConfig();
|
|
8
|
+
const searchMode = sanitizeSearchMode(mode, config.defaultSearchMode);
|
|
9
|
+
const provider = createEmbeddingProvider(config.embeddingProvider);
|
|
10
|
+
const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
|
|
11
|
+
const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
|
|
12
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
13
|
+
try {
|
|
14
|
+
return index.search(query, limit, agentId, searchMode, queryEmbedding);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
index.close();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const isLoopbackHost = (host) => host === 'localhost' || host === '::1' || host === '[::1]' || host.startsWith('127.');
|
|
2
|
+
export const assertPublicBindAllowed = (host, allowPublic = false) => {
|
|
3
|
+
if (!allowPublic && !isLoopbackHost(host)) {
|
|
4
|
+
throw new Error(`Refusing to bind Brainlink server to non-loopback host ${host}. Pass --allow-public only behind your own auth and TLS.`);
|
|
5
|
+
}
|
|
6
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const contentTypes = {
|
|
2
|
+
'.html': 'text/html; charset=utf-8',
|
|
3
|
+
'.css': 'text/css; charset=utf-8',
|
|
4
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
5
|
+
'.json': 'application/json; charset=utf-8'
|
|
6
|
+
};
|
|
7
|
+
export const createJsonResponse = (value) => JSON.stringify(value, null, 2);
|
|
8
|
+
export const parsePositiveInteger = (value, fallback) => {
|
|
9
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
10
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
11
|
+
};
|
|
12
|
+
export const isHttpError = (error) => error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number';
|
|
13
|
+
export const isReadMethod = (request) => request.method === 'GET' || request.method === 'HEAD';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
|
|
2
|
+
import { buildContextPackage } from '../build-context.js';
|
|
3
|
+
import { getGraph } from '../get-graph.js';
|
|
4
|
+
import { getGraphLayout } from '../get-graph-layout.js';
|
|
5
|
+
import { listAgents } from '../list-agents.js';
|
|
6
|
+
import { listBacklinks, listLinks } from '../list-links.js';
|
|
7
|
+
import { searchKnowledge } from '../search-knowledge.js';
|
|
8
|
+
import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
|
|
9
|
+
import { createClientCss } from '../frontend/client-css.js';
|
|
10
|
+
import { createClientHtml } from '../frontend/client-html.js';
|
|
11
|
+
import { createClientJs } from '../frontend/client-js.js';
|
|
12
|
+
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
13
|
+
const readSearchMode = async (url) => {
|
|
14
|
+
const config = await loadBrainlinkConfig();
|
|
15
|
+
return sanitizeSearchMode(url.searchParams.get('mode'), config.defaultSearchMode);
|
|
16
|
+
};
|
|
17
|
+
const hasInvalidSearchMode = (url) => {
|
|
18
|
+
const mode = url.searchParams.get('mode');
|
|
19
|
+
return mode !== null && !['fts', 'semantic', 'hybrid'].includes(mode);
|
|
20
|
+
};
|
|
21
|
+
const createResponse = (body, statusCode = 200, contentType = 'text/plain; charset=utf-8') => ({
|
|
22
|
+
body,
|
|
23
|
+
statusCode,
|
|
24
|
+
headers: {
|
|
25
|
+
'content-type': contentType,
|
|
26
|
+
'cache-control': 'no-store'
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
30
|
+
export const route = async (request, url, vaultPath) => {
|
|
31
|
+
if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
32
|
+
return createResponse(createClientHtml(), 200, contentTypes['.html']);
|
|
33
|
+
}
|
|
34
|
+
if (isReadMethod(request) && url.pathname === '/styles.css') {
|
|
35
|
+
return createResponse(createClientCss(), 200, contentTypes['.css']);
|
|
36
|
+
}
|
|
37
|
+
if (isReadMethod(request) && url.pathname === '/app.js') {
|
|
38
|
+
return createResponse(createClientJs(), 200, contentTypes['.js']);
|
|
39
|
+
}
|
|
40
|
+
if (isReadMethod(request) && url.pathname === '/api/graph') {
|
|
41
|
+
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
42
|
+
}
|
|
43
|
+
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
44
|
+
return createResponse(createJsonResponse(await getGraphLayout(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
45
|
+
}
|
|
46
|
+
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
47
|
+
return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
|
|
48
|
+
}
|
|
49
|
+
if (isReadMethod(request) && url.pathname === '/api/search') {
|
|
50
|
+
const query = url.searchParams.get('q') ?? '';
|
|
51
|
+
const limit = parsePositiveInteger(url.searchParams.get('limit'), 10);
|
|
52
|
+
const mode = await readSearchMode(url);
|
|
53
|
+
if (hasInvalidSearchMode(url)) {
|
|
54
|
+
return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
|
|
55
|
+
}
|
|
56
|
+
return createResponse(createJsonResponse({ query, agent: readAgentQuery(url), limit, mode, results: await searchKnowledge(vaultPath, query, limit, readAgentQuery(url), mode) }), 200, contentTypes['.json']);
|
|
57
|
+
}
|
|
58
|
+
if (isReadMethod(request) && url.pathname === '/api/context') {
|
|
59
|
+
const query = url.searchParams.get('q') ?? '';
|
|
60
|
+
const limit = parsePositiveInteger(url.searchParams.get('limit'), 12);
|
|
61
|
+
const tokens = parsePositiveInteger(url.searchParams.get('tokens'), 2000);
|
|
62
|
+
const mode = await readSearchMode(url);
|
|
63
|
+
if (hasInvalidSearchMode(url)) {
|
|
64
|
+
return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
|
|
65
|
+
}
|
|
66
|
+
return createResponse(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode)), 200, contentTypes['.json']);
|
|
67
|
+
}
|
|
68
|
+
if (isReadMethod(request) && url.pathname === '/api/links') {
|
|
69
|
+
return createResponse(createJsonResponse({ links: await listLinks(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
70
|
+
}
|
|
71
|
+
if (isReadMethod(request) && url.pathname === '/api/backlinks') {
|
|
72
|
+
const title = url.searchParams.get('title') ?? '';
|
|
73
|
+
return createResponse(createJsonResponse({ title, backlinks: await listBacklinks(vaultPath, title, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
74
|
+
}
|
|
75
|
+
if (isReadMethod(request) && url.pathname === '/api/stats') {
|
|
76
|
+
return createResponse(createJsonResponse(await getStats(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
77
|
+
}
|
|
78
|
+
if (isReadMethod(request) && url.pathname === '/api/broken-links') {
|
|
79
|
+
return createResponse(createJsonResponse({ brokenLinks: await getBrokenLinksReport(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
80
|
+
}
|
|
81
|
+
if (isReadMethod(request) && url.pathname === '/api/orphans') {
|
|
82
|
+
return createResponse(createJsonResponse({ orphans: await getOrphansReport(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
83
|
+
}
|
|
84
|
+
if (isReadMethod(request) && url.pathname === '/api/validate') {
|
|
85
|
+
return createResponse(createJsonResponse(await validateVault(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
86
|
+
}
|
|
87
|
+
return createResponse('Not found', 404);
|
|
88
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|