@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 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 = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
669
+ state.renderEdges = collectVisibleEdgesForNodes(ids)
498
670
  return
499
671
  }
500
672
 
501
- const viewport = worldViewportBounds()
673
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
502
674
  const stride = viewportNodeStride()
503
675
  const picked = []
504
676
 
505
- for (let index = 0; index < state.visibleNodes.length; index += 1) {
506
- const node = state.visibleNodes[index]
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 centerX = (viewport.minX + viewport.maxX) / 2
525
- const centerY = (viewport.minY + viewport.maxY) / 2
526
- const closest = [...state.visibleNodes]
527
- .sort((left, right) => {
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 = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
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, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
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 files = await readMarkdownFiles(absoluteVaultPath);
54
- const documents = files.map((file) => parseMarkdownDocument({
55
- absolutePath: file.absolutePath,
56
- vaultPath: absoluteVaultPath,
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
- try {
68
- await buildSearchPacks(absoluteVaultPath, indexedDocuments);
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
- catch {
71
- // Pack generation is best-effort. The JSON index remains the primary path.
72
- }
73
- return {
74
- documentCount: indexedDocuments.length,
75
- chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
76
- linkCount: indexedDocuments.reduce((total, document) => total + document.links.length, 0)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.41",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",