@andespindola/brainlink 0.1.0-beta.40 → 0.1.0-beta.41
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 +2 -0
- package/README.md +3 -0
- package/dist/application/frontend/client-js.js +186 -24
- package/dist/application/index-vault.js +137 -21
- package/dist/infrastructure/file-index.js +30 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +50 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
- Improved non-mac browser detection fallback to try installed Edge/Chrome/Firefox/Chromium candidates before system default open.
|
|
32
32
|
- Improved graph filter rendering to keep hub anchor nodes visible (`Memory Hub`/`MOC`/high-degree fallback) for coherent relationship context.
|
|
33
33
|
- Fixed graph modal content loading by correcting agent query parameter composition for `/api/graph-node` and `/api/graph-filter` requests.
|
|
34
|
+
- Improved 50k+ graph rendering performance with viewport-aware spatial node culling, cached render visibility, and node-adjacent edge selection to avoid full graph scans every frame.
|
|
35
|
+
- Added incremental vault indexing with file snapshots to reuse unchanged documents/chunks/embeddings, plus adaptive search-pack rebuild thresholds to avoid full re-compression on small edits.
|
|
34
36
|
|
|
35
37
|
## 0.1.0-beta.3
|
|
36
38
|
|
package/README.md
CHANGED
|
@@ -71,6 +71,8 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
71
71
|
- Middle-out context assembly around the strongest chunk per document.
|
|
72
72
|
- In-process index and context caching with automatic invalidation on index updates.
|
|
73
73
|
- Compressed-space prefiltering for `.blpk` packs before decryption and scan.
|
|
74
|
+
- Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
|
|
75
|
+
- Adaptive compressed-pack rebuild policy to keep indexing fast during small edit batches.
|
|
74
76
|
- Agent namespaces under `agents/<agent-id>/`.
|
|
75
77
|
- S3-compatible bucket vaults through `s3://bucket/prefix` URIs.
|
|
76
78
|
- CLI with machine-readable `--json` output.
|
|
@@ -78,6 +80,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
78
80
|
- Built-in MCP stdio server for agent tool integration.
|
|
79
81
|
- Local HTTP API.
|
|
80
82
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
83
|
+
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
81
84
|
|
|
82
85
|
## Install
|
|
83
86
|
|
|
@@ -3,6 +3,7 @@ const ctx = canvas.getContext('2d')
|
|
|
3
3
|
const largeGraphNodeThreshold = 4000
|
|
4
4
|
const largeGraphEdgeRenderLimit = 16000
|
|
5
5
|
const renderNodeBudget = 1800
|
|
6
|
+
const renderEdgeBudget = 5200
|
|
6
7
|
const minNodePixelRadius = 1.8
|
|
7
8
|
const viewportPaddingPx = 280
|
|
8
9
|
const worldCoordinateLimit = 5_000_000
|
|
@@ -30,7 +31,11 @@ const state = {
|
|
|
30
31
|
graphStatus: '',
|
|
31
32
|
last: performance.now(),
|
|
32
33
|
offscreenFrameCount: 0,
|
|
33
|
-
recoveringViewport: false
|
|
34
|
+
recoveringViewport: false,
|
|
35
|
+
renderVisibilityDirty: true,
|
|
36
|
+
lastViewportKey: '',
|
|
37
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
38
|
+
visibleEdgeByNode: new Map()
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const byId = id => document.getElementById(id)
|
|
@@ -96,6 +101,7 @@ const resize = () => {
|
|
|
96
101
|
canvas.width = Math.floor(width * ratio)
|
|
97
102
|
canvas.height = Math.floor(height * ratio)
|
|
98
103
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
104
|
+
markRenderDirty()
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
const normalizeQuery = value => value.trim().toLowerCase()
|
|
@@ -168,9 +174,158 @@ const recomputeVisibility = () => {
|
|
|
168
174
|
|
|
169
175
|
state.visibleNodes = nodes
|
|
170
176
|
state.visibleEdges = limitedEdges
|
|
177
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
178
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
179
|
+
markRenderDirty()
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
183
|
+
const markRenderDirty = () => {
|
|
184
|
+
state.renderVisibilityDirty = true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const createSpatialIndex = nodes => {
|
|
188
|
+
if (nodes.length === 0) {
|
|
189
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bounds = graphBounds(nodes)
|
|
193
|
+
if (!bounds) {
|
|
194
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const targetNodesPerCell = 18
|
|
198
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
199
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
200
|
+
const buckets = new Map()
|
|
201
|
+
|
|
202
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
203
|
+
const node = nodes[index]
|
|
204
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
205
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
206
|
+
const key = cellX + ':' + cellY
|
|
207
|
+
const bucket = buckets.get(key)
|
|
208
|
+
if (bucket) {
|
|
209
|
+
bucket.push(node)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
buckets.set(key, [node])
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
cellSize,
|
|
217
|
+
minX: bounds.minX,
|
|
218
|
+
minY: bounds.minY,
|
|
219
|
+
maxX: bounds.maxX,
|
|
220
|
+
maxY: bounds.maxY,
|
|
221
|
+
buckets
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
226
|
+
if (state.visibleNodes.length <= 2500) {
|
|
227
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const spatial = state.visibleNodeSpatial
|
|
231
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
232
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
236
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
237
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
238
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
239
|
+
const nodes = []
|
|
240
|
+
|
|
241
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
242
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
243
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
244
|
+
if (!bucket) continue
|
|
245
|
+
|
|
246
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
247
|
+
const node = bucket[index]
|
|
248
|
+
if (isNodeInViewport(node, viewport)) {
|
|
249
|
+
nodes.push(node)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return nodes
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const createVisibleEdgeLookup = edges => {
|
|
259
|
+
const lookup = new Map()
|
|
260
|
+
|
|
261
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
262
|
+
const edge = edges[index]
|
|
263
|
+
if (!edge.target) continue
|
|
264
|
+
|
|
265
|
+
const sourceList = lookup.get(edge.source)
|
|
266
|
+
if (sourceList) {
|
|
267
|
+
sourceList.push(edge)
|
|
268
|
+
} else {
|
|
269
|
+
lookup.set(edge.source, [edge])
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const targetList = lookup.get(edge.target)
|
|
273
|
+
if (targetList) {
|
|
274
|
+
targetList.push(edge)
|
|
275
|
+
} else {
|
|
276
|
+
lookup.set(edge.target, [edge])
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return lookup
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
284
|
+
if (nodeIds.size === 0) {
|
|
285
|
+
return []
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const seen = new Set()
|
|
289
|
+
const collected = []
|
|
290
|
+
|
|
291
|
+
nodeIds.forEach(nodeId => {
|
|
292
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
293
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
294
|
+
const edge = candidateEdges[index]
|
|
295
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const key = edge.source < edge.target
|
|
299
|
+
? edge.source + '|' + edge.target + '|' + edge.targetTitle
|
|
300
|
+
: edge.target + '|' + edge.source + '|' + edge.targetTitle
|
|
301
|
+
if (seen.has(key)) continue
|
|
302
|
+
|
|
303
|
+
seen.add(key)
|
|
304
|
+
collected.push(edge)
|
|
305
|
+
if (collected.length >= renderEdgeBudget) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return collected
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const fallbackViewportNodes = () => {
|
|
315
|
+
const nodes = []
|
|
316
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
317
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
318
|
+
|
|
319
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
320
|
+
nodes.push(state.visibleNodes[index])
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
324
|
+
nodes.push(state.selected)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return nodes
|
|
328
|
+
}
|
|
174
329
|
|
|
175
330
|
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
176
331
|
const isFiniteNumber = value => Number.isFinite(value)
|
|
@@ -232,6 +387,7 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
232
387
|
|
|
233
388
|
if (!bounds) {
|
|
234
389
|
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
390
|
+
markRenderDirty()
|
|
235
391
|
return
|
|
236
392
|
}
|
|
237
393
|
|
|
@@ -259,6 +415,7 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
259
415
|
y: height / 2 - centerY * scale,
|
|
260
416
|
scale
|
|
261
417
|
}
|
|
418
|
+
markRenderDirty()
|
|
262
419
|
}
|
|
263
420
|
|
|
264
421
|
const resetView = () => fitView({ useFiltered: false })
|
|
@@ -429,6 +586,7 @@ const worldPoint = event => {
|
|
|
429
586
|
}
|
|
430
587
|
|
|
431
588
|
const hitNode = point => {
|
|
589
|
+
computeRenderVisibility()
|
|
432
590
|
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
433
591
|
return null
|
|
434
592
|
}
|
|
@@ -491,22 +649,33 @@ const viewportNodeStride = () => {
|
|
|
491
649
|
}
|
|
492
650
|
|
|
493
651
|
const computeRenderVisibility = () => {
|
|
652
|
+
const viewport = worldViewportBounds()
|
|
653
|
+
const viewportKey =
|
|
654
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
655
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
656
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
657
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
658
|
+
Math.round(state.transform.scale * 1000)
|
|
659
|
+
|
|
660
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
state.lastViewportKey = viewportKey
|
|
664
|
+
state.renderVisibilityDirty = false
|
|
665
|
+
|
|
494
666
|
if (state.visibleNodes.length <= 2000) {
|
|
495
667
|
state.renderNodes = state.visibleNodes
|
|
496
668
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
497
|
-
state.renderEdges =
|
|
669
|
+
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
498
670
|
return
|
|
499
671
|
}
|
|
500
672
|
|
|
501
|
-
const
|
|
673
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
502
674
|
const stride = viewportNodeStride()
|
|
503
675
|
const picked = []
|
|
504
676
|
|
|
505
|
-
for (let index = 0; index <
|
|
506
|
-
const node =
|
|
507
|
-
if (!isNodeInViewport(node, viewport)) {
|
|
508
|
-
continue
|
|
509
|
-
}
|
|
677
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
678
|
+
const node = viewportNodes[index]
|
|
510
679
|
|
|
511
680
|
const isPriority =
|
|
512
681
|
node.id === state.selected?.id ||
|
|
@@ -521,26 +690,15 @@ const computeRenderVisibility = () => {
|
|
|
521
690
|
? picked.slice(0, renderNodeBudget)
|
|
522
691
|
: picked
|
|
523
692
|
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
|
|
529
|
-
const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
|
|
530
|
-
return leftDistance - rightDistance
|
|
531
|
-
})
|
|
532
|
-
.slice(0, Math.min(renderNodeBudget, 180))
|
|
533
|
-
const closestIds = new Set(closest.map((node) => node.id))
|
|
534
|
-
|
|
535
|
-
state.renderNodes = closest
|
|
536
|
-
state.renderEdges = state.visibleEdges.filter(
|
|
537
|
-
(edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
|
|
538
|
-
)
|
|
693
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
694
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
695
|
+
state.renderNodes = fallbackNodes
|
|
696
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
539
697
|
return
|
|
540
698
|
}
|
|
541
699
|
|
|
542
700
|
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
543
|
-
const edges =
|
|
701
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
544
702
|
|
|
545
703
|
state.renderNodes = nodes
|
|
546
704
|
state.renderEdges = edges
|
|
@@ -767,6 +925,7 @@ const zoomAtPoint = (screenX, screenY, factor) => {
|
|
|
767
925
|
state.transform.scale = nextScale
|
|
768
926
|
state.transform.x = screenX - worldX * nextScale
|
|
769
927
|
state.transform.y = screenY - worldY * nextScale
|
|
928
|
+
markRenderDirty()
|
|
770
929
|
}
|
|
771
930
|
|
|
772
931
|
const wheelZoomFactor = event => {
|
|
@@ -869,6 +1028,7 @@ const bindEvents = () => {
|
|
|
869
1028
|
if (node) {
|
|
870
1029
|
node.x = point.x
|
|
871
1030
|
node.y = point.y
|
|
1031
|
+
markRenderDirty()
|
|
872
1032
|
}
|
|
873
1033
|
canvas.setPointerCapture(event.pointerId)
|
|
874
1034
|
})
|
|
@@ -885,10 +1045,12 @@ const bindEvents = () => {
|
|
|
885
1045
|
if (state.pointer.dragNode) {
|
|
886
1046
|
state.pointer.dragNode.x = point.x
|
|
887
1047
|
state.pointer.dragNode.y = point.y
|
|
1048
|
+
markRenderDirty()
|
|
888
1049
|
return
|
|
889
1050
|
}
|
|
890
1051
|
state.transform.x += dx
|
|
891
1052
|
state.transform.y += dy
|
|
1053
|
+
markRenderDirty()
|
|
892
1054
|
})
|
|
893
1055
|
canvas.addEventListener('pointerup', event => {
|
|
894
1056
|
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
|
|
2
4
|
import { sharedAgentId } from '../domain/agents.js';
|
|
3
5
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
4
6
|
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
5
|
-
import { ensureVault,
|
|
7
|
+
import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
|
|
8
|
+
import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
|
|
6
9
|
import { buildSearchPacks } from '../infrastructure/search-packs.js';
|
|
7
10
|
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
8
11
|
const toTitleKey = (title) => title.toLowerCase();
|
|
@@ -34,6 +37,9 @@ const createScopedTitleResolver = (document, titleMaps) => ({
|
|
|
34
37
|
get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
|
|
35
38
|
});
|
|
36
39
|
const embedIndexedDocuments = async (documents, providerName) => {
|
|
40
|
+
if (documents.length === 0) {
|
|
41
|
+
return documents;
|
|
42
|
+
}
|
|
37
43
|
const provider = createEmbeddingProvider(providerName);
|
|
38
44
|
const chunks = documents.flatMap((document) => document.chunks);
|
|
39
45
|
const embeddings = await provider.embed(chunks.map((chunk) => chunk.content));
|
|
@@ -47,34 +53,144 @@ const embedIndexedDocuments = async (documents, providerName) => {
|
|
|
47
53
|
}))
|
|
48
54
|
}));
|
|
49
55
|
};
|
|
56
|
+
const relinkIndexedDocument = (indexedDocument, titleMaps) => {
|
|
57
|
+
const resolver = createScopedTitleResolver(indexedDocument.document, titleMaps);
|
|
58
|
+
return {
|
|
59
|
+
...indexedDocument,
|
|
60
|
+
links: indexedDocument.links
|
|
61
|
+
.map((link) => ({
|
|
62
|
+
...link,
|
|
63
|
+
toDocumentId: resolver.get(link.toTitle.toLowerCase()) ?? null
|
|
64
|
+
}))
|
|
65
|
+
.filter((link) => link.toDocumentId !== indexedDocument.document.id)
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const toIndexResult = (documents) => ({
|
|
69
|
+
documentCount: documents.length,
|
|
70
|
+
chunkCount: documents.reduce((total, document) => total + document.chunks.length, 0),
|
|
71
|
+
linkCount: documents.reduce((total, document) => total + document.links.length, 0)
|
|
72
|
+
});
|
|
73
|
+
const toSnapshot = (summaries) => summaries.map((summary) => ({
|
|
74
|
+
path: summary.relativePath,
|
|
75
|
+
mtimeMs: summary.updatedAt.getTime(),
|
|
76
|
+
size: summary.size
|
|
77
|
+
}));
|
|
78
|
+
const createSnapshotMap = (snapshot) => new Map(snapshot.map((entry) => [entry.path, entry]));
|
|
79
|
+
const packManifestPath = (vaultPath) => join(vaultPath, '.brainlink', 'search-packs', 'manifest.json');
|
|
80
|
+
const hasPackManifest = async (vaultPath) => {
|
|
81
|
+
try {
|
|
82
|
+
await stat(packManifestPath(vaultPath));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
|
|
90
|
+
const parsed = await Promise.all(changedSummaries.map(async (summary) => parseMarkdownDocument({
|
|
91
|
+
absolutePath: summary.absolutePath,
|
|
92
|
+
vaultPath: absoluteVaultPath,
|
|
93
|
+
content: await readFile(summary.absolutePath, 'utf8'),
|
|
94
|
+
createdAt: summary.createdAt,
|
|
95
|
+
updatedAt: summary.updatedAt
|
|
96
|
+
})));
|
|
97
|
+
return new Map(parsed.map((document) => [document.path, document]));
|
|
98
|
+
};
|
|
50
99
|
export const indexVault = async (vaultPath) => {
|
|
51
100
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
52
101
|
const config = await loadBrainlinkConfig();
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
content: file.content,
|
|
58
|
-
createdAt: file.createdAt,
|
|
59
|
-
updatedAt: file.updatedAt
|
|
60
|
-
}));
|
|
61
|
-
const titleMaps = createTitleMaps(documents);
|
|
62
|
-
const indexedDocuments = await embedIndexedDocuments(documents.map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider);
|
|
102
|
+
const [summaries, previousState] = await Promise.all([
|
|
103
|
+
readMarkdownFileSummaries(absoluteVaultPath),
|
|
104
|
+
readIndexState(absoluteVaultPath)
|
|
105
|
+
]);
|
|
63
106
|
const index = openFileIndex(absoluteVaultPath);
|
|
64
107
|
try {
|
|
108
|
+
const existingIndexedDocuments = await index.getIndexedDocuments();
|
|
109
|
+
const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
|
|
110
|
+
const currentSnapshot = toSnapshot(summaries);
|
|
111
|
+
const currentSnapshotMap = createSnapshotMap(currentSnapshot);
|
|
112
|
+
const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
|
|
113
|
+
const settingsChanged = previousState == null ||
|
|
114
|
+
previousState.chunkSize !== config.chunkSize ||
|
|
115
|
+
previousState.embeddingProvider !== config.embeddingProvider;
|
|
116
|
+
const changedPaths = new Set();
|
|
117
|
+
for (let index = 0; index < summaries.length; index += 1) {
|
|
118
|
+
const summary = summaries[index];
|
|
119
|
+
const previous = previousSnapshotMap.get(summary.relativePath);
|
|
120
|
+
const changed = settingsChanged ||
|
|
121
|
+
previous == null ||
|
|
122
|
+
previous.mtimeMs !== summary.updatedAt.getTime() ||
|
|
123
|
+
previous.size !== summary.size ||
|
|
124
|
+
!existingByPath.has(summary.relativePath);
|
|
125
|
+
if (changed) {
|
|
126
|
+
changedPaths.add(summary.relativePath);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const hasDeletes = previousState
|
|
130
|
+
? previousState.files.some((entry) => !currentSnapshotMap.has(entry.path))
|
|
131
|
+
: false;
|
|
132
|
+
if (changedPaths.size === 0 &&
|
|
133
|
+
!hasDeletes &&
|
|
134
|
+
existingIndexedDocuments.length === summaries.length &&
|
|
135
|
+
previousState != null) {
|
|
136
|
+
return toIndexResult(existingIndexedDocuments);
|
|
137
|
+
}
|
|
138
|
+
const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
|
|
139
|
+
const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
|
|
140
|
+
const documents = summaries.flatMap((summary) => {
|
|
141
|
+
const changed = changedDocumentsByPath.get(summary.relativePath);
|
|
142
|
+
if (changed) {
|
|
143
|
+
return [changed];
|
|
144
|
+
}
|
|
145
|
+
const existing = existingByPath.get(summary.relativePath);
|
|
146
|
+
return existing ? [existing.document] : [];
|
|
147
|
+
});
|
|
148
|
+
const titleMaps = createTitleMaps(documents);
|
|
149
|
+
const changedIndexedDocuments = changedDocumentsByPath.size > 0
|
|
150
|
+
? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
|
|
151
|
+
: [];
|
|
152
|
+
const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
|
|
153
|
+
const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
|
|
154
|
+
const indexedDocuments = documents.map((document) => {
|
|
155
|
+
const changed = changedIndexedByPath.get(document.path);
|
|
156
|
+
if (changed) {
|
|
157
|
+
return changed;
|
|
158
|
+
}
|
|
159
|
+
const existing = existingByPath.get(document.path);
|
|
160
|
+
if (!existing) {
|
|
161
|
+
return createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize);
|
|
162
|
+
}
|
|
163
|
+
return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
|
|
164
|
+
});
|
|
65
165
|
await index.reset();
|
|
66
166
|
await index.saveDocuments(indexedDocuments);
|
|
67
|
-
|
|
68
|
-
|
|
167
|
+
const existingPackManifest = await hasPackManifest(absoluteVaultPath);
|
|
168
|
+
const changedCount = changedPaths.size;
|
|
169
|
+
const documentCount = Math.max(indexedDocuments.length, 1);
|
|
170
|
+
const changeRatio = changedCount / documentCount;
|
|
171
|
+
const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
|
|
172
|
+
const pendingPackChanges = previousPendingPackChanges + changedCount;
|
|
173
|
+
const shouldRebuildPacks = !existingPackManifest ||
|
|
174
|
+
settingsChanged ||
|
|
175
|
+
hasDeletes ||
|
|
176
|
+
changedCount >= 400 ||
|
|
177
|
+
changeRatio >= 0.04 ||
|
|
178
|
+
pendingPackChanges >= 1200;
|
|
179
|
+
if (shouldRebuildPacks) {
|
|
180
|
+
try {
|
|
181
|
+
await buildSearchPacks(absoluteVaultPath, indexedDocuments);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Pack generation is best-effort. The JSON index remains the primary path.
|
|
185
|
+
}
|
|
69
186
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
};
|
|
187
|
+
await writeIndexState(absoluteVaultPath, {
|
|
188
|
+
chunkSize: config.chunkSize,
|
|
189
|
+
embeddingProvider: config.embeddingProvider,
|
|
190
|
+
files: currentSnapshot,
|
|
191
|
+
pendingPackChanges: shouldRebuildPacks ? 0 : pendingPackChanges
|
|
192
|
+
});
|
|
193
|
+
return toIndexResult(indexedDocuments);
|
|
78
194
|
}
|
|
79
195
|
finally {
|
|
80
196
|
index.close();
|
|
@@ -155,6 +155,36 @@ export const openFileIndex = (vaultPath) => {
|
|
|
155
155
|
links
|
|
156
156
|
});
|
|
157
157
|
},
|
|
158
|
+
getIndexedDocuments: async (agentId) => {
|
|
159
|
+
const index = await load();
|
|
160
|
+
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
161
|
+
const selectedDocumentIds = new Set(documents.map((document) => document.id));
|
|
162
|
+
const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
|
|
163
|
+
if (!selectedDocumentIds.has(chunk.documentId)) {
|
|
164
|
+
return state;
|
|
165
|
+
}
|
|
166
|
+
const current = state.get(chunk.documentId) ?? [];
|
|
167
|
+
current.push(chunk);
|
|
168
|
+
state.set(chunk.documentId, current);
|
|
169
|
+
return state;
|
|
170
|
+
}, new Map());
|
|
171
|
+
const linksByDocumentId = index.links.reduce((state, link) => {
|
|
172
|
+
if (!selectedDocumentIds.has(link.fromDocumentId)) {
|
|
173
|
+
return state;
|
|
174
|
+
}
|
|
175
|
+
const current = state.get(link.fromDocumentId) ?? [];
|
|
176
|
+
current.push(link);
|
|
177
|
+
state.set(link.fromDocumentId, current);
|
|
178
|
+
return state;
|
|
179
|
+
}, new Map());
|
|
180
|
+
return documents
|
|
181
|
+
.map((document) => ({
|
|
182
|
+
document,
|
|
183
|
+
chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
|
|
184
|
+
links: linksByDocumentId.get(document.id) ?? []
|
|
185
|
+
}))
|
|
186
|
+
.sort((left, right) => left.document.path.localeCompare(right.document.path));
|
|
187
|
+
},
|
|
158
188
|
search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
|
|
159
189
|
const index = await load();
|
|
160
190
|
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
@@ -76,6 +76,21 @@ export const readMarkdownFiles = async (vaultPath) => {
|
|
|
76
76
|
};
|
|
77
77
|
}));
|
|
78
78
|
};
|
|
79
|
+
export const readMarkdownFileSummaries = async (vaultPath) => {
|
|
80
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
81
|
+
const paths = await walkMarkdownFiles(absoluteVaultPath);
|
|
82
|
+
const summaries = await Promise.all(paths.map(async (absolutePath) => {
|
|
83
|
+
const fileStats = await stat(absolutePath);
|
|
84
|
+
return {
|
|
85
|
+
absolutePath,
|
|
86
|
+
relativePath: relative(absoluteVaultPath, absolutePath),
|
|
87
|
+
createdAt: fileStats.birthtime,
|
|
88
|
+
updatedAt: fileStats.mtime,
|
|
89
|
+
size: fileStats.size
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
return summaries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
93
|
+
};
|
|
79
94
|
export const listVaultFiles = async (vaultPath) => {
|
|
80
95
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
81
96
|
return walkVaultFiles(absoluteVaultPath);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const indexStateFileName = 'index-state.json';
|
|
4
|
+
const toIndexStatePath = (vaultPath) => join(vaultPath, '.brainlink', indexStateFileName);
|
|
5
|
+
export const readIndexState = async (vaultPath) => {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(await readFile(toIndexStatePath(vaultPath), 'utf8'));
|
|
8
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.files)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const files = parsed.files.flatMap((entry) => {
|
|
12
|
+
if (!entry || typeof entry !== 'object') {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const row = entry;
|
|
16
|
+
if (typeof row.path !== 'string' || typeof row.mtimeMs !== 'number' || typeof row.size !== 'number') {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
path: row.path,
|
|
22
|
+
mtimeMs: row.mtimeMs,
|
|
23
|
+
size: row.size
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
30
|
+
chunkSize: typeof parsed.chunkSize === 'number' ? parsed.chunkSize : 1200,
|
|
31
|
+
embeddingProvider: typeof parsed.embeddingProvider === 'string' ? parsed.embeddingProvider : 'none',
|
|
32
|
+
files,
|
|
33
|
+
pendingPackChanges: typeof parsed.pendingPackChanges === 'number' && parsed.pendingPackChanges >= 0 ? parsed.pendingPackChanges : 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
export const writeIndexState = async (vaultPath, state) => {
|
|
41
|
+
const payload = {
|
|
42
|
+
version: 1,
|
|
43
|
+
updatedAt: new Date().toISOString(),
|
|
44
|
+
chunkSize: state.chunkSize,
|
|
45
|
+
embeddingProvider: state.embeddingProvider,
|
|
46
|
+
files: [...state.files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
47
|
+
pendingPackChanges: Math.max(0, Math.floor(state.pendingPackChanges))
|
|
48
|
+
};
|
|
49
|
+
await writeFile(toIndexStatePath(vaultPath), `${JSON.stringify(payload)}\n`, 'utf8');
|
|
50
|
+
};
|
package/package.json
CHANGED