@andespindola/brainlink 1.1.0 → 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 +11 -1
- package/README.md +31 -5
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/build-context.js +14 -7
- package/dist/application/index-vault-phases.js +1 -0
- package/dist/application/index-vault.js +14 -0
- package/dist/application/provision-vault.js +74 -0
- package/dist/application/ranking/run-search.js +38 -3
- package/dist/application/search-knowledge.js +7 -16
- 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 +36 -141
- package/dist/cli/commands/read-commands.js +9 -5
- package/dist/cli/commands/vault-sync-commands.js +22 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
- package/dist/infrastructure/config.js +13 -2
- package/dist/infrastructure/context-packs.js +57 -18
- package/dist/infrastructure/file-index.js +2 -2
- package/dist/infrastructure/index-signature.js +27 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/knowledge-store/binary-store.js +173 -3
- package/dist/infrastructure/knowledge-store/index.js +3 -2
- package/dist/infrastructure/knowledge-store/stored-index.js +2 -2
- 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 +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
+
const storedPackVersion = 2;
|
|
4
5
|
const normalizePackKey = (key) => ({
|
|
5
6
|
query: key.query.trim().toLowerCase(),
|
|
6
7
|
limit: key.limit,
|
|
@@ -13,12 +14,35 @@ export const contextPackPath = (vaultPath, key) => {
|
|
|
13
14
|
const digest = createHash('sha256').update(JSON.stringify(normalizePackKey(key))).digest('hex');
|
|
14
15
|
return join(contextPacksDirectory(vaultPath), `${digest}.json`);
|
|
15
16
|
};
|
|
17
|
+
// Signature of a single file, matching the volatile/index signatures used
|
|
18
|
+
// elsewhere (floored mtime + size). A missing file collapses to a stable
|
|
19
|
+
// sentinel so a deleted dependency reliably differs from its recorded value.
|
|
20
|
+
const fileSignature = async (path) => {
|
|
21
|
+
try {
|
|
22
|
+
const info = await stat(path);
|
|
23
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '0:0';
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
// Durable (non-volatile) section source paths, deduplicated. Volatile sections
|
|
30
|
+
// come from turn memory rather than vault files, so they are tracked through the
|
|
31
|
+
// separate volatile signature instead of per-document dependencies.
|
|
32
|
+
const dependencyPaths = (context) => {
|
|
33
|
+
const paths = context.sections.filter((section) => !section.volatile).map((section) => section.path);
|
|
34
|
+
return Array.from(new Set(paths));
|
|
35
|
+
};
|
|
36
|
+
const computeDependencies = async (vaultPath, context) => Promise.all(dependencyPaths(context).map(async (path) => ({ path, signature: await fileSignature(join(vaultPath, path)) })));
|
|
16
37
|
const isStoredContextPack = (value) => {
|
|
17
38
|
if (!value || typeof value !== 'object') {
|
|
18
39
|
return false;
|
|
19
40
|
}
|
|
20
41
|
const candidate = value;
|
|
21
|
-
return candidate.version ===
|
|
42
|
+
return (candidate.version === storedPackVersion &&
|
|
43
|
+
Array.isArray(candidate.dependencies) &&
|
|
44
|
+
typeof candidate.volatileSignature === 'string' &&
|
|
45
|
+
Boolean(candidate.context));
|
|
22
46
|
};
|
|
23
47
|
const readStoredContextPack = async (path) => {
|
|
24
48
|
try {
|
|
@@ -29,30 +53,43 @@ const readStoredContextPack = async (path) => {
|
|
|
29
53
|
return null;
|
|
30
54
|
}
|
|
31
55
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
// A pack is fresh while it is within its optional TTL, its volatile signature
|
|
57
|
+
// matches, and every cited document is unchanged on disk. Reads no vault index,
|
|
58
|
+
// so the CAG fast path stays cheap: a few stats over the cited files.
|
|
59
|
+
const isPackFresh = async (vaultPath, stored, freshness, now) => {
|
|
60
|
+
if (freshness.ttlMs > 0) {
|
|
61
|
+
const age = now - Date.parse(stored.createdAt);
|
|
62
|
+
if (!Number.isFinite(age) || age > freshness.ttlMs) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (stored.volatileSignature !== freshness.volatileSignature) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const current = await Promise.all(stored.dependencies.map((dependency) => fileSignature(join(vaultPath, dependency.path))));
|
|
70
|
+
return stored.dependencies.every((dependency, index) => dependency.signature === current[index]);
|
|
71
|
+
};
|
|
72
|
+
const toContextPackSummary = async (path, vaultPath, freshness) => {
|
|
73
|
+
const [info, stored] = await Promise.all([stat(path), readStoredContextPack(path)]);
|
|
74
|
+
const stale = !stored || (freshness ? !(await isPackFresh(vaultPath, stored, freshness, Date.now())) : false);
|
|
38
75
|
return {
|
|
39
76
|
path,
|
|
40
77
|
filename: basename(path),
|
|
41
78
|
createdAt: stored?.createdAt ?? null,
|
|
42
|
-
|
|
79
|
+
dependencyCount: stored?.dependencies.length ?? null,
|
|
43
80
|
key: stored?.key ?? null,
|
|
44
81
|
sizeBytes: info.size,
|
|
45
82
|
stale
|
|
46
83
|
};
|
|
47
84
|
};
|
|
48
|
-
export const listContextPacks = async (vaultPath,
|
|
85
|
+
export const listContextPacks = async (vaultPath, freshness) => {
|
|
49
86
|
const directory = contextPacksDirectory(vaultPath);
|
|
50
87
|
try {
|
|
51
88
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
52
89
|
const paths = entries
|
|
53
90
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
54
91
|
.map((entry) => join(directory, entry.name));
|
|
55
|
-
const summaries = await Promise.all(paths.map((path) => toContextPackSummary(path,
|
|
92
|
+
const summaries = await Promise.all(paths.map((path) => toContextPackSummary(path, vaultPath, freshness)));
|
|
56
93
|
return summaries.sort((left, right) => (right.createdAt ?? '').localeCompare(left.createdAt ?? '') || left.filename.localeCompare(right.filename));
|
|
57
94
|
}
|
|
58
95
|
catch (error) {
|
|
@@ -63,20 +100,20 @@ export const listContextPacks = async (vaultPath, dataSignature) => {
|
|
|
63
100
|
}
|
|
64
101
|
};
|
|
65
102
|
export const clearContextPacks = async (vaultPath, options = {}) => {
|
|
66
|
-
const packs = await listContextPacks(vaultPath, options.
|
|
103
|
+
const packs = await listContextPacks(vaultPath, options.freshness);
|
|
67
104
|
const removed = options.staleOnly ? packs.filter((pack) => pack.stale) : packs;
|
|
68
105
|
const kept = options.staleOnly ? packs.filter((pack) => !pack.stale) : [];
|
|
69
106
|
await Promise.all(removed.map((pack) => rm(pack.path, { force: true })));
|
|
70
107
|
return { removed, kept };
|
|
71
108
|
};
|
|
72
|
-
export const readContextPack = async (vaultPath, key,
|
|
109
|
+
export const readContextPack = async (vaultPath, key, freshness) => {
|
|
73
110
|
const path = contextPackPath(vaultPath, key);
|
|
74
111
|
try {
|
|
75
112
|
const parsed = await readStoredContextPack(path);
|
|
76
113
|
if (!parsed) {
|
|
77
114
|
return { status: 'stale', path };
|
|
78
115
|
}
|
|
79
|
-
if (parsed
|
|
116
|
+
if (!(await isPackFresh(vaultPath, parsed, freshness, Date.now()))) {
|
|
80
117
|
return { status: 'stale', path };
|
|
81
118
|
}
|
|
82
119
|
return {
|
|
@@ -88,7 +125,7 @@ export const readContextPack = async (vaultPath, key, dataSignature) => {
|
|
|
88
125
|
cache: {
|
|
89
126
|
storage: 'context-pack',
|
|
90
127
|
status: 'hit',
|
|
91
|
-
dataSignature,
|
|
128
|
+
dataSignature: parsed.volatileSignature,
|
|
92
129
|
path
|
|
93
130
|
}
|
|
94
131
|
}
|
|
@@ -98,12 +135,14 @@ export const readContextPack = async (vaultPath, key, dataSignature) => {
|
|
|
98
135
|
return { status: 'miss', path };
|
|
99
136
|
}
|
|
100
137
|
};
|
|
101
|
-
export const writeContextPack = async (vaultPath, key,
|
|
138
|
+
export const writeContextPack = async (vaultPath, key, context, meta) => {
|
|
102
139
|
const path = contextPackPath(vaultPath, key);
|
|
140
|
+
const dependencies = await computeDependencies(vaultPath, context);
|
|
103
141
|
const stored = {
|
|
104
|
-
version:
|
|
142
|
+
version: storedPackVersion,
|
|
105
143
|
createdAt: new Date().toISOString(),
|
|
106
|
-
|
|
144
|
+
dependencies,
|
|
145
|
+
volatileSignature: meta.volatileSignature,
|
|
107
146
|
key: normalizePackKey(key),
|
|
108
147
|
context: {
|
|
109
148
|
...context,
|
|
@@ -111,7 +150,7 @@ export const writeContextPack = async (vaultPath, key, dataSignature, context) =
|
|
|
111
150
|
cache: {
|
|
112
151
|
storage: 'context-pack',
|
|
113
152
|
status: 'refresh',
|
|
114
|
-
dataSignature,
|
|
153
|
+
dataSignature: meta.volatileSignature,
|
|
115
154
|
path
|
|
116
155
|
}
|
|
117
156
|
}
|
|
@@ -124,7 +124,7 @@ const writeIndex = async (vaultPath, index) => {
|
|
|
124
124
|
// JSON file backend: the original single `index.json` store. Loading/persisting
|
|
125
125
|
// is its own concern; the graph/search/link reads come from the shared query
|
|
126
126
|
// core so this backend stays behavior-identical to the binary backend.
|
|
127
|
-
export const openFileIndex = (vaultPath) => ({
|
|
127
|
+
export const openFileIndex = (vaultPath, options = {}) => ({
|
|
128
128
|
reset: async () => {
|
|
129
129
|
await writeIndex(vaultPath, emptyIndex());
|
|
130
130
|
},
|
|
@@ -134,7 +134,7 @@ export const openFileIndex = (vaultPath) => ({
|
|
|
134
134
|
...createStoredIndexQueries({
|
|
135
135
|
load: () => readIndex(vaultPath),
|
|
136
136
|
loadStrict: () => readIndex(vaultPath, true)
|
|
137
|
-
}),
|
|
137
|
+
}, options),
|
|
138
138
|
close: () => {
|
|
139
139
|
// File-based index has no persistent connection.
|
|
140
140
|
}
|
|
@@ -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,
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
// reads transparently fall back to a legacy `index.json`; the first save writes
|
|
10
10
|
// the binary store and removes the legacy file.
|
|
11
11
|
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
|
|
12
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { createEmbeddingBuckets } from '../../domain/embeddings.js';
|
|
15
|
+
import { runSearch } from '../../application/ranking/run-search.js';
|
|
14
16
|
import { indexStoragePath, readJsonStoredIndex } from '../file-index.js';
|
|
17
|
+
import { selectSemanticCandidates, semanticPrefilterMinChunks } from '../semantic-prefilter.js';
|
|
15
18
|
import { createStoredIndexQueries, toStoredIndex } from './stored-index.js';
|
|
16
19
|
const storeVersion = 1;
|
|
17
20
|
export const binaryStoreDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'store');
|
|
@@ -52,6 +55,24 @@ const decodeVector = (vectors, index, dims) => {
|
|
|
52
55
|
}
|
|
53
56
|
return out;
|
|
54
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
|
+
};
|
|
55
76
|
const writeAtomic = async (path, data) => {
|
|
56
77
|
const temp = `${path}.tmp`;
|
|
57
78
|
await writeFile(temp, data, { mode: 0o600 });
|
|
@@ -84,6 +105,10 @@ const persistStoredIndex = async (vaultPath, stored) => {
|
|
|
84
105
|
await writeAtomic(storeFile(vaultPath, 'chunks.json.br'), brotli(JSON.stringify(chunkMeta)));
|
|
85
106
|
await writeAtomic(storeFile(vaultPath, 'links.json.br'), brotli(JSON.stringify(stored.links)));
|
|
86
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));
|
|
87
112
|
await writeAtomic(metaPath(vaultPath), Buffer.from(`${JSON.stringify(meta)}\n`, 'utf8'));
|
|
88
113
|
// Migration complete: the binary store now holds everything, so drop the
|
|
89
114
|
// legacy JSON index. Markdown remains the canonical source either way.
|
|
@@ -142,12 +167,152 @@ const loadBinary = async (vaultPath) => {
|
|
|
142
167
|
links
|
|
143
168
|
};
|
|
144
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
|
+
};
|
|
145
309
|
// Binary backend. While its store does not yet exist, reads fall back to the
|
|
146
310
|
// legacy JSON index so search/graph stay available mid-migration; the first save
|
|
147
311
|
// materializes the binary store and removes the legacy file.
|
|
148
|
-
export const openBinaryStore = (vaultPath) => {
|
|
312
|
+
export const openBinaryStore = (vaultPath, options = {}) => {
|
|
149
313
|
const load = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, false);
|
|
150
314
|
const loadStrict = async () => (await binaryStoreExists(vaultPath)) ? loadBinary(vaultPath) : readJsonStoredIndex(vaultPath, true);
|
|
315
|
+
const shared = createStoredIndexQueries({ load, loadStrict }, options);
|
|
151
316
|
return {
|
|
152
317
|
reset: async () => {
|
|
153
318
|
await persist(vaultPath, []);
|
|
@@ -155,7 +320,12 @@ export const openBinaryStore = (vaultPath) => {
|
|
|
155
320
|
saveDocuments: async (documents) => {
|
|
156
321
|
await persist(vaultPath, documents);
|
|
157
322
|
},
|
|
158
|
-
...
|
|
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),
|
|
159
329
|
close: () => {
|
|
160
330
|
// File-based store has no persistent connection.
|
|
161
331
|
}
|
|
@@ -3,11 +3,12 @@ import { openFileIndex } from '../file-index.js';
|
|
|
3
3
|
import { migrateLegacyIndexToBinary, openBinaryStore } from './binary-store.js';
|
|
4
4
|
const resolveBackend = async (vaultPath) => {
|
|
5
5
|
const config = await loadBrainlinkConfig();
|
|
6
|
+
const options = { hybridFusion: config.hybridFusion };
|
|
6
7
|
if (config.storageBackend === 'binary') {
|
|
7
8
|
await migrateLegacyIndexToBinary(vaultPath);
|
|
8
|
-
return openBinaryStore(vaultPath);
|
|
9
|
+
return openBinaryStore(vaultPath, options);
|
|
9
10
|
}
|
|
10
|
-
return openFileIndex(vaultPath);
|
|
11
|
+
return openFileIndex(vaultPath, options);
|
|
11
12
|
};
|
|
12
13
|
export const openKnowledgeStore = (vaultPath) => {
|
|
13
14
|
let backend = null;
|
|
@@ -39,7 +39,7 @@ const toGraphNode = (document, includeContent) => ({
|
|
|
39
39
|
// Build the shared read methods over a backend's loaders. The returned object is
|
|
40
40
|
// the read half of the KnowledgeStore port; backends add reset/saveDocuments/
|
|
41
41
|
// close around it.
|
|
42
|
-
export const createStoredIndexQueries = ({ load, loadStrict }) => ({
|
|
42
|
+
export const createStoredIndexQueries = ({ load, loadStrict }, options = {}) => ({
|
|
43
43
|
getIndexedDocuments: async (agentId) => {
|
|
44
44
|
const index = await load();
|
|
45
45
|
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
@@ -105,7 +105,7 @@ export const createStoredIndexQueries = ({ load, loadStrict }) => ({
|
|
|
105
105
|
const candidates = mode === 'semantic' && queryEmbedding.length > 0
|
|
106
106
|
? selectSemanticCandidates(rows, createEmbeddingBuckets(queryEmbedding), limit)
|
|
107
107
|
: rows;
|
|
108
|
-
return runSearch(candidates, query, limit, mode, queryEmbedding);
|
|
108
|
+
return runSearch(candidates, query, limit, mode, queryEmbedding, options.hybridFusion);
|
|
109
109
|
},
|
|
110
110
|
listLinks: async (agentId) => {
|
|
111
111
|
const index = await load();
|
|
@@ -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
|
+
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools.js';
|
|
2
|
+
import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultCommitInputSchema, vaultCommitTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
|
|
3
3
|
import { getRuntimeVersion } from './runtime.js';
|
|
4
4
|
import { guardToolHandler } from './tool-guard.js';
|
|
5
5
|
export const createBrainlinkMcpServer = () => {
|
|
@@ -155,6 +155,51 @@ export const createBrainlinkMcpServer = () => {
|
|
|
155
155
|
description: 'Run index, stats, validate, broken-links and orphans checks in one call. Optionally run context probe.',
|
|
156
156
|
inputSchema: syncInputSchema
|
|
157
157
|
}, syncTool);
|
|
158
|
+
server.registerTool('brainlink_vault_status', {
|
|
159
|
+
title: 'Vault Git Status',
|
|
160
|
+
description: 'Report the vault git branch, whether it is dirty, and which Markdown files changed. Only Markdown is versioned; the derived index is git-ignored.',
|
|
161
|
+
inputSchema: vaultStatusInputSchema
|
|
162
|
+
}, vaultStatusTool);
|
|
163
|
+
server.registerTool('brainlink_vault_init', {
|
|
164
|
+
title: 'Initialize Vault Git',
|
|
165
|
+
description: 'Initialize git versioning for the vault (git init + .gitignore for the derived index), optionally setting an origin remote. Inline-credential remotes are refused.',
|
|
166
|
+
inputSchema: vaultInitInputSchema
|
|
167
|
+
}, vaultInitTool);
|
|
168
|
+
server.registerTool('brainlink_vault_provision', {
|
|
169
|
+
title: 'Provision Vault Repository',
|
|
170
|
+
description: 'Create a private GitHub repository for the vault with the authenticated gh CLI, wire it as origin, push the first snapshot, and enable autoVersion so later indexed changes are committed and pushed automatically. Only Markdown is versioned.',
|
|
171
|
+
inputSchema: vaultProvisionInputSchema
|
|
172
|
+
}, vaultProvisionTool);
|
|
173
|
+
server.registerTool('brainlink_vault_commit', {
|
|
174
|
+
title: 'Commit Vault Markdown',
|
|
175
|
+
description: 'Stage and commit the canonical Markdown (and .gitignore), optionally pushing to origin. Returns null commit when there is nothing to commit.',
|
|
176
|
+
inputSchema: vaultCommitInputSchema
|
|
177
|
+
}, vaultCommitTool);
|
|
178
|
+
server.registerTool('brainlink_vault_history', {
|
|
179
|
+
title: 'Vault Markdown History',
|
|
180
|
+
description: 'List recent git commits that touched the vault Markdown, with hash, author, date and subject.',
|
|
181
|
+
inputSchema: vaultHistoryInputSchema
|
|
182
|
+
}, vaultHistoryTool);
|
|
183
|
+
server.registerTool('brainlink_vault_pull', {
|
|
184
|
+
title: 'Pull Vault From Origin',
|
|
185
|
+
description: 'Pull Markdown from origin and reindex changed files so the vault stays immediately searchable. A merge conflict is aborted with a clear error instead of leaving conflict markers.',
|
|
186
|
+
inputSchema: vaultPullInputSchema
|
|
187
|
+
}, vaultPullTool);
|
|
188
|
+
server.registerTool('brainlink_vault_restore', {
|
|
189
|
+
title: 'Restore Vault To Ref',
|
|
190
|
+
description: 'Restore the vault Markdown to a git ref (commit, tag or branch) and reindex the changed files.',
|
|
191
|
+
inputSchema: vaultRestoreInputSchema
|
|
192
|
+
}, vaultRestoreTool);
|
|
193
|
+
server.registerTool('brainlink_vault_export', {
|
|
194
|
+
title: 'Export Vault Snapshot',
|
|
195
|
+
description: 'Write a portable .blvault snapshot (gzip envelope of the Markdown with per-file SHA-256). The derived index is not included; import rebuilds it.',
|
|
196
|
+
inputSchema: vaultExportInputSchema
|
|
197
|
+
}, vaultExportTool);
|
|
198
|
+
server.registerTool('brainlink_vault_import', {
|
|
199
|
+
title: 'Import Vault Snapshot',
|
|
200
|
+
description: 'Import a .blvault snapshot into the vault and reindex. Checksums are verified; divergent local files are preserved as *.conflict-<ts>.md instead of being overwritten.',
|
|
201
|
+
inputSchema: vaultImportInputSchema
|
|
202
|
+
}, vaultImportTool);
|
|
158
203
|
server.registerTool('brainlink_graph', {
|
|
159
204
|
title: 'Read Brainlink Graph',
|
|
160
205
|
description: 'Read indexed graph nodes and wiki-link edges. Edges include weight and priority fields so agents can rank importance and priority.',
|
|
@@ -176,7 +176,7 @@ export const syncTool = async (input) => {
|
|
|
176
176
|
const strategy = sanitizeContextStrategy(input.strategy, context.defaults.defaultContextStrategy);
|
|
177
177
|
const contextLimit = input.contextLimit ?? context.defaults.defaultSearchLimit;
|
|
178
178
|
const contextTokens = input.contextTokens ?? context.defaults.defaultContextTokens;
|
|
179
|
-
const contextPackage = await buildContextPackage(context.vault, input.contextQuery, contextLimit, contextTokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs);
|
|
179
|
+
const contextPackage = await buildContextPackage(context.vault, input.contextQuery, contextLimit, contextTokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs, context.defaults.cagPackTtlMs);
|
|
180
180
|
const contextSession = await touchContextSession(context.vault, context.agent);
|
|
181
181
|
return jsonResult({
|
|
182
182
|
...response,
|
|
@@ -198,7 +198,7 @@ export const bootstrapTool = async (input) => {
|
|
|
198
198
|
const limit = input.limit ?? context.defaults.defaultSearchLimit;
|
|
199
199
|
const tokens = input.tokens ?? context.defaults.defaultContextTokens;
|
|
200
200
|
const contextPackage = input.query
|
|
201
|
-
? await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs)
|
|
201
|
+
? await buildContextPackage(context.vault, input.query, limit, tokens, context.agent, mode, strategy, context.defaults.defaultContextCacheTtlMs, context.defaults.cagPackTtlMs)
|
|
202
202
|
: undefined;
|
|
203
203
|
const contextSession = input.query ? await touchContextSession(context.vault, context.agent) : undefined;
|
|
204
204
|
const guidance = stats.documentCount === 0
|