@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
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { bm25TermScore, minMaxNormalize } from '../domain/scoring.js';
|
|
5
|
-
import { selectSemanticCandidates } from './semantic-prefilter.js';
|
|
6
|
-
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
3
|
+
import { createStoredIndexQueries, emptyIndex, toStoredIndex } from './knowledge-store/stored-index.js';
|
|
7
4
|
const indexCacheMaxEntries = 16;
|
|
8
5
|
const indexCache = new Map();
|
|
9
|
-
const emptyIndex = () => ({
|
|
10
|
-
version: 1,
|
|
11
|
-
updatedAt: new Date().toISOString(),
|
|
12
|
-
documents: [],
|
|
13
|
-
chunks: [],
|
|
14
|
-
links: []
|
|
15
|
-
});
|
|
16
6
|
// Raised when index.json exists but cannot be parsed. Search treats it as a
|
|
17
7
|
// signal to fall back to the independent compressed search packs instead of
|
|
18
8
|
// silently returning an empty result set.
|
|
@@ -54,6 +44,11 @@ const quarantineCorruptIndex = async (path, cause) => {
|
|
|
54
44
|
}
|
|
55
45
|
}
|
|
56
46
|
};
|
|
47
|
+
// Exposed so the binary backend can serve reads from a legacy index.json until
|
|
48
|
+
// the next index run materializes the binary store (seamless migration).
|
|
49
|
+
export function readJsonStoredIndex(vaultPath, strict = false) {
|
|
50
|
+
return readIndex(vaultPath, strict);
|
|
51
|
+
}
|
|
57
52
|
const readIndex = async (vaultPath, strict = false) => {
|
|
58
53
|
const path = indexStoragePath(vaultPath);
|
|
59
54
|
let stats = null;
|
|
@@ -126,403 +121,21 @@ const writeIndex = async (vaultPath, index) => {
|
|
|
126
121
|
index
|
|
127
122
|
});
|
|
128
123
|
};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
hits += 1;
|
|
146
|
-
cursor = index + token.length;
|
|
124
|
+
// JSON file backend: the original single `index.json` store. Loading/persisting
|
|
125
|
+
// is its own concern; the graph/search/link reads come from the shared query
|
|
126
|
+
// core so this backend stays behavior-identical to the binary backend.
|
|
127
|
+
export const openFileIndex = (vaultPath, options = {}) => ({
|
|
128
|
+
reset: async () => {
|
|
129
|
+
await writeIndex(vaultPath, emptyIndex());
|
|
130
|
+
},
|
|
131
|
+
saveDocuments: async (documents) => {
|
|
132
|
+
await writeIndex(vaultPath, toStoredIndex(documents));
|
|
133
|
+
},
|
|
134
|
+
...createStoredIndexQueries({
|
|
135
|
+
load: () => readIndex(vaultPath),
|
|
136
|
+
loadStrict: () => readIndex(vaultPath, true)
|
|
137
|
+
}, options),
|
|
138
|
+
close: () => {
|
|
139
|
+
// File-based index has no persistent connection.
|
|
147
140
|
}
|
|
148
|
-
return hits;
|
|
149
|
-
};
|
|
150
|
-
const titleFieldWeight = 5;
|
|
151
|
-
const tagFieldWeight = 4;
|
|
152
|
-
const pathFieldWeight = 2;
|
|
153
|
-
const contentHitCap = 6;
|
|
154
|
-
const hybridTextWeight = 0.6;
|
|
155
|
-
const hybridSemanticWeight = 0.4;
|
|
156
|
-
const normalizeFields = (row) => ({
|
|
157
|
-
title: normalizeToken(row.title),
|
|
158
|
-
path: normalizeToken(row.path),
|
|
159
|
-
content: normalizeToken(row.content),
|
|
160
|
-
tags: normalizeToken(row.tags.join(' '))
|
|
161
141
|
});
|
|
162
|
-
// Field-weighted term frequency: title/tag/path matches count more than body
|
|
163
|
-
// matches, and body hits saturate at a cap so a term repeated many times in one
|
|
164
|
-
// chunk cannot dominate. This weighted frequency feeds BM25 below.
|
|
165
|
-
const weightedTermFrequency = (fields, token) => {
|
|
166
|
-
const titleHits = countOccurrences(fields.title, token);
|
|
167
|
-
const tagHits = countOccurrences(fields.tags, token);
|
|
168
|
-
const pathHits = countOccurrences(fields.path, token);
|
|
169
|
-
const contentHits = countOccurrences(fields.content, token);
|
|
170
|
-
return (titleHits * titleFieldWeight +
|
|
171
|
-
tagHits * tagFieldWeight +
|
|
172
|
-
pathHits * pathFieldWeight +
|
|
173
|
-
Math.min(contentHits, contentHitCap));
|
|
174
|
-
};
|
|
175
|
-
// Naive additive field score retained for graph-node title/path lookup, where
|
|
176
|
-
// the candidate set is documents (not chunks) and corpus-wide BM25 statistics
|
|
177
|
-
// add no signal over a direct weighted hit count.
|
|
178
|
-
const lexicalFieldScore = (row, tokens) => {
|
|
179
|
-
if (tokens.length === 0) {
|
|
180
|
-
return 0;
|
|
181
|
-
}
|
|
182
|
-
const fields = normalizeFields(row);
|
|
183
|
-
return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
|
|
184
|
-
};
|
|
185
|
-
// A single chunk with a non-finite cached tokenCount (legacy index, partial
|
|
186
|
-
// write, manual edit) would otherwise poison the shared averageLength and zero
|
|
187
|
-
// out lexical scoring for the whole corpus, so coerce to a safe positive length.
|
|
188
|
-
const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
|
|
189
|
-
const buildCorpusStatistics = (prepared, tokens) => {
|
|
190
|
-
const documentCount = prepared.length;
|
|
191
|
-
const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
|
|
192
|
-
const documentFrequencyByTerm = new Map();
|
|
193
|
-
for (const term of tokens) {
|
|
194
|
-
let documentFrequency = 0;
|
|
195
|
-
for (const entry of prepared) {
|
|
196
|
-
if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
|
|
197
|
-
documentFrequency += 1;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
documentFrequencyByTerm.set(term, documentFrequency);
|
|
201
|
-
}
|
|
202
|
-
return {
|
|
203
|
-
documentCount,
|
|
204
|
-
averageLength: documentCount > 0 ? totalLength / documentCount : 0,
|
|
205
|
-
documentFrequencyByTerm
|
|
206
|
-
};
|
|
207
|
-
};
|
|
208
|
-
const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
|
|
209
|
-
bm25TermScore({
|
|
210
|
-
termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
|
|
211
|
-
documentLength: safeDocumentLength(entry.row.tokenCount),
|
|
212
|
-
documentCount: stats.documentCount,
|
|
213
|
-
documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
|
|
214
|
-
averageLength: stats.averageLength
|
|
215
|
-
}), 0);
|
|
216
|
-
const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
|
|
217
|
-
const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
|
|
218
|
-
const toResult = (row, mode, score, text, semantic) => ({
|
|
219
|
-
documentId: row.documentId,
|
|
220
|
-
agentId: row.agentId,
|
|
221
|
-
title: row.title,
|
|
222
|
-
path: row.path,
|
|
223
|
-
chunkId: row.chunkId,
|
|
224
|
-
chunkOrdinal: row.chunkOrdinal,
|
|
225
|
-
content: row.content,
|
|
226
|
-
score,
|
|
227
|
-
textScore: text,
|
|
228
|
-
semanticScore: semantic,
|
|
229
|
-
tokenCount: row.tokenCount,
|
|
230
|
-
searchMode: mode,
|
|
231
|
-
tags: row.tags
|
|
232
|
-
});
|
|
233
|
-
const toGraphLink = (link, documentsById) => {
|
|
234
|
-
const source = documentsById.get(link.fromDocumentId);
|
|
235
|
-
const target = link.toDocumentId ? documentsById.get(link.toDocumentId) : undefined;
|
|
236
|
-
return {
|
|
237
|
-
agentId: source?.agentId ?? 'shared',
|
|
238
|
-
fromTitle: source?.title ?? 'Unknown',
|
|
239
|
-
fromPath: source?.path ?? 'Unknown',
|
|
240
|
-
toTitle: target?.title ?? link.toTitle,
|
|
241
|
-
toPath: target?.path ?? null,
|
|
242
|
-
weight: link.weight,
|
|
243
|
-
priority: link.priority
|
|
244
|
-
};
|
|
245
|
-
};
|
|
246
|
-
export const openFileIndex = (vaultPath) => {
|
|
247
|
-
const load = async () => readIndex(vaultPath);
|
|
248
|
-
const persist = async (index) => writeIndex(vaultPath, index);
|
|
249
|
-
return {
|
|
250
|
-
reset: async () => {
|
|
251
|
-
await persist(emptyIndex());
|
|
252
|
-
},
|
|
253
|
-
saveDocuments: async (documents) => {
|
|
254
|
-
const chunks = documents.flatMap((document) => document.chunks);
|
|
255
|
-
const links = documents.flatMap((document) => document.links);
|
|
256
|
-
await persist({
|
|
257
|
-
version: 1,
|
|
258
|
-
updatedAt: new Date().toISOString(),
|
|
259
|
-
documents: documents.map((document) => document.document),
|
|
260
|
-
chunks,
|
|
261
|
-
links
|
|
262
|
-
});
|
|
263
|
-
},
|
|
264
|
-
getIndexedDocuments: async (agentId) => {
|
|
265
|
-
const index = await load();
|
|
266
|
-
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
267
|
-
const selectedDocumentIds = new Set(documents.map((document) => document.id));
|
|
268
|
-
const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
|
|
269
|
-
if (!selectedDocumentIds.has(chunk.documentId)) {
|
|
270
|
-
return state;
|
|
271
|
-
}
|
|
272
|
-
const current = state.get(chunk.documentId) ?? [];
|
|
273
|
-
current.push(chunk);
|
|
274
|
-
state.set(chunk.documentId, current);
|
|
275
|
-
return state;
|
|
276
|
-
}, new Map());
|
|
277
|
-
const linksByDocumentId = index.links.reduce((state, link) => {
|
|
278
|
-
if (!selectedDocumentIds.has(link.fromDocumentId)) {
|
|
279
|
-
return state;
|
|
280
|
-
}
|
|
281
|
-
const current = state.get(link.fromDocumentId) ?? [];
|
|
282
|
-
current.push(link);
|
|
283
|
-
state.set(link.fromDocumentId, current);
|
|
284
|
-
return state;
|
|
285
|
-
}, new Map());
|
|
286
|
-
return documents
|
|
287
|
-
.map((document) => ({
|
|
288
|
-
document,
|
|
289
|
-
chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
|
|
290
|
-
links: linksByDocumentId.get(document.id) ?? []
|
|
291
|
-
}))
|
|
292
|
-
.sort((left, right) => left.document.path.localeCompare(right.document.path));
|
|
293
|
-
},
|
|
294
|
-
search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
|
|
295
|
-
// Strict load: a corrupt index throws so the caller can fall back to the
|
|
296
|
-
// independent search packs rather than reporting "no results".
|
|
297
|
-
const index = await readIndex(vaultPath, true);
|
|
298
|
-
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
299
|
-
const rows = index.chunks.flatMap((chunk) => {
|
|
300
|
-
const document = documentsById.get(chunk.documentId);
|
|
301
|
-
if (!document) {
|
|
302
|
-
return [];
|
|
303
|
-
}
|
|
304
|
-
if (agentId && document.agentId !== agentId) {
|
|
305
|
-
return [];
|
|
306
|
-
}
|
|
307
|
-
return [
|
|
308
|
-
{
|
|
309
|
-
documentId: document.id,
|
|
310
|
-
agentId: document.agentId,
|
|
311
|
-
title: document.title,
|
|
312
|
-
path: document.path,
|
|
313
|
-
chunkId: chunk.id,
|
|
314
|
-
chunkOrdinal: chunk.ordinal,
|
|
315
|
-
content: chunk.content,
|
|
316
|
-
tokenCount: chunk.tokenCount,
|
|
317
|
-
tags: document.tags,
|
|
318
|
-
embedding: chunk.embedding,
|
|
319
|
-
buckets: chunk.buckets
|
|
320
|
-
}
|
|
321
|
-
];
|
|
322
|
-
});
|
|
323
|
-
const tokens = tokenize(query);
|
|
324
|
-
// Pure-semantic scoring on a large vault can skip chunks that share no
|
|
325
|
-
// embedding bucket with the query. Hybrid/fts keep every row so lexical
|
|
326
|
-
// matches are never dropped; the prefilter also falls back to a full scan
|
|
327
|
-
// on small or partially-indexed vaults.
|
|
328
|
-
const candidates = mode === 'semantic' && queryEmbedding.length > 0
|
|
329
|
-
? selectSemanticCandidates(rows, createEmbeddingBuckets(queryEmbedding), limit)
|
|
330
|
-
: rows;
|
|
331
|
-
// Pass 1: field-weighted term frequencies per candidate, then corpus-wide
|
|
332
|
-
// statistics (document frequency, average length) needed for BM25 IDF and
|
|
333
|
-
// length normalization.
|
|
334
|
-
const prepared = candidates.map((row) => {
|
|
335
|
-
const fields = normalizeFields(row);
|
|
336
|
-
const weightedTermFrequencies = new Map();
|
|
337
|
-
for (const term of tokens) {
|
|
338
|
-
const frequency = weightedTermFrequency(fields, term);
|
|
339
|
-
if (frequency > 0) {
|
|
340
|
-
weightedTermFrequencies.set(term, frequency);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return { row, weightedTermFrequencies };
|
|
344
|
-
});
|
|
345
|
-
const stats = buildCorpusStatistics(prepared, tokens);
|
|
346
|
-
// Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
|
|
347
|
-
const componentScored = prepared.map((entry) => ({
|
|
348
|
-
row: entry.row,
|
|
349
|
-
text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
|
|
350
|
-
semantic: semanticScore(entry.row, queryEmbedding)
|
|
351
|
-
}));
|
|
352
|
-
// Filter on raw relevance before normalization so a legitimate match that
|
|
353
|
-
// happens to be the weakest in its mode is never min-max scaled to zero
|
|
354
|
-
// and dropped. fts stays lexical-only and semantic stays vector-only.
|
|
355
|
-
const relevant = componentScored.filter((entry) => {
|
|
356
|
-
if (tokens.length === 0) {
|
|
357
|
-
return true;
|
|
358
|
-
}
|
|
359
|
-
if (mode === 'fts') {
|
|
360
|
-
return entry.text > 0;
|
|
361
|
-
}
|
|
362
|
-
if (mode === 'semantic') {
|
|
363
|
-
return entry.semantic > 0;
|
|
364
|
-
}
|
|
365
|
-
return entry.text > 0 || entry.semantic > 0;
|
|
366
|
-
});
|
|
367
|
-
if (relevant.length === 0) {
|
|
368
|
-
return [];
|
|
369
|
-
}
|
|
370
|
-
const textRange = rangeOf(relevant.map((entry) => entry.text));
|
|
371
|
-
const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
|
|
372
|
-
const finalScore = (entry) => {
|
|
373
|
-
if (mode === 'fts') {
|
|
374
|
-
return entry.text;
|
|
375
|
-
}
|
|
376
|
-
if (mode === 'semantic') {
|
|
377
|
-
return entry.semantic;
|
|
378
|
-
}
|
|
379
|
-
// Hybrid mixes the two components on a shared [0, 1] scale so neither the
|
|
380
|
-
// unbounded BM25 magnitude nor the bounded cosine value can swamp the
|
|
381
|
-
// other, replacing the previous fixed `text + semantic * 8` blend.
|
|
382
|
-
const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
|
|
383
|
-
const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
|
|
384
|
-
return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
|
|
385
|
-
};
|
|
386
|
-
return relevant
|
|
387
|
-
.map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
|
|
388
|
-
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
|
|
389
|
-
.slice(0, Math.max(0, limit));
|
|
390
|
-
},
|
|
391
|
-
listLinks: async (agentId) => {
|
|
392
|
-
const index = await load();
|
|
393
|
-
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
394
|
-
return index.links
|
|
395
|
-
.filter((link) => {
|
|
396
|
-
const source = documentsById.get(link.fromDocumentId);
|
|
397
|
-
return agentId ? source?.agentId === agentId : true;
|
|
398
|
-
})
|
|
399
|
-
.map((link) => toGraphLink(link, documentsById))
|
|
400
|
-
.sort((left, right) => left.fromTitle.localeCompare(right.fromTitle));
|
|
401
|
-
},
|
|
402
|
-
listBacklinks: async (title, agentId) => {
|
|
403
|
-
const index = await load();
|
|
404
|
-
const titleKey = title.toLowerCase();
|
|
405
|
-
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
406
|
-
return index.links
|
|
407
|
-
.filter((link) => link.toTitle.toLowerCase() === titleKey)
|
|
408
|
-
.filter((link) => {
|
|
409
|
-
const source = documentsById.get(link.fromDocumentId);
|
|
410
|
-
return agentId ? source?.agentId === agentId : true;
|
|
411
|
-
})
|
|
412
|
-
.map((link) => toGraphLink(link, documentsById))
|
|
413
|
-
.sort((left, right) => right.weight - left.weight || left.fromTitle.localeCompare(right.fromTitle));
|
|
414
|
-
},
|
|
415
|
-
getGraph: async (agentId) => {
|
|
416
|
-
const index = await load();
|
|
417
|
-
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
418
|
-
const documentIds = new Set(documents.map((document) => document.id));
|
|
419
|
-
const edges = index.links
|
|
420
|
-
.filter((link) => documentIds.has(link.fromDocumentId))
|
|
421
|
-
.map((link) => ({
|
|
422
|
-
source: link.fromDocumentId,
|
|
423
|
-
target: link.toDocumentId,
|
|
424
|
-
targetTitle: link.toTitle,
|
|
425
|
-
weight: link.weight,
|
|
426
|
-
priority: link.priority
|
|
427
|
-
}));
|
|
428
|
-
return {
|
|
429
|
-
nodes: documents.map((document) => ({
|
|
430
|
-
id: document.id,
|
|
431
|
-
agentId: document.agentId,
|
|
432
|
-
title: document.title,
|
|
433
|
-
path: document.path,
|
|
434
|
-
content: document.content,
|
|
435
|
-
tags: document.tags,
|
|
436
|
-
contextLinks: document.contextLinks ?? []
|
|
437
|
-
})),
|
|
438
|
-
edges
|
|
439
|
-
};
|
|
440
|
-
},
|
|
441
|
-
getGraphSummary: async (agentId) => {
|
|
442
|
-
const graph = await (async () => {
|
|
443
|
-
const index = await load();
|
|
444
|
-
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
445
|
-
const documentIds = new Set(documents.map((document) => document.id));
|
|
446
|
-
const edges = index.links
|
|
447
|
-
.filter((link) => documentIds.has(link.fromDocumentId))
|
|
448
|
-
.map((link) => ({
|
|
449
|
-
source: link.fromDocumentId,
|
|
450
|
-
target: link.toDocumentId,
|
|
451
|
-
targetTitle: link.toTitle,
|
|
452
|
-
weight: link.weight,
|
|
453
|
-
priority: link.priority
|
|
454
|
-
}));
|
|
455
|
-
return {
|
|
456
|
-
nodes: documents.map((document) => ({
|
|
457
|
-
id: document.id,
|
|
458
|
-
agentId: document.agentId,
|
|
459
|
-
title: document.title,
|
|
460
|
-
path: document.path,
|
|
461
|
-
content: '',
|
|
462
|
-
tags: document.tags,
|
|
463
|
-
contextLinks: document.contextLinks ?? []
|
|
464
|
-
})),
|
|
465
|
-
edges
|
|
466
|
-
};
|
|
467
|
-
})();
|
|
468
|
-
return graph;
|
|
469
|
-
},
|
|
470
|
-
getGraphNode: async (id, agentId) => {
|
|
471
|
-
const index = await load();
|
|
472
|
-
const document = index.documents.find((row) => row.id === id && (!agentId || row.agentId === agentId));
|
|
473
|
-
return document
|
|
474
|
-
? {
|
|
475
|
-
id: document.id,
|
|
476
|
-
agentId: document.agentId,
|
|
477
|
-
title: document.title,
|
|
478
|
-
path: document.path,
|
|
479
|
-
content: document.content,
|
|
480
|
-
tags: document.tags,
|
|
481
|
-
contextLinks: document.contextLinks ?? []
|
|
482
|
-
}
|
|
483
|
-
: undefined;
|
|
484
|
-
},
|
|
485
|
-
searchGraphNodeIds: async (query, limit, agentId) => {
|
|
486
|
-
const index = await load();
|
|
487
|
-
const normalized = normalizeToken(query);
|
|
488
|
-
if (normalized.length === 0 || limit <= 0) {
|
|
489
|
-
return [];
|
|
490
|
-
}
|
|
491
|
-
const tokens = tokenize(query);
|
|
492
|
-
const scored = index.documents
|
|
493
|
-
.filter((document) => (!agentId || document.agentId === agentId))
|
|
494
|
-
.map((document) => {
|
|
495
|
-
const score = lexicalFieldScore({
|
|
496
|
-
documentId: document.id,
|
|
497
|
-
agentId: document.agentId,
|
|
498
|
-
title: document.title,
|
|
499
|
-
path: document.path,
|
|
500
|
-
chunkId: document.id,
|
|
501
|
-
chunkOrdinal: 0,
|
|
502
|
-
content: document.content,
|
|
503
|
-
tokenCount: 0,
|
|
504
|
-
tags: document.tags,
|
|
505
|
-
embedding: []
|
|
506
|
-
}, tokens);
|
|
507
|
-
return { id: document.id, score };
|
|
508
|
-
})
|
|
509
|
-
.filter((row) => row.score > 0)
|
|
510
|
-
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
|
|
511
|
-
.slice(0, limit);
|
|
512
|
-
return scored.map((row) => row.id);
|
|
513
|
-
},
|
|
514
|
-
listAgents: async () => {
|
|
515
|
-
const index = await load();
|
|
516
|
-
const counts = index.documents.reduce((state, document) => {
|
|
517
|
-
state.set(document.agentId, (state.get(document.agentId) ?? 0) + 1);
|
|
518
|
-
return state;
|
|
519
|
-
}, new Map());
|
|
520
|
-
return Array.from(counts.entries())
|
|
521
|
-
.sort((left, right) => left[0].localeCompare(right[0]))
|
|
522
|
-
.map(([id, documentCount]) => ({ id, documentCount }));
|
|
523
|
-
},
|
|
524
|
-
close: () => {
|
|
525
|
-
// File-based index has no persistent connection.
|
|
526
|
-
}
|
|
527
|
-
};
|
|
528
|
-
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { indexStoragePath } from './file-index.js';
|
|
4
|
+
const fileSignature = async (path) => {
|
|
5
|
+
try {
|
|
6
|
+
const info = await stat(path);
|
|
7
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '0:0';
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
// Commit signature of the derived index, independent of which storage backend is
|
|
14
|
+
// active. The JSON backend commits `index.json`; the binary backend commits
|
|
15
|
+
// `store/meta.json` (written last) and removes `index.json`. Combining both file
|
|
16
|
+
// signatures means the value changes on any index write under either backend —
|
|
17
|
+
// and on a backend switch, when one file appears and the other disappears — so
|
|
18
|
+
// the in-memory search/context caches invalidate correctly. On the binary
|
|
19
|
+
// backend `index.json` is absent, so keying a cache on it alone would never
|
|
20
|
+
// change and would serve stale results after an edit.
|
|
21
|
+
export const indexCommitSignature = async (vaultPath) => {
|
|
22
|
+
const [jsonSignature, binarySignature] = await Promise.all([
|
|
23
|
+
fileSignature(indexStoragePath(vaultPath)),
|
|
24
|
+
fileSignature(join(vaultPath, '.brainlink', 'store', 'meta.json'))
|
|
25
|
+
]);
|
|
26
|
+
return `${jsonSignature}|${binarySignature}`;
|
|
27
|
+
};
|
|
@@ -29,6 +29,11 @@ export const readIndexState = async (vaultPath) => {
|
|
|
29
29
|
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
30
30
|
chunkSize: typeof parsed.chunkSize === 'number' ? parsed.chunkSize : 1200,
|
|
31
31
|
embeddingProvider: typeof parsed.embeddingProvider === 'string' ? parsed.embeddingProvider : 'none',
|
|
32
|
+
// Absent on states written before the backend was tracked: default to the
|
|
33
|
+
// historical 'json' backend so a json vault does not spuriously reindex,
|
|
34
|
+
// while a vault already switched to binary reindexes once to materialize
|
|
35
|
+
// the new store (which also writes the int8 scan index).
|
|
36
|
+
storageBackend: typeof parsed.storageBackend === 'string' ? parsed.storageBackend : 'json',
|
|
32
37
|
graphLinkModelVersion: typeof parsed.graphLinkModelVersion === 'number' ? parsed.graphLinkModelVersion : 1,
|
|
33
38
|
searchPackRowChunkSize: typeof parsed.searchPackRowChunkSize === 'number' ? parsed.searchPackRowChunkSize : 5_000,
|
|
34
39
|
searchPackCompressionLevel: typeof parsed.searchPackCompressionLevel === 'number' ? parsed.searchPackCompressionLevel : 5,
|
|
@@ -47,6 +52,7 @@ export const writeIndexState = async (vaultPath, state) => {
|
|
|
47
52
|
updatedAt: new Date().toISOString(),
|
|
48
53
|
chunkSize: state.chunkSize,
|
|
49
54
|
embeddingProvider: state.embeddingProvider,
|
|
55
|
+
storageBackend: state.storageBackend,
|
|
50
56
|
graphLinkModelVersion: state.graphLinkModelVersion,
|
|
51
57
|
searchPackRowChunkSize: state.searchPackRowChunkSize,
|
|
52
58
|
searchPackCompressionLevel: state.searchPackCompressionLevel,
|