@andespindola/brainlink 0.1.0-beta.10 → 0.1.0-beta.11
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/README.md +2 -0
- package/dist/application/frontend/client-js.js +63 -16
- package/dist/application/get-graph-layout.js +17 -5
- package/dist/application/index-vault.js +7 -0
- package/dist/application/search-knowledge.js +22 -7
- package/dist/infrastructure/search-packs.js +151 -0
- package/docs/AGENT_USAGE.md +1 -0
- package/docs/ARCHITECTURE.md +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
|
|
|
58
58
|
|
|
59
59
|
Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable index.
|
|
60
60
|
Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup`. If the main SQLite file is corrupted, Brainlink automatically restores from snapshot (or recreates a clean index when no snapshot exists).
|
|
61
|
+
After each index run, Brainlink also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`. If SQLite is unavailable, search falls back to these packs automatically.
|
|
61
62
|
|
|
62
63
|
## Features
|
|
63
64
|
|
|
@@ -563,6 +564,7 @@ The graph UI shows:
|
|
|
563
564
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
564
565
|
- wheel zoom anchored to cursor position for faster navigation in large graphs
|
|
565
566
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
567
|
+
- large-graph rendering safeguards (edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
566
568
|
|
|
567
569
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
568
570
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
|
+
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const largeGraphEdgeRenderLimit = 16000
|
|
3
5
|
const state = {
|
|
4
6
|
graph: { nodes: [], edges: [] },
|
|
5
7
|
nodes: [],
|
|
6
8
|
edges: [],
|
|
9
|
+
visibleNodes: [],
|
|
10
|
+
visibleEdges: [],
|
|
11
|
+
nodeDegrees: new Map(),
|
|
7
12
|
selected: null,
|
|
8
13
|
hovered: null,
|
|
9
14
|
query: '',
|
|
@@ -102,11 +107,18 @@ const filteredNodes = () => {
|
|
|
102
107
|
return localFilteredNodes(query)
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
+
const recomputeVisibility = () => {
|
|
111
|
+
const nodes = filteredNodes()
|
|
112
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
113
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
114
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
115
|
+
? [...edges]
|
|
116
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
117
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
118
|
+
: edges
|
|
119
|
+
|
|
120
|
+
state.visibleNodes = nodes
|
|
121
|
+
state.visibleEdges = limitedEdges
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
@@ -205,6 +217,7 @@ const resetContentFilter = () => {
|
|
|
205
217
|
token: state.contentFilter.token + 1,
|
|
206
218
|
timer: null
|
|
207
219
|
}
|
|
220
|
+
recomputeVisibility()
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
const syncContentFilter = async (query, token) => {
|
|
@@ -228,6 +241,7 @@ const syncContentFilter = async (query, token) => {
|
|
|
228
241
|
|
|
229
242
|
state.contentFilter.query = query
|
|
230
243
|
state.contentFilter.ids = new Set(nodeIds)
|
|
244
|
+
recomputeVisibility()
|
|
231
245
|
}
|
|
232
246
|
|
|
233
247
|
const scheduleContentFilterSync = () => {
|
|
@@ -253,9 +267,11 @@ const scheduleContentFilterSync = () => {
|
|
|
253
267
|
}
|
|
254
268
|
|
|
255
269
|
const tick = delta => {
|
|
256
|
-
const nodes =
|
|
257
|
-
const
|
|
258
|
-
|
|
270
|
+
const nodes = state.visibleNodes
|
|
271
|
+
const edges = state.visibleEdges
|
|
272
|
+
if (nodes.length > 1200) {
|
|
273
|
+
return
|
|
274
|
+
}
|
|
259
275
|
const strength = Math.min(delta / 16, 2)
|
|
260
276
|
|
|
261
277
|
edges.forEach(edge => {
|
|
@@ -314,7 +330,11 @@ const worldPoint = event => {
|
|
|
314
330
|
}
|
|
315
331
|
|
|
316
332
|
const hitNode = point => {
|
|
317
|
-
|
|
333
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const nodes = state.visibleNodes
|
|
318
338
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
319
339
|
const node = nodes[index]
|
|
320
340
|
const radius = nodeRadius(node)
|
|
@@ -324,13 +344,18 @@ const hitNode = point => {
|
|
|
324
344
|
}
|
|
325
345
|
|
|
326
346
|
const nodeRadius = node => {
|
|
327
|
-
const degree = state.
|
|
347
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
328
348
|
return 9 + Math.min(degree, 8) * 1.6
|
|
329
349
|
}
|
|
330
350
|
|
|
331
351
|
const render = now => {
|
|
332
352
|
const delta = now - state.last
|
|
333
353
|
state.last = now
|
|
354
|
+
const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 180 : 16
|
|
355
|
+
if (delta < minFrameIntervalMs) {
|
|
356
|
+
requestAnimationFrame(render)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
334
359
|
const rect = canvas.getBoundingClientRect()
|
|
335
360
|
const width = Math.max(rect.width, 320)
|
|
336
361
|
const height = Math.max(rect.height, 320)
|
|
@@ -347,7 +372,10 @@ const render = now => {
|
|
|
347
372
|
ctx.translate(state.transform.x, state.transform.y)
|
|
348
373
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
349
374
|
|
|
350
|
-
|
|
375
|
+
tick(delta)
|
|
376
|
+
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
377
|
+
if (drawEdges) {
|
|
378
|
+
state.visibleEdges.forEach(edge => {
|
|
351
379
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
352
380
|
ctx.beginPath()
|
|
353
381
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -355,9 +383,10 @@ const render = now => {
|
|
|
355
383
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
356
384
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
357
385
|
ctx.stroke()
|
|
358
|
-
|
|
386
|
+
})
|
|
387
|
+
}
|
|
359
388
|
|
|
360
|
-
|
|
389
|
+
state.visibleNodes.forEach(node => {
|
|
361
390
|
const radius = nodeRadius(node)
|
|
362
391
|
const isSelected = state.selected?.id === node.id
|
|
363
392
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -373,7 +402,11 @@ const render = now => {
|
|
|
373
402
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
374
403
|
ctx.stroke()
|
|
375
404
|
|
|
376
|
-
|
|
405
|
+
const shouldDrawLabels =
|
|
406
|
+
isSelected ||
|
|
407
|
+
isHovered ||
|
|
408
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
409
|
+
if (shouldDrawLabels) {
|
|
377
410
|
ctx.fillStyle = graphTheme.label
|
|
378
411
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
379
412
|
ctx.textAlign = 'center'
|
|
@@ -483,6 +516,7 @@ const bindEvents = () => {
|
|
|
483
516
|
window.addEventListener('resize', resize)
|
|
484
517
|
elements.search.addEventListener('input', event => {
|
|
485
518
|
state.query = event.target.value
|
|
519
|
+
recomputeVisibility()
|
|
486
520
|
scheduleContentFilterSync()
|
|
487
521
|
})
|
|
488
522
|
elements.agent.addEventListener('change', event => {
|
|
@@ -490,6 +524,7 @@ const bindEvents = () => {
|
|
|
490
524
|
state.selected = null
|
|
491
525
|
state.nodeDetails = new Map()
|
|
492
526
|
resetContentFilter()
|
|
527
|
+
recomputeVisibility()
|
|
493
528
|
scheduleContentFilterSync()
|
|
494
529
|
loadGraph({ reset: true }).catch(error => {
|
|
495
530
|
console.error(error)
|
|
@@ -504,9 +539,13 @@ const bindEvents = () => {
|
|
|
504
539
|
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
|
|
505
540
|
})
|
|
506
541
|
if (elements.fit) {
|
|
507
|
-
elements.fit.addEventListener('click', () =>
|
|
542
|
+
elements.fit.addEventListener('click', () => {
|
|
543
|
+
fitView({ useFiltered: true })
|
|
544
|
+
})
|
|
508
545
|
}
|
|
509
|
-
elements.reset.addEventListener('click',
|
|
546
|
+
elements.reset.addEventListener('click', () => {
|
|
547
|
+
resetView()
|
|
548
|
+
})
|
|
510
549
|
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
511
550
|
elements.contentDialog.addEventListener('click', event => {
|
|
512
551
|
const target = event.target
|
|
@@ -602,8 +641,16 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
602
641
|
state.graph = graph
|
|
603
642
|
state.nodes = layout.nodes
|
|
604
643
|
state.edges = layout.edges
|
|
644
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
645
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
646
|
+
if (edge.target) {
|
|
647
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
648
|
+
}
|
|
649
|
+
return degrees
|
|
650
|
+
}, new Map())
|
|
605
651
|
state.nodeDetails = new Map()
|
|
606
652
|
resetContentFilter()
|
|
653
|
+
recomputeVisibility()
|
|
607
654
|
scheduleContentFilterSync()
|
|
608
655
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
609
656
|
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
3
5
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
4
6
|
const graphLayoutCache = new Map();
|
|
7
|
+
const readDatabaseSignature = async (vaultPath) => {
|
|
8
|
+
try {
|
|
9
|
+
const info = await stat(join(vaultPath, '.brainlink', 'brainlink.db'));
|
|
10
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '0:0';
|
|
14
|
+
}
|
|
15
|
+
};
|
|
5
16
|
const createGraphSignature = (graph) => {
|
|
6
17
|
const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
|
|
7
18
|
const edgesSignature = graph.edges
|
|
@@ -12,18 +23,19 @@ const createGraphSignature = (graph) => {
|
|
|
12
23
|
.digest('hex');
|
|
13
24
|
};
|
|
14
25
|
export const getGraphLayout = async (vaultPath, agentId) => {
|
|
15
|
-
const
|
|
16
|
-
const signature = createGraphSignature(graph);
|
|
26
|
+
const databaseSignature = await readDatabaseSignature(vaultPath);
|
|
17
27
|
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
18
28
|
const cached = graphLayoutCache.get(cacheKey);
|
|
19
|
-
if (cached?.
|
|
29
|
+
if (cached?.databaseSignature === databaseSignature) {
|
|
20
30
|
return {
|
|
21
|
-
signature,
|
|
31
|
+
signature: cached.signature,
|
|
22
32
|
layout: cached.layout
|
|
23
33
|
};
|
|
24
34
|
}
|
|
35
|
+
const graph = await getGraphSummary(vaultPath, agentId);
|
|
36
|
+
const signature = createGraphSignature(graph);
|
|
25
37
|
const layout = createCauliflowerGraphLayout(graph);
|
|
26
|
-
graphLayoutCache.set(cacheKey, { signature, layout });
|
|
38
|
+
graphLayoutCache.set(cacheKey, { databaseSignature, signature, layout });
|
|
27
39
|
return {
|
|
28
40
|
signature,
|
|
29
41
|
layout
|
|
@@ -3,6 +3,7 @@ import { sharedAgentId } from '../domain/agents.js';
|
|
|
3
3
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
4
4
|
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
5
5
|
import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
|
|
6
|
+
import { buildSearchPacks } from '../infrastructure/search-packs.js';
|
|
6
7
|
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
7
8
|
const toTitleKey = (title) => title.toLowerCase();
|
|
8
9
|
const appendTitleEntry = (map, document) => {
|
|
@@ -63,6 +64,12 @@ export const indexVault = async (vaultPath) => {
|
|
|
63
64
|
try {
|
|
64
65
|
index.reset();
|
|
65
66
|
index.saveDocuments(indexedDocuments);
|
|
67
|
+
try {
|
|
68
|
+
await buildSearchPacks(absoluteVaultPath, indexedDocuments);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Pack generation is best-effort. SQLite index remains the primary path.
|
|
72
|
+
}
|
|
66
73
|
return {
|
|
67
74
|
documentCount: indexedDocuments.length,
|
|
68
75
|
chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
4
|
+
import { searchInPacks } from '../infrastructure/search-packs.js';
|
|
4
5
|
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
5
6
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
6
7
|
import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
|
|
@@ -55,20 +56,34 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
|
|
|
55
56
|
const provider = createEmbeddingProvider(config.embeddingProvider);
|
|
56
57
|
const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
|
|
57
58
|
const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
|
|
58
|
-
const index = openSqliteIndex(absoluteVaultPath);
|
|
59
59
|
try {
|
|
60
|
-
const
|
|
60
|
+
const index = openSqliteIndex(absoluteVaultPath);
|
|
61
|
+
try {
|
|
62
|
+
const results = index.search(query, limit, agentId, searchMode, queryEmbedding);
|
|
63
|
+
if (cacheKey) {
|
|
64
|
+
cacheSet({
|
|
65
|
+
key: cacheKey,
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
indexMtimeMs,
|
|
68
|
+
results
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
index.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
const fallbackResults = await searchInPacks(absoluteVaultPath, query, limit, agentId);
|
|
61
79
|
if (cacheKey) {
|
|
62
80
|
cacheSet({
|
|
63
81
|
key: cacheKey,
|
|
64
82
|
createdAt: Date.now(),
|
|
65
83
|
indexMtimeMs,
|
|
66
|
-
results
|
|
84
|
+
results: fallbackResults
|
|
67
85
|
});
|
|
68
86
|
}
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
finally {
|
|
72
|
-
index.close();
|
|
87
|
+
return fallbackResults;
|
|
73
88
|
}
|
|
74
89
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const packsDirectoryName = 'search-packs';
|
|
5
|
+
const manifestFileName = 'manifest.json';
|
|
6
|
+
const rowChunkSize = 5_000;
|
|
7
|
+
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
8
|
+
const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
|
|
9
|
+
const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
|
|
10
|
+
const parseRowsFromPack = (content) => gunzipSync(content)
|
|
11
|
+
.toString('utf8')
|
|
12
|
+
.split('\n')
|
|
13
|
+
.map((line) => line.trim())
|
|
14
|
+
.filter((line) => line.length > 0)
|
|
15
|
+
.map((line) => JSON.parse(line));
|
|
16
|
+
const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
|
|
17
|
+
documentId: document.document.id,
|
|
18
|
+
agentId: document.document.agentId,
|
|
19
|
+
title: document.document.title,
|
|
20
|
+
path: document.document.path,
|
|
21
|
+
chunkId: chunk.id,
|
|
22
|
+
content: chunk.content,
|
|
23
|
+
tags: document.document.tags
|
|
24
|
+
})));
|
|
25
|
+
const writeManifest = async (vaultPath, manifest) => {
|
|
26
|
+
await writeFile(toManifestPath(vaultPath), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
27
|
+
};
|
|
28
|
+
const chunkRows = (rows, size) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for (let index = 0; index < rows.length; index += size) {
|
|
31
|
+
chunks.push(rows.slice(index, index + size));
|
|
32
|
+
}
|
|
33
|
+
return chunks;
|
|
34
|
+
};
|
|
35
|
+
const normalizeToken = (value) => value
|
|
36
|
+
.normalize('NFKD')
|
|
37
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
38
|
+
.toLowerCase();
|
|
39
|
+
const tokenize = (query) => query
|
|
40
|
+
.match(queryTokenPattern)
|
|
41
|
+
?.map(normalizeToken)
|
|
42
|
+
.filter((token) => token.length > 1) ?? [];
|
|
43
|
+
const countOccurrences = (text, token) => {
|
|
44
|
+
let hits = 0;
|
|
45
|
+
let start = 0;
|
|
46
|
+
while (start < text.length) {
|
|
47
|
+
const index = text.indexOf(token, start);
|
|
48
|
+
if (index < 0) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
hits += 1;
|
|
52
|
+
start = index + token.length;
|
|
53
|
+
}
|
|
54
|
+
return hits;
|
|
55
|
+
};
|
|
56
|
+
const computeTextScore = (row, tokens) => {
|
|
57
|
+
if (tokens.length === 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const title = normalizeToken(row.title);
|
|
61
|
+
const path = normalizeToken(row.path);
|
|
62
|
+
const content = normalizeToken(row.content);
|
|
63
|
+
const tags = normalizeToken(row.tags.join(' '));
|
|
64
|
+
return tokens.reduce((score, token) => {
|
|
65
|
+
const titleHits = countOccurrences(title, token);
|
|
66
|
+
const tagHits = countOccurrences(tags, token);
|
|
67
|
+
const pathHits = countOccurrences(path, token);
|
|
68
|
+
const contentHits = countOccurrences(content, token);
|
|
69
|
+
return score + titleHits * 5 + tagHits * 4 + pathHits * 2 + Math.min(contentHits, 5);
|
|
70
|
+
}, 0);
|
|
71
|
+
};
|
|
72
|
+
const toSearchResult = (row, score) => ({
|
|
73
|
+
documentId: row.documentId,
|
|
74
|
+
agentId: row.agentId,
|
|
75
|
+
title: row.title,
|
|
76
|
+
path: row.path,
|
|
77
|
+
chunkId: row.chunkId,
|
|
78
|
+
content: row.content,
|
|
79
|
+
score,
|
|
80
|
+
textScore: score,
|
|
81
|
+
semanticScore: 0,
|
|
82
|
+
searchMode: 'fts',
|
|
83
|
+
tags: row.tags
|
|
84
|
+
});
|
|
85
|
+
const sortedPackFiles = async (vaultPath) => {
|
|
86
|
+
try {
|
|
87
|
+
const files = await readdir(toPackDirectory(vaultPath));
|
|
88
|
+
return files
|
|
89
|
+
.filter((file) => file.endsWith('.jsonl.gz'))
|
|
90
|
+
.sort((left, right) => left.localeCompare(right));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export const buildSearchPacks = async (vaultPath, documents) => {
|
|
100
|
+
const directory = toPackDirectory(vaultPath);
|
|
101
|
+
const rows = toRows(documents);
|
|
102
|
+
await mkdir(directory, { recursive: true });
|
|
103
|
+
const current = await readdir(directory);
|
|
104
|
+
await Promise.all(current
|
|
105
|
+
.filter((name) => name.endsWith('.jsonl.gz') || name === manifestFileName)
|
|
106
|
+
.map((name) => rm(join(directory, name), { force: true })));
|
|
107
|
+
const chunks = chunkRows(rows, rowChunkSize);
|
|
108
|
+
await Promise.all(chunks.map(async (chunk, index) => {
|
|
109
|
+
const fileName = `pack-${String(index + 1).padStart(4, '0')}.jsonl.gz`;
|
|
110
|
+
const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
|
|
111
|
+
const compressed = gzipSync(Buffer.from(serialized, 'utf8'), { level: 6 });
|
|
112
|
+
await writeFile(join(directory, fileName), compressed);
|
|
113
|
+
}));
|
|
114
|
+
await writeManifest(vaultPath, {
|
|
115
|
+
version: 1,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
packCount: chunks.length,
|
|
118
|
+
recordCount: rows.length
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
packCount: chunks.length,
|
|
122
|
+
recordCount: rows.length
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
export const searchInPacks = async (vaultPath, query, limit, agentId) => {
|
|
126
|
+
const normalizedAgent = agentId?.trim();
|
|
127
|
+
const tokens = tokenize(query);
|
|
128
|
+
if (limit <= 0 || tokens.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const files = await sortedPackFiles(vaultPath);
|
|
132
|
+
if (files.length === 0) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const scored = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const rows = parseRowsFromPack(await readFile(join(toPackDirectory(vaultPath), file)));
|
|
138
|
+
rows.forEach((row) => {
|
|
139
|
+
if (normalizedAgent && row.agentId !== normalizedAgent) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const score = computeTextScore(row, tokens);
|
|
143
|
+
if (score > 0) {
|
|
144
|
+
scored.push(toSearchResult(row, score));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return scored
|
|
149
|
+
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
|
|
150
|
+
.slice(0, limit);
|
|
151
|
+
};
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -635,6 +635,7 @@ GET /api/validate
|
|
|
635
635
|
The HTTP API is read-only. Use the CLI for writes and indexing.
|
|
636
636
|
|
|
637
637
|
Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores from snapshot automatically or recreates a clean index if no snapshot exists yet.
|
|
638
|
+
Indexing also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`; when SQLite cannot be opened, Brainlink falls back to pack-based search automatically.
|
|
638
639
|
|
|
639
640
|
## Agent Integration Contract
|
|
640
641
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -301,6 +301,7 @@ Markdown keeps the system portable, inspectable, Git-friendly, and compatible wi
|
|
|
301
301
|
SQLite gives fast local search, local vector storage and rebuildable retrieval without forcing users to run external infrastructure.
|
|
302
302
|
Hybrid retrieval also uses a short-lived in-memory cache keyed by vault/query/agent and invalidated by index file mtime to reduce repeated query latency.
|
|
303
303
|
Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores from snapshot automatically before reopening the index.
|
|
304
|
+
Indexing additionally exports compressed pack files (`.brainlink/search-packs/*.jsonl.gz`) from indexed chunks. Search falls back to these packs when SQLite is unavailable, preserving retrieval continuity in degraded mode.
|
|
304
305
|
|
|
305
306
|
### CLI First
|
|
306
307
|
|
package/package.json
CHANGED