@andespindola/brainlink 1.0.7 → 1.2.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/CHANGELOG.md +16 -1
- package/README.md +63 -2
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/build-context.js +14 -7
- package/dist/application/get-graph-node.js +2 -2
- package/dist/application/get-graph-summary.js +2 -2
- package/dist/application/get-graph.js +2 -2
- package/dist/application/index-vault-phases.js +1 -0
- package/dist/application/index-vault.js +16 -2
- package/dist/application/list-agents.js +2 -2
- package/dist/application/list-links.js +3 -3
- package/dist/application/ports/knowledge-store.js +1 -0
- package/dist/application/provision-vault.js +74 -0
- package/dist/application/ranking/run-search.js +212 -0
- package/dist/application/search-graph-node-ids.js +3 -2
- package/dist/application/search-knowledge.js +9 -17
- package/dist/application/server/routes.js +6 -1
- package/dist/application/vault-encryption.js +64 -0
- package/dist/application/vault-git-core.js +168 -0
- package/dist/application/vault-git.js +75 -0
- package/dist/application/vault-snapshot.js +87 -0
- package/dist/cli/commands/read-commands.js +9 -5
- package/dist/cli/commands/vault-sync-commands.js +137 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/infrastructure/config.js +16 -1
- package/dist/infrastructure/context-packs.js +57 -18
- package/dist/infrastructure/file-index.js +22 -409
- package/dist/infrastructure/index-signature.js +27 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/knowledge-store/binary-store.js +333 -0
- package/dist/infrastructure/knowledge-store/index.js +37 -0
- package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
- package/dist/infrastructure/vault-crypto.js +78 -0
- package/dist/mcp/server.js +46 -1
- package/dist/mcp/tools/maintenance-tools.js +2 -2
- package/dist/mcp/tools/read-tools.js +8 -4
- package/dist/mcp/tools/vault-tools.js +163 -0
- package/dist/mcp/tools.js +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Binary storage backend. Stores chunk vectors as raw little-endian Float32 in a
|
|
2
|
+
// dense `vectors.f32` file (~4x smaller than the JSON backend's decimal text and
|
|
3
|
+
// far cheaper to load) and the document/chunk/link metadata as Brotli-compressed
|
|
4
|
+
// JSON. Reads reconstruct the shared `StoredIndex` shape and answer through the
|
|
5
|
+
// same query core as the JSON backend, so results are behavior-identical (vector
|
|
6
|
+
// values are stored at Float32 precision, the standard for embeddings).
|
|
7
|
+
//
|
|
8
|
+
// Migration is seamless: until the first index run materializes the binary store,
|
|
9
|
+
// reads transparently fall back to a legacy `index.json`; the first save writes
|
|
10
|
+
// the binary store and removes the legacy file.
|
|
11
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
|
|
12
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createEmbeddingBuckets } from '../../domain/embeddings.js';
|
|
15
|
+
import { runSearch } from '../../application/ranking/run-search.js';
|
|
16
|
+
import { indexStoragePath, readJsonStoredIndex } from '../file-index.js';
|
|
17
|
+
import { selectSemanticCandidates, semanticPrefilterMinChunks } from '../semantic-prefilter.js';
|
|
18
|
+
import { createStoredIndexQueries, toStoredIndex } from './stored-index.js';
|
|
19
|
+
const storeVersion = 1;
|
|
20
|
+
export const binaryStoreDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'store');
|
|
21
|
+
const storeFile = (vaultPath, name) => join(binaryStoreDirectory(vaultPath), name);
|
|
22
|
+
const metaPath = (vaultPath) => storeFile(vaultPath, 'meta.json');
|
|
23
|
+
const brotli = (value) => brotliCompressSync(Buffer.from(value, 'utf8'), {
|
|
24
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT }
|
|
25
|
+
});
|
|
26
|
+
const unbrotli = (buffer) => brotliDecompressSync(buffer).toString('utf8');
|
|
27
|
+
export const binaryStoreExists = async (vaultPath) => {
|
|
28
|
+
try {
|
|
29
|
+
await stat(metaPath(vaultPath));
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// Pack chunk embeddings into one dense Float32 buffer (chunk i at i*dims floats).
|
|
37
|
+
const encodeVectors = (chunks, dims) => {
|
|
38
|
+
if (dims === 0) {
|
|
39
|
+
return Buffer.alloc(0);
|
|
40
|
+
}
|
|
41
|
+
const buffer = Buffer.alloc(chunks.length * dims * 4);
|
|
42
|
+
chunks.forEach((chunk, index) => {
|
|
43
|
+
const base = index * dims * 4;
|
|
44
|
+
for (let dimension = 0; dimension < dims; dimension += 1) {
|
|
45
|
+
buffer.writeFloatLE(chunk.embedding[dimension] ?? 0, base + dimension * 4);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return buffer;
|
|
49
|
+
};
|
|
50
|
+
const decodeVector = (vectors, index, dims) => {
|
|
51
|
+
const out = new Array(dims);
|
|
52
|
+
const base = index * dims * 4;
|
|
53
|
+
for (let dimension = 0; dimension < dims; dimension += 1) {
|
|
54
|
+
out[dimension] = vectors.readFloatLE(base + dimension * 4);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
// int8 quantization of the (L2-normalized, so ~[-1, 1]) embeddings: one byte per
|
|
59
|
+
// dimension, `round(v * 127)` clamped to the signed range. The scale is uniform,
|
|
60
|
+
// so int8·int8 preserves the ordering of the exact dot product — enough to pick
|
|
61
|
+
// approximate nearest neighbours cheaply before an exact Float32 rerank.
|
|
62
|
+
const clampInt8 = (value) => (value > 127 ? 127 : value < -127 ? -127 : value);
|
|
63
|
+
const encodeVectorsI8 = (chunks, dims) => {
|
|
64
|
+
if (dims === 0) {
|
|
65
|
+
return Buffer.alloc(0);
|
|
66
|
+
}
|
|
67
|
+
const buffer = Buffer.alloc(chunks.length * dims);
|
|
68
|
+
chunks.forEach((chunk, index) => {
|
|
69
|
+
const base = index * dims;
|
|
70
|
+
for (let dimension = 0; dimension < dims; dimension += 1) {
|
|
71
|
+
buffer.writeInt8(clampInt8(Math.round((chunk.embedding[dimension] ?? 0) * 127)), base + dimension);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return buffer;
|
|
75
|
+
};
|
|
76
|
+
const writeAtomic = async (path, data) => {
|
|
77
|
+
const temp = `${path}.tmp`;
|
|
78
|
+
await writeFile(temp, data, { mode: 0o600 });
|
|
79
|
+
await rename(temp, path);
|
|
80
|
+
};
|
|
81
|
+
const persistStoredIndex = async (vaultPath, stored) => {
|
|
82
|
+
const dims = stored.chunks.reduce((max, chunk) => Math.max(max, chunk.embedding.length), 0);
|
|
83
|
+
const chunkMeta = stored.chunks.map((chunk) => ({
|
|
84
|
+
id: chunk.id,
|
|
85
|
+
documentId: chunk.documentId,
|
|
86
|
+
ordinal: chunk.ordinal,
|
|
87
|
+
content: chunk.content,
|
|
88
|
+
tokenCount: chunk.tokenCount,
|
|
89
|
+
embeddingProvider: chunk.embeddingProvider,
|
|
90
|
+
...(chunk.buckets ? { buckets: chunk.buckets } : {}),
|
|
91
|
+
hasEmbedding: chunk.embedding.length > 0
|
|
92
|
+
}));
|
|
93
|
+
const meta = {
|
|
94
|
+
version: storeVersion,
|
|
95
|
+
updatedAt: stored.updatedAt,
|
|
96
|
+
dims,
|
|
97
|
+
documentCount: stored.documents.length,
|
|
98
|
+
chunkCount: stored.chunks.length
|
|
99
|
+
};
|
|
100
|
+
await mkdir(binaryStoreDirectory(vaultPath), { recursive: true, mode: 0o700 });
|
|
101
|
+
// Write data files first, then meta.json last as the commit point: a reader
|
|
102
|
+
// checks meta first, so a crash mid-write leaves the previous meta (and its
|
|
103
|
+
// matching data) in place rather than a torn state.
|
|
104
|
+
await writeAtomic(storeFile(vaultPath, 'documents.json.br'), brotli(JSON.stringify(stored.documents)));
|
|
105
|
+
await writeAtomic(storeFile(vaultPath, 'chunks.json.br'), brotli(JSON.stringify(chunkMeta)));
|
|
106
|
+
await writeAtomic(storeFile(vaultPath, 'links.json.br'), brotli(JSON.stringify(stored.links)));
|
|
107
|
+
await writeAtomic(storeFile(vaultPath, 'vectors.f32'), encodeVectors(stored.chunks, dims));
|
|
108
|
+
// int8 vectors: a compact (1 byte/dim) approximate-nearest-neighbour scan index
|
|
109
|
+
// read fully during search; the exact Float32 vectors are read by offset only
|
|
110
|
+
// for the shortlist the int8 scan produces.
|
|
111
|
+
await writeAtomic(storeFile(vaultPath, 'vectors.i8'), encodeVectorsI8(stored.chunks, dims));
|
|
112
|
+
await writeAtomic(metaPath(vaultPath), Buffer.from(`${JSON.stringify(meta)}\n`, 'utf8'));
|
|
113
|
+
// Migration complete: the binary store now holds everything, so drop the
|
|
114
|
+
// legacy JSON index. Markdown remains the canonical source either way.
|
|
115
|
+
await rm(indexStoragePath(vaultPath), { force: true });
|
|
116
|
+
};
|
|
117
|
+
const persist = (vaultPath, documents) => persistStoredIndex(vaultPath, toStoredIndex(documents));
|
|
118
|
+
// One-time, lossless format conversion: if the binary store is absent but a
|
|
119
|
+
// legacy index.json exists, rewrite its contents (embeddings included, no
|
|
120
|
+
// re-embedding) into the binary store and drop the legacy file. Deduped per
|
|
121
|
+
// vault so concurrent operations convert once.
|
|
122
|
+
const inFlightMigrations = new Map();
|
|
123
|
+
export const migrateLegacyIndexToBinary = async (vaultPath) => {
|
|
124
|
+
if (await binaryStoreExists(vaultPath)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const existing = inFlightMigrations.get(vaultPath);
|
|
128
|
+
if (existing) {
|
|
129
|
+
return existing;
|
|
130
|
+
}
|
|
131
|
+
const run = (async () => {
|
|
132
|
+
const legacy = await readJsonStoredIndex(vaultPath, false);
|
|
133
|
+
if (legacy.documents.length === 0 && legacy.chunks.length === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await persistStoredIndex(vaultPath, legacy);
|
|
137
|
+
})().finally(() => inFlightMigrations.delete(vaultPath));
|
|
138
|
+
inFlightMigrations.set(vaultPath, run);
|
|
139
|
+
return run;
|
|
140
|
+
};
|
|
141
|
+
const loadBinary = async (vaultPath) => {
|
|
142
|
+
const meta = JSON.parse(await readFile(metaPath(vaultPath), 'utf8'));
|
|
143
|
+
const [documentsRaw, chunkMetaRaw, linksRaw, vectors] = await Promise.all([
|
|
144
|
+
readFile(storeFile(vaultPath, 'documents.json.br')),
|
|
145
|
+
readFile(storeFile(vaultPath, 'chunks.json.br')),
|
|
146
|
+
readFile(storeFile(vaultPath, 'links.json.br')),
|
|
147
|
+
readFile(storeFile(vaultPath, 'vectors.f32'))
|
|
148
|
+
]);
|
|
149
|
+
const documents = JSON.parse(unbrotli(documentsRaw));
|
|
150
|
+
const chunkMeta = JSON.parse(unbrotli(chunkMetaRaw));
|
|
151
|
+
const links = JSON.parse(unbrotli(linksRaw));
|
|
152
|
+
const chunks = chunkMeta.map((chunk, index) => ({
|
|
153
|
+
id: chunk.id,
|
|
154
|
+
documentId: chunk.documentId,
|
|
155
|
+
ordinal: chunk.ordinal,
|
|
156
|
+
content: chunk.content,
|
|
157
|
+
tokenCount: chunk.tokenCount,
|
|
158
|
+
embeddingProvider: chunk.embeddingProvider,
|
|
159
|
+
embedding: chunk.hasEmbedding && meta.dims > 0 ? decodeVector(vectors, index, meta.dims) : [],
|
|
160
|
+
...(chunk.buckets ? { buckets: chunk.buckets } : {})
|
|
161
|
+
}));
|
|
162
|
+
return {
|
|
163
|
+
version: 1,
|
|
164
|
+
updatedAt: typeof meta.updatedAt === 'string' ? meta.updatedAt : new Date().toISOString(),
|
|
165
|
+
documents,
|
|
166
|
+
chunks,
|
|
167
|
+
links
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
const readInt8Vectors = async (vaultPath) => {
|
|
171
|
+
try {
|
|
172
|
+
const raw = await readFile(storeFile(vaultPath, 'vectors.i8'));
|
|
173
|
+
return new Int8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// A store written before the int8 index existed (e.g. a 1.1.0 vault not yet
|
|
177
|
+
// reindexed): fall back to exact scoring over the shortlist pool.
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const loadForSearch = async (vaultPath) => {
|
|
182
|
+
const meta = JSON.parse(await readFile(metaPath(vaultPath), 'utf8'));
|
|
183
|
+
const [documentsRaw, chunkMetaRaw, linksRaw] = await Promise.all([
|
|
184
|
+
readFile(storeFile(vaultPath, 'documents.json.br')),
|
|
185
|
+
readFile(storeFile(vaultPath, 'chunks.json.br')),
|
|
186
|
+
readFile(storeFile(vaultPath, 'links.json.br'))
|
|
187
|
+
]);
|
|
188
|
+
const documents = JSON.parse(unbrotli(documentsRaw));
|
|
189
|
+
const chunkMeta = JSON.parse(unbrotli(chunkMetaRaw));
|
|
190
|
+
const links = JSON.parse(unbrotli(linksRaw));
|
|
191
|
+
const chunks = chunkMeta.map((chunk) => ({
|
|
192
|
+
id: chunk.id,
|
|
193
|
+
documentId: chunk.documentId,
|
|
194
|
+
ordinal: chunk.ordinal,
|
|
195
|
+
content: chunk.content,
|
|
196
|
+
tokenCount: chunk.tokenCount,
|
|
197
|
+
embeddingProvider: chunk.embeddingProvider,
|
|
198
|
+
embedding: [],
|
|
199
|
+
...(chunk.buckets ? { buckets: chunk.buckets } : {})
|
|
200
|
+
}));
|
|
201
|
+
const int8 = meta.dims > 0 ? await readInt8Vectors(vaultPath) : null;
|
|
202
|
+
return {
|
|
203
|
+
index: {
|
|
204
|
+
version: 1,
|
|
205
|
+
updatedAt: typeof meta.updatedAt === 'string' ? meta.updatedAt : new Date().toISOString(),
|
|
206
|
+
documents,
|
|
207
|
+
chunks,
|
|
208
|
+
links
|
|
209
|
+
},
|
|
210
|
+
int8,
|
|
211
|
+
dims: meta.dims
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
// Approximate dot product of the Float32 query against a chunk's int8 vector.
|
|
215
|
+
// The uniform int8 scale cancels out of the ranking, so this preserves the exact
|
|
216
|
+
// dot product's ordering closely enough to shortlist candidates.
|
|
217
|
+
const approxScoreI8 = (query, int8, index, dims) => {
|
|
218
|
+
const length = Math.min(query.length, dims);
|
|
219
|
+
const base = index * dims;
|
|
220
|
+
let total = 0;
|
|
221
|
+
for (let dimension = 0; dimension < length; dimension += 1) {
|
|
222
|
+
total += query[dimension] * int8[base + dimension];
|
|
223
|
+
}
|
|
224
|
+
return total;
|
|
225
|
+
};
|
|
226
|
+
// Read the exact Float32 vectors for a shortlist of chunk indices by offset,
|
|
227
|
+
// through a single file handle. This is the only place the exact vectors are
|
|
228
|
+
// touched during search, and only for the shortlist (not the whole corpus).
|
|
229
|
+
const readExactVectors = async (vaultPath, indices, dims) => {
|
|
230
|
+
const vectors = new Map();
|
|
231
|
+
if (indices.length === 0 || dims === 0) {
|
|
232
|
+
return vectors;
|
|
233
|
+
}
|
|
234
|
+
const handle = await open(storeFile(vaultPath, 'vectors.f32'), 'r');
|
|
235
|
+
try {
|
|
236
|
+
const byteLength = dims * 4;
|
|
237
|
+
const buffer = Buffer.alloc(byteLength);
|
|
238
|
+
for (const index of indices) {
|
|
239
|
+
await handle.read(buffer, 0, byteLength, index * byteLength);
|
|
240
|
+
const vector = new Array(dims);
|
|
241
|
+
for (let dimension = 0; dimension < dims; dimension += 1) {
|
|
242
|
+
vector[dimension] = buffer.readFloatLE(dimension * 4);
|
|
243
|
+
}
|
|
244
|
+
vectors.set(index, vector);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await handle.close();
|
|
249
|
+
}
|
|
250
|
+
return vectors;
|
|
251
|
+
};
|
|
252
|
+
// ANN search over the materialized binary store. Candidate selection is layered:
|
|
253
|
+
// embedding buckets prune coarsely on large vaults (IVF-style), the int8 scan
|
|
254
|
+
// ranks the survivors, and the exact Float32 vectors are read only for the
|
|
255
|
+
// resulting shortlist and reranked by the shared ranker — so the final ordering
|
|
256
|
+
// is produced by the same deterministic scorer as the JSON backend. Small vaults
|
|
257
|
+
// (or a store without an int8 index) rerank the whole pool exactly, staying
|
|
258
|
+
// bit-identical to a full scan.
|
|
259
|
+
const annSearch = async (vaultPath, options, query, limit, agentId, mode, queryEmbedding) => {
|
|
260
|
+
const loaded = await loadForSearch(vaultPath);
|
|
261
|
+
const documentsById = new Map(loaded.index.documents.map((document) => [document.id, document]));
|
|
262
|
+
const eligible = loaded.index.chunks.flatMap((chunk, index) => {
|
|
263
|
+
const document = documentsById.get(chunk.documentId);
|
|
264
|
+
if (!document || (agentId && document.agentId !== agentId)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
return [{ index, chunk, document, buckets: chunk.buckets }];
|
|
268
|
+
});
|
|
269
|
+
const toRow = (entry, embedding) => ({
|
|
270
|
+
documentId: entry.document.id,
|
|
271
|
+
agentId: entry.document.agentId,
|
|
272
|
+
title: entry.document.title,
|
|
273
|
+
path: entry.document.path,
|
|
274
|
+
chunkId: entry.chunk.id,
|
|
275
|
+
chunkOrdinal: entry.chunk.ordinal,
|
|
276
|
+
content: entry.chunk.content,
|
|
277
|
+
tokenCount: entry.chunk.tokenCount,
|
|
278
|
+
tags: entry.document.tags,
|
|
279
|
+
embedding,
|
|
280
|
+
buckets: entry.chunk.buckets
|
|
281
|
+
});
|
|
282
|
+
// Lexical-only modes never need vectors, so no exact vector is ever read.
|
|
283
|
+
if (mode === 'fts' || queryEmbedding.length === 0 || loaded.dims === 0) {
|
|
284
|
+
return runSearch(eligible.map((entry) => toRow(entry, [])), query, limit, mode, queryEmbedding, options.hybridFusion);
|
|
285
|
+
}
|
|
286
|
+
// Small vaults (a full exact scan is already sub-millisecond) and stores
|
|
287
|
+
// written before the int8 index existed rerank every candidate exactly, read
|
|
288
|
+
// from a single bulk pass over the exact vectors — bit-identical to a full
|
|
289
|
+
// scan, and no slower than the legacy load.
|
|
290
|
+
const useAnn = loaded.int8 !== null && loaded.index.chunks.length > semanticPrefilterMinChunks;
|
|
291
|
+
if (!useAnn) {
|
|
292
|
+
const buffer = await readFile(storeFile(vaultPath, 'vectors.f32'));
|
|
293
|
+
return runSearch(eligible.map((entry) => toRow(entry, decodeVector(buffer, entry.index, loaded.dims))), query, limit, mode, queryEmbedding, options.hybridFusion);
|
|
294
|
+
}
|
|
295
|
+
// Large vault: coarse-prune with embedding buckets, rank the survivors with the
|
|
296
|
+
// int8 scan, then read the exact Float32 vectors by offset only for the
|
|
297
|
+
// shortlist and rerank with the shared scorer.
|
|
298
|
+
const int8 = loaded.int8;
|
|
299
|
+
const pool = selectSemanticCandidates(eligible, createEmbeddingBuckets(queryEmbedding), limit);
|
|
300
|
+
const recallFloor = Math.max(limit * 4, 64);
|
|
301
|
+
const shortlist = [...pool]
|
|
302
|
+
.map((entry) => ({ entry, score: approxScoreI8(queryEmbedding, int8, entry.index, loaded.dims) }))
|
|
303
|
+
.sort((left, right) => right.score - left.score || left.entry.chunk.id.localeCompare(right.entry.chunk.id))
|
|
304
|
+
.slice(0, recallFloor)
|
|
305
|
+
.map((scored) => scored.entry);
|
|
306
|
+
const exactVectors = await readExactVectors(vaultPath, shortlist.map((entry) => entry.index), loaded.dims);
|
|
307
|
+
return runSearch(eligible.map((entry) => toRow(entry, exactVectors.get(entry.index) ?? [])), query, limit, mode, queryEmbedding, options.hybridFusion);
|
|
308
|
+
};
|
|
309
|
+
// Binary backend. While its store does not yet exist, reads fall back to the
|
|
310
|
+
// legacy JSON index so search/graph stay available mid-migration; the first save
|
|
311
|
+
// materializes the binary store and removes the legacy file.
|
|
312
|
+
export const openBinaryStore = (vaultPath, options = {}) => {
|
|
313
|
+
const load = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, false);
|
|
314
|
+
const loadStrict = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, true);
|
|
315
|
+
const shared = createStoredIndexQueries({ load, loadStrict }, options);
|
|
316
|
+
return {
|
|
317
|
+
reset: async () => {
|
|
318
|
+
await persist(vaultPath, []);
|
|
319
|
+
},
|
|
320
|
+
saveDocuments: async (documents) => {
|
|
321
|
+
await persist(vaultPath, documents);
|
|
322
|
+
},
|
|
323
|
+
...shared,
|
|
324
|
+
// Override search with the ANN path once the binary store is materialized;
|
|
325
|
+
// until then defer to the shared full-scan search over the legacy fallback.
|
|
326
|
+
search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => (await binaryStoreExists(vaultPath))
|
|
327
|
+
? annSearch(vaultPath, options, query, limit, agentId, mode, queryEmbedding)
|
|
328
|
+
: shared.search(query, limit, agentId, mode, queryEmbedding),
|
|
329
|
+
close: () => {
|
|
330
|
+
// File-based store has no persistent connection.
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { loadBrainlinkConfig } from '../config.js';
|
|
2
|
+
import { openFileIndex } from '../file-index.js';
|
|
3
|
+
import { migrateLegacyIndexToBinary, openBinaryStore } from './binary-store.js';
|
|
4
|
+
const resolveBackend = async (vaultPath) => {
|
|
5
|
+
const config = await loadBrainlinkConfig();
|
|
6
|
+
const options = { hybridFusion: config.hybridFusion };
|
|
7
|
+
if (config.storageBackend === 'binary') {
|
|
8
|
+
await migrateLegacyIndexToBinary(vaultPath);
|
|
9
|
+
return openBinaryStore(vaultPath, options);
|
|
10
|
+
}
|
|
11
|
+
return openFileIndex(vaultPath, options);
|
|
12
|
+
};
|
|
13
|
+
export const openKnowledgeStore = (vaultPath) => {
|
|
14
|
+
let backend = null;
|
|
15
|
+
// Resolve (and memoize) the backend once per store instance. A config-load
|
|
16
|
+
// failure degrades to the always-available JSON backend rather than throwing.
|
|
17
|
+
const resolve = () => {
|
|
18
|
+
backend ??= resolveBackend(vaultPath).catch(() => openFileIndex(vaultPath));
|
|
19
|
+
return backend;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
reset: async () => (await resolve()).reset(),
|
|
23
|
+
saveDocuments: async (documents) => (await resolve()).saveDocuments(documents),
|
|
24
|
+
getIndexedDocuments: async (agentId) => (await resolve()).getIndexedDocuments(agentId),
|
|
25
|
+
search: async (query, limit, agentId, mode, queryEmbedding) => (await resolve()).search(query, limit, agentId, mode, queryEmbedding),
|
|
26
|
+
listLinks: async (agentId) => (await resolve()).listLinks(agentId),
|
|
27
|
+
listBacklinks: async (title, agentId) => (await resolve()).listBacklinks(title, agentId),
|
|
28
|
+
getGraph: async (agentId) => (await resolve()).getGraph(agentId),
|
|
29
|
+
getGraphSummary: async (agentId) => (await resolve()).getGraphSummary(agentId),
|
|
30
|
+
getGraphNode: async (id, agentId) => (await resolve()).getGraphNode(id, agentId),
|
|
31
|
+
searchGraphNodeIds: async (query, limit, agentId) => (await resolve()).searchGraphNodeIds(query, limit, agentId),
|
|
32
|
+
listAgents: async () => (await resolve()).listAgents(),
|
|
33
|
+
close: () => {
|
|
34
|
+
// Backends hold no persistent connection; nothing to release.
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Shared in-memory query core for the knowledge index. Both storage backends
|
|
2
|
+
// (the JSON file and the binary store) load their data into the same
|
|
3
|
+
// `StoredIndex` shape and then expose identical read semantics through these
|
|
4
|
+
// queries. Keeping the graph/search/link logic here — instead of duplicating it
|
|
5
|
+
// per backend — guarantees the two backends answer the same way and that the
|
|
6
|
+
// only thing a backend owns is how bytes are loaded and persisted.
|
|
7
|
+
import { createEmbeddingBuckets } from '../../domain/embeddings.js';
|
|
8
|
+
import { lexicalFieldScore, normalizeToken, runSearch, tokenize } from '../../application/ranking/run-search.js';
|
|
9
|
+
import { selectSemanticCandidates } from '../semantic-prefilter.js';
|
|
10
|
+
export const emptyIndex = () => ({
|
|
11
|
+
version: 1,
|
|
12
|
+
updatedAt: new Date().toISOString(),
|
|
13
|
+
documents: [],
|
|
14
|
+
chunks: [],
|
|
15
|
+
links: []
|
|
16
|
+
});
|
|
17
|
+
const toGraphLink = (link, documentsById) => {
|
|
18
|
+
const source = documentsById.get(link.fromDocumentId);
|
|
19
|
+
const target = link.toDocumentId ? documentsById.get(link.toDocumentId) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
agentId: source?.agentId ?? 'shared',
|
|
22
|
+
fromTitle: source?.title ?? 'Unknown',
|
|
23
|
+
fromPath: source?.path ?? 'Unknown',
|
|
24
|
+
toTitle: target?.title ?? link.toTitle,
|
|
25
|
+
toPath: target?.path ?? null,
|
|
26
|
+
weight: link.weight,
|
|
27
|
+
priority: link.priority
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const toGraphNode = (document, includeContent) => ({
|
|
31
|
+
id: document.id,
|
|
32
|
+
agentId: document.agentId,
|
|
33
|
+
title: document.title,
|
|
34
|
+
path: document.path,
|
|
35
|
+
content: includeContent ? document.content : '',
|
|
36
|
+
tags: document.tags,
|
|
37
|
+
contextLinks: document.contextLinks ?? []
|
|
38
|
+
});
|
|
39
|
+
// Build the shared read methods over a backend's loaders. The returned object is
|
|
40
|
+
// the read half of the KnowledgeStore port; backends add reset/saveDocuments/
|
|
41
|
+
// close around it.
|
|
42
|
+
export const createStoredIndexQueries = ({ load, loadStrict }, options = {}) => ({
|
|
43
|
+
getIndexedDocuments: async (agentId) => {
|
|
44
|
+
const index = await load();
|
|
45
|
+
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
46
|
+
const selectedDocumentIds = new Set(documents.map((document) => document.id));
|
|
47
|
+
const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
|
|
48
|
+
if (!selectedDocumentIds.has(chunk.documentId)) {
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
const current = state.get(chunk.documentId) ?? [];
|
|
52
|
+
current.push(chunk);
|
|
53
|
+
state.set(chunk.documentId, current);
|
|
54
|
+
return state;
|
|
55
|
+
}, new Map());
|
|
56
|
+
const linksByDocumentId = index.links.reduce((state, link) => {
|
|
57
|
+
if (!selectedDocumentIds.has(link.fromDocumentId)) {
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
const current = state.get(link.fromDocumentId) ?? [];
|
|
61
|
+
current.push(link);
|
|
62
|
+
state.set(link.fromDocumentId, current);
|
|
63
|
+
return state;
|
|
64
|
+
}, new Map());
|
|
65
|
+
return documents
|
|
66
|
+
.map((document) => ({
|
|
67
|
+
document,
|
|
68
|
+
chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
|
|
69
|
+
links: linksByDocumentId.get(document.id) ?? []
|
|
70
|
+
}))
|
|
71
|
+
.sort((left, right) => left.document.path.localeCompare(right.document.path));
|
|
72
|
+
},
|
|
73
|
+
search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
|
|
74
|
+
const index = await loadStrict();
|
|
75
|
+
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
76
|
+
const rows = index.chunks.flatMap((chunk) => {
|
|
77
|
+
const document = documentsById.get(chunk.documentId);
|
|
78
|
+
if (!document) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (agentId && document.agentId !== agentId) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
documentId: document.id,
|
|
87
|
+
agentId: document.agentId,
|
|
88
|
+
title: document.title,
|
|
89
|
+
path: document.path,
|
|
90
|
+
chunkId: chunk.id,
|
|
91
|
+
chunkOrdinal: chunk.ordinal,
|
|
92
|
+
content: chunk.content,
|
|
93
|
+
tokenCount: chunk.tokenCount,
|
|
94
|
+
tags: document.tags,
|
|
95
|
+
embedding: chunk.embedding,
|
|
96
|
+
buckets: chunk.buckets
|
|
97
|
+
}
|
|
98
|
+
];
|
|
99
|
+
});
|
|
100
|
+
// Pure-semantic scoring on a large vault can skip chunks that share no
|
|
101
|
+
// embedding bucket with the query. Hybrid/fts keep every row so lexical
|
|
102
|
+
// matches are never dropped; the prefilter also falls back to a full scan
|
|
103
|
+
// on small or partially-indexed vaults. Candidate selection is the
|
|
104
|
+
// backend's job; the shared ranker then scores and orders the candidates.
|
|
105
|
+
const candidates = mode === 'semantic' && queryEmbedding.length > 0
|
|
106
|
+
? selectSemanticCandidates(rows, createEmbeddingBuckets(queryEmbedding), limit)
|
|
107
|
+
: rows;
|
|
108
|
+
return runSearch(candidates, query, limit, mode, queryEmbedding, options.hybridFusion);
|
|
109
|
+
},
|
|
110
|
+
listLinks: async (agentId) => {
|
|
111
|
+
const index = await load();
|
|
112
|
+
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
113
|
+
return index.links
|
|
114
|
+
.filter((link) => {
|
|
115
|
+
const source = documentsById.get(link.fromDocumentId);
|
|
116
|
+
return agentId ? source?.agentId === agentId : true;
|
|
117
|
+
})
|
|
118
|
+
.map((link) => toGraphLink(link, documentsById))
|
|
119
|
+
.sort((left, right) => left.fromTitle.localeCompare(right.fromTitle));
|
|
120
|
+
},
|
|
121
|
+
listBacklinks: async (title, agentId) => {
|
|
122
|
+
const index = await load();
|
|
123
|
+
const titleKey = title.toLowerCase();
|
|
124
|
+
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
125
|
+
return index.links
|
|
126
|
+
.filter((link) => link.toTitle.toLowerCase() === titleKey)
|
|
127
|
+
.filter((link) => {
|
|
128
|
+
const source = documentsById.get(link.fromDocumentId);
|
|
129
|
+
return agentId ? source?.agentId === agentId : true;
|
|
130
|
+
})
|
|
131
|
+
.map((link) => toGraphLink(link, documentsById))
|
|
132
|
+
.sort((left, right) => right.weight - left.weight || left.fromTitle.localeCompare(right.fromTitle));
|
|
133
|
+
},
|
|
134
|
+
getGraph: async (agentId) => {
|
|
135
|
+
const index = await load();
|
|
136
|
+
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
137
|
+
const documentIds = new Set(documents.map((document) => document.id));
|
|
138
|
+
const edges = index.links
|
|
139
|
+
.filter((link) => documentIds.has(link.fromDocumentId))
|
|
140
|
+
.map((link) => ({
|
|
141
|
+
source: link.fromDocumentId,
|
|
142
|
+
target: link.toDocumentId,
|
|
143
|
+
targetTitle: link.toTitle,
|
|
144
|
+
weight: link.weight,
|
|
145
|
+
priority: link.priority
|
|
146
|
+
}));
|
|
147
|
+
return { nodes: documents.map((document) => toGraphNode(document, true)), edges };
|
|
148
|
+
},
|
|
149
|
+
getGraphSummary: async (agentId) => {
|
|
150
|
+
const index = await load();
|
|
151
|
+
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
152
|
+
const documentIds = new Set(documents.map((document) => document.id));
|
|
153
|
+
const edges = index.links
|
|
154
|
+
.filter((link) => documentIds.has(link.fromDocumentId))
|
|
155
|
+
.map((link) => ({
|
|
156
|
+
source: link.fromDocumentId,
|
|
157
|
+
target: link.toDocumentId,
|
|
158
|
+
targetTitle: link.toTitle,
|
|
159
|
+
weight: link.weight,
|
|
160
|
+
priority: link.priority
|
|
161
|
+
}));
|
|
162
|
+
return { nodes: documents.map((document) => toGraphNode(document, false)), edges };
|
|
163
|
+
},
|
|
164
|
+
getGraphNode: async (id, agentId) => {
|
|
165
|
+
const index = await load();
|
|
166
|
+
const document = index.documents.find((row) => row.id === id && (!agentId || row.agentId === agentId));
|
|
167
|
+
return document ? toGraphNode(document, true) : undefined;
|
|
168
|
+
},
|
|
169
|
+
searchGraphNodeIds: async (query, limit, agentId) => {
|
|
170
|
+
const index = await load();
|
|
171
|
+
const normalized = normalizeToken(query);
|
|
172
|
+
if (normalized.length === 0 || limit <= 0) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const tokens = tokenize(query);
|
|
176
|
+
const scored = index.documents
|
|
177
|
+
.filter((document) => !agentId || document.agentId === agentId)
|
|
178
|
+
.map((document) => {
|
|
179
|
+
const score = lexicalFieldScore({
|
|
180
|
+
documentId: document.id,
|
|
181
|
+
agentId: document.agentId,
|
|
182
|
+
title: document.title,
|
|
183
|
+
path: document.path,
|
|
184
|
+
chunkId: document.id,
|
|
185
|
+
chunkOrdinal: 0,
|
|
186
|
+
content: document.content,
|
|
187
|
+
tokenCount: 0,
|
|
188
|
+
tags: document.tags,
|
|
189
|
+
embedding: []
|
|
190
|
+
}, tokens);
|
|
191
|
+
return { id: document.id, score };
|
|
192
|
+
})
|
|
193
|
+
.filter((row) => row.score > 0)
|
|
194
|
+
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
|
|
195
|
+
.slice(0, limit);
|
|
196
|
+
return scored.map((row) => row.id);
|
|
197
|
+
},
|
|
198
|
+
listAgents: async () => {
|
|
199
|
+
const index = await load();
|
|
200
|
+
const counts = index.documents.reduce((state, document) => {
|
|
201
|
+
state.set(document.agentId, (state.get(document.agentId) ?? 0) + 1);
|
|
202
|
+
return state;
|
|
203
|
+
}, new Map());
|
|
204
|
+
return Array.from(counts.entries())
|
|
205
|
+
.sort((left, right) => left[0].localeCompare(right[0]))
|
|
206
|
+
.map(([id, documentCount]) => ({ id, documentCount }));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
// Flatten indexed documents into the stored shape persisted by both backends.
|
|
210
|
+
export const toStoredIndex = (documents) => ({
|
|
211
|
+
version: 1,
|
|
212
|
+
updatedAt: new Date().toISOString(),
|
|
213
|
+
documents: documents.map((document) => document.document),
|
|
214
|
+
chunks: documents.flatMap((document) => document.chunks),
|
|
215
|
+
links: documents.flatMap((document) => document.links)
|
|
216
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Symmetric encryption for versioned vault content. The per-vault key lives in
|
|
2
|
+
// ~/.brainlink/keys (outside the vault, never committed), so only a Brainlink
|
|
3
|
+
// instance holding that key can decrypt the repository — the ciphertext in git
|
|
4
|
+
// is opaque without it. AES-256-GCM with a content-derived (deterministic)
|
|
5
|
+
// nonce, so identical content always encrypts to identical bytes (no git churn)
|
|
6
|
+
// while distinct content always gets a distinct nonce (no nonce reuse).
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { getBrainlinkHomePath } from './paths.js';
|
|
11
|
+
const magic = Buffer.from('BLVE1', 'ascii');
|
|
12
|
+
const nonceLength = 12;
|
|
13
|
+
const authTagLength = 16;
|
|
14
|
+
const algorithm = 'aes-256-gcm';
|
|
15
|
+
const keyFilePath = (vaultPath) => {
|
|
16
|
+
const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
|
|
17
|
+
return join(getBrainlinkHomePath(), 'keys', `vault-versioning-${vaultHash}.key`);
|
|
18
|
+
};
|
|
19
|
+
const deriveKeyFromSecret = (secret) => createHash('sha256').update(secret, 'utf8').digest();
|
|
20
|
+
// Load the vault's versioning key, generating a fresh 48-byte secret on first
|
|
21
|
+
// use. BRAINLINK_VAULT_KEY overrides the file (useful to restore an encrypted
|
|
22
|
+
// vault on another machine by carrying just the secret, not the whole repo).
|
|
23
|
+
export const readOrCreateVaultKey = async (vaultPath) => {
|
|
24
|
+
const envSecret = process.env.BRAINLINK_VAULT_KEY?.trim();
|
|
25
|
+
if (envSecret && envSecret.length > 0) {
|
|
26
|
+
return deriveKeyFromSecret(envSecret);
|
|
27
|
+
}
|
|
28
|
+
const path = keyFilePath(vaultPath);
|
|
29
|
+
try {
|
|
30
|
+
const existing = (await readFile(path, 'utf8')).trim();
|
|
31
|
+
if (existing.length > 0) {
|
|
32
|
+
return deriveKeyFromSecret(existing);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const secret = randomBytes(48).toString('base64url');
|
|
41
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
42
|
+
await writeFile(path, `${secret}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
43
|
+
return deriveKeyFromSecret(secret);
|
|
44
|
+
};
|
|
45
|
+
// True when the vault has a versioning key on disk (or via env), so callers can
|
|
46
|
+
// warn before losing access rather than silently minting a new key.
|
|
47
|
+
export const vaultKeyExists = async (vaultPath) => {
|
|
48
|
+
if (process.env.BRAINLINK_VAULT_KEY?.trim()) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return (await readFile(keyFilePath(vaultPath), 'utf8')).trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const deriveNonce = (key, plaintext) => createHash('sha256').update(key).update(plaintext).digest().subarray(0, nonceLength);
|
|
59
|
+
export const encryptVaultContent = (key, plaintext) => {
|
|
60
|
+
const nonce = deriveNonce(key, plaintext);
|
|
61
|
+
const cipher = createCipheriv(algorithm, key, nonce);
|
|
62
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
63
|
+
const authTag = cipher.getAuthTag();
|
|
64
|
+
return Buffer.concat([magic, nonce, authTag, ciphertext]);
|
|
65
|
+
};
|
|
66
|
+
export const isEncryptedVaultPayload = (payload) => payload.length >= magic.length && payload.subarray(0, magic.length).equals(magic);
|
|
67
|
+
export const decryptVaultContent = (key, payload) => {
|
|
68
|
+
if (payload.length < magic.length + nonceLength + authTagLength || !isEncryptedVaultPayload(payload)) {
|
|
69
|
+
throw new Error('Not a Brainlink encrypted vault file.');
|
|
70
|
+
}
|
|
71
|
+
let offset = magic.length;
|
|
72
|
+
const nonce = payload.subarray(offset, (offset += nonceLength));
|
|
73
|
+
const authTag = payload.subarray(offset, (offset += authTagLength));
|
|
74
|
+
const ciphertext = payload.subarray(offset);
|
|
75
|
+
const decipher = createDecipheriv(algorithm, key, nonce);
|
|
76
|
+
decipher.setAuthTag(authTag);
|
|
77
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
78
|
+
};
|