@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.161
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/AGENTS.md +9 -6
- package/CHANGELOG.md +27 -0
- package/COPYRIGHT.md +5 -0
- package/README.md +177 -20
- package/dist/application/add-note.js +13 -44
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +64 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +258 -51
- package/dist/application/frontend/client-html.js +50 -27
- package/dist/application/frontend/client-js.js +1369 -605
- package/dist/application/frontend/client-render-worker-js.js +645 -0
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-contexts.js +33 -0
- package/dist/application/get-graph-layout.js +62 -8
- package/dist/application/get-graph-stream-chunk.js +326 -0
- package/dist/application/get-graph-view.js +246 -0
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/import-legacy-sqlite.js +266 -0
- package/dist/application/index-vault.js +262 -23
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +247 -7
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/cli/commands/agent-commands.js +7 -0
- package/dist/cli/commands/write-commands.js +924 -14
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +54 -11
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +389 -18
- package/dist/domain/markdown.js +53 -9
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +121 -4
- package/dist/infrastructure/file-index.js +76 -6
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +58 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +286 -15
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +39 -11
- package/dist/mcp/tools.js +183 -7
- package/docs/AGENT_USAGE.md +96 -5
- package/docs/ARCHITECTURE.md +8 -0
- package/docs/QUICKSTART.md +7 -0
- package/package.json +7 -2
|
@@ -10,11 +10,19 @@ export const defaultBrainlinkConfig = {
|
|
|
10
10
|
allowedVaults: [],
|
|
11
11
|
defaultAgent: undefined,
|
|
12
12
|
autoIndexOnWrite: true,
|
|
13
|
+
autoCanonicalContextLinks: true,
|
|
13
14
|
defaultSearchLimit: 10,
|
|
14
15
|
defaultContextTokens: 2000,
|
|
15
16
|
embeddingProvider: 'local',
|
|
16
17
|
defaultSearchMode: 'hybrid',
|
|
17
18
|
chunkSize: 1200,
|
|
19
|
+
searchPack: {
|
|
20
|
+
rowChunkSize: 5_000,
|
|
21
|
+
compressionLevel: 5,
|
|
22
|
+
useDictionary: true,
|
|
23
|
+
guardrailMinSavingsPercent: 8,
|
|
24
|
+
guardrailMaxLatencyRegressionPercent: 5
|
|
25
|
+
},
|
|
18
26
|
agentProfiles: {}
|
|
19
27
|
};
|
|
20
28
|
const configFilenames = ['brainlink.config.json', '.brainlink.json'];
|
|
@@ -37,6 +45,36 @@ const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embedd
|
|
|
37
45
|
export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
|
|
38
46
|
const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
|
39
47
|
const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
48
|
+
const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
|
|
49
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
const rounded = Math.round(value);
|
|
53
|
+
if (rounded < minimum) {
|
|
54
|
+
return minimum;
|
|
55
|
+
}
|
|
56
|
+
if (rounded > maximum) {
|
|
57
|
+
return maximum;
|
|
58
|
+
}
|
|
59
|
+
return rounded;
|
|
60
|
+
};
|
|
61
|
+
const sanitizeSearchPackConfig = (value) => {
|
|
62
|
+
const fallback = defaultBrainlinkConfig.searchPack;
|
|
63
|
+
if (!isRecord(value)) {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
rowChunkSize: sanitizeIntegerInRange(value.rowChunkSize, fallback.rowChunkSize, 100, 100_000),
|
|
68
|
+
compressionLevel: sanitizeIntegerInRange(value.compressionLevel, fallback.compressionLevel, 0, 11),
|
|
69
|
+
useDictionary: typeof value.useDictionary === 'boolean' ? value.useDictionary : fallback.useDictionary,
|
|
70
|
+
guardrailMinSavingsPercent: typeof value.guardrailMinSavingsPercent === 'number' && Number.isFinite(value.guardrailMinSavingsPercent)
|
|
71
|
+
? Math.max(0, Math.min(95, value.guardrailMinSavingsPercent))
|
|
72
|
+
: fallback.guardrailMinSavingsPercent,
|
|
73
|
+
guardrailMaxLatencyRegressionPercent: typeof value.guardrailMaxLatencyRegressionPercent === 'number' && Number.isFinite(value.guardrailMaxLatencyRegressionPercent)
|
|
74
|
+
? Math.max(0, Math.min(300, value.guardrailMaxLatencyRegressionPercent))
|
|
75
|
+
: fallback.guardrailMaxLatencyRegressionPercent
|
|
76
|
+
};
|
|
77
|
+
};
|
|
40
78
|
const sanitizeAgentProfile = (value) => {
|
|
41
79
|
if (!isRecord(value)) {
|
|
42
80
|
return null;
|
|
@@ -122,6 +160,9 @@ const sanitizeConfig = (value) => ({
|
|
|
122
160
|
? sanitizeAgentId(value.defaultAgent)
|
|
123
161
|
: defaultBrainlinkConfig.defaultAgent,
|
|
124
162
|
autoIndexOnWrite: typeof value.autoIndexOnWrite === 'boolean' ? value.autoIndexOnWrite : defaultBrainlinkConfig.autoIndexOnWrite,
|
|
163
|
+
autoCanonicalContextLinks: typeof value.autoCanonicalContextLinks === 'boolean'
|
|
164
|
+
? value.autoCanonicalContextLinks
|
|
165
|
+
: defaultBrainlinkConfig.autoCanonicalContextLinks,
|
|
125
166
|
defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
|
|
126
167
|
? value.defaultSearchLimit
|
|
127
168
|
: defaultBrainlinkConfig.defaultSearchLimit,
|
|
@@ -130,6 +171,7 @@ const sanitizeConfig = (value) => ({
|
|
|
130
171
|
: defaultBrainlinkConfig.defaultContextTokens,
|
|
131
172
|
allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
|
|
132
173
|
chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
|
|
174
|
+
searchPack: sanitizeSearchPackConfig(value.searchPack),
|
|
133
175
|
embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
|
|
134
176
|
defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
|
|
135
177
|
agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
|
|
@@ -143,12 +185,87 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
|
143
185
|
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
|
|
144
186
|
};
|
|
145
187
|
};
|
|
188
|
+
const mergeConfigLayers = (layers) => layers.reduce((state, config) => ({
|
|
189
|
+
...state,
|
|
190
|
+
...config
|
|
191
|
+
}), {});
|
|
192
|
+
export const getVaultConfigSourceDetails = async (cwd = safeCwd()) => {
|
|
193
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
194
|
+
loadRawConfig('global', cwd),
|
|
195
|
+
loadRawConfig('local', cwd),
|
|
196
|
+
loadLegacyLocalRawConfig(cwd)
|
|
197
|
+
]);
|
|
198
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
source: 'local-legacy',
|
|
201
|
+
sourcePath: resolve(cwd, '.brainlink.json')
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
205
|
+
return {
|
|
206
|
+
source: 'local',
|
|
207
|
+
sourcePath: getLocalConfigPath(cwd)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
211
|
+
return {
|
|
212
|
+
source: 'global',
|
|
213
|
+
sourcePath: getGlobalConfigPath()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
source: 'default',
|
|
218
|
+
sourcePath: null
|
|
219
|
+
};
|
|
220
|
+
};
|
|
146
221
|
export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
|
|
147
222
|
const globalConfig = await readJsonConfig(getGlobalConfigPath());
|
|
148
223
|
const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
|
|
149
|
-
const merged = [globalConfig, ...localConfigs]
|
|
150
|
-
...state,
|
|
151
|
-
...config
|
|
152
|
-
}), {});
|
|
224
|
+
const merged = mergeConfigLayers([globalConfig, ...localConfigs]);
|
|
153
225
|
return sanitizeConfig(merged);
|
|
154
226
|
};
|
|
227
|
+
export const loadBrainlinkConfigWithSource = async (cwd = safeCwd()) => {
|
|
228
|
+
const globalConfigPath = getGlobalConfigPath();
|
|
229
|
+
const localConfigPath = getLocalConfigPath(cwd);
|
|
230
|
+
const legacyLocalConfigPath = resolve(cwd, '.brainlink.json');
|
|
231
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
232
|
+
readJsonConfig(globalConfigPath),
|
|
233
|
+
readJsonConfig(localConfigPath),
|
|
234
|
+
readJsonConfig(legacyLocalConfigPath)
|
|
235
|
+
]);
|
|
236
|
+
const config = sanitizeConfig(mergeConfigLayers([globalConfig, localConfig, legacyLocalConfig]));
|
|
237
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
238
|
+
return {
|
|
239
|
+
config,
|
|
240
|
+
vaultSource: {
|
|
241
|
+
source: 'local-legacy',
|
|
242
|
+
sourcePath: legacyLocalConfigPath
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
247
|
+
return {
|
|
248
|
+
config,
|
|
249
|
+
vaultSource: {
|
|
250
|
+
source: 'local',
|
|
251
|
+
sourcePath: localConfigPath
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
256
|
+
return {
|
|
257
|
+
config,
|
|
258
|
+
vaultSource: {
|
|
259
|
+
source: 'global',
|
|
260
|
+
sourcePath: globalConfigPath
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
config,
|
|
266
|
+
vaultSource: {
|
|
267
|
+
source: 'default',
|
|
268
|
+
sourcePath: null
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { cosineSimilarity } from '../domain/embeddings.js';
|
|
4
4
|
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
5
|
+
const indexCacheMaxEntries = 16;
|
|
6
|
+
const indexCache = new Map();
|
|
5
7
|
const emptyIndex = () => ({
|
|
6
8
|
version: 1,
|
|
7
9
|
updatedAt: new Date().toISOString(),
|
|
@@ -11,18 +13,44 @@ const emptyIndex = () => ({
|
|
|
11
13
|
});
|
|
12
14
|
export const indexStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'index.json');
|
|
13
15
|
const readIndex = async (vaultPath) => {
|
|
16
|
+
const path = indexStoragePath(vaultPath);
|
|
17
|
+
let stats = null;
|
|
14
18
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
19
|
+
const fileStats = await stat(path);
|
|
20
|
+
stats = { mtimeMs: fileStats.mtimeMs, size: fileStats.size };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
24
|
+
indexCache.delete(path);
|
|
25
|
+
return emptyIndex();
|
|
26
|
+
}
|
|
27
|
+
return emptyIndex();
|
|
28
|
+
}
|
|
29
|
+
const cached = indexCache.get(path);
|
|
30
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
|
|
31
|
+
return cached.index;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
35
|
+
const loaded = {
|
|
17
36
|
version: 1,
|
|
18
37
|
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
19
38
|
documents: Array.isArray(parsed.documents) ? parsed.documents : [],
|
|
20
39
|
chunks: Array.isArray(parsed.chunks) ? parsed.chunks : [],
|
|
21
40
|
links: Array.isArray(parsed.links) ? parsed.links : []
|
|
22
41
|
};
|
|
42
|
+
indexCache.set(path, { ...stats, index: loaded });
|
|
43
|
+
if (indexCache.size > indexCacheMaxEntries) {
|
|
44
|
+
const oldest = indexCache.keys().next().value;
|
|
45
|
+
if (typeof oldest === 'string') {
|
|
46
|
+
indexCache.delete(oldest);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return loaded;
|
|
23
50
|
}
|
|
24
51
|
catch (error) {
|
|
25
52
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
53
|
+
indexCache.delete(path);
|
|
26
54
|
return emptyIndex();
|
|
27
55
|
}
|
|
28
56
|
return emptyIndex();
|
|
@@ -34,6 +62,12 @@ const writeIndex = async (vaultPath, index) => {
|
|
|
34
62
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
35
63
|
await writeFile(temp, `${JSON.stringify(index)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
36
64
|
await rename(temp, target);
|
|
65
|
+
const fileStats = await stat(target);
|
|
66
|
+
indexCache.set(target, {
|
|
67
|
+
mtimeMs: fileStats.mtimeMs,
|
|
68
|
+
size: fileStats.size,
|
|
69
|
+
index
|
|
70
|
+
});
|
|
37
71
|
};
|
|
38
72
|
const normalizeToken = (value) => value
|
|
39
73
|
.normalize('NFKD')
|
|
@@ -81,6 +115,7 @@ const toResult = (row, mode, text, semantic) => {
|
|
|
81
115
|
title: row.title,
|
|
82
116
|
path: row.path,
|
|
83
117
|
chunkId: row.chunkId,
|
|
118
|
+
chunkOrdinal: row.chunkOrdinal,
|
|
84
119
|
content: row.content,
|
|
85
120
|
score,
|
|
86
121
|
textScore: text,
|
|
@@ -120,6 +155,36 @@ export const openFileIndex = (vaultPath) => {
|
|
|
120
155
|
links
|
|
121
156
|
});
|
|
122
157
|
},
|
|
158
|
+
getIndexedDocuments: async (agentId) => {
|
|
159
|
+
const index = await load();
|
|
160
|
+
const documents = agentId ? index.documents.filter((document) => document.agentId === agentId) : index.documents;
|
|
161
|
+
const selectedDocumentIds = new Set(documents.map((document) => document.id));
|
|
162
|
+
const chunksByDocumentId = index.chunks.reduce((state, chunk) => {
|
|
163
|
+
if (!selectedDocumentIds.has(chunk.documentId)) {
|
|
164
|
+
return state;
|
|
165
|
+
}
|
|
166
|
+
const current = state.get(chunk.documentId) ?? [];
|
|
167
|
+
current.push(chunk);
|
|
168
|
+
state.set(chunk.documentId, current);
|
|
169
|
+
return state;
|
|
170
|
+
}, new Map());
|
|
171
|
+
const linksByDocumentId = index.links.reduce((state, link) => {
|
|
172
|
+
if (!selectedDocumentIds.has(link.fromDocumentId)) {
|
|
173
|
+
return state;
|
|
174
|
+
}
|
|
175
|
+
const current = state.get(link.fromDocumentId) ?? [];
|
|
176
|
+
current.push(link);
|
|
177
|
+
state.set(link.fromDocumentId, current);
|
|
178
|
+
return state;
|
|
179
|
+
}, new Map());
|
|
180
|
+
return documents
|
|
181
|
+
.map((document) => ({
|
|
182
|
+
document,
|
|
183
|
+
chunks: [...(chunksByDocumentId.get(document.id) ?? [])].sort((left, right) => left.ordinal - right.ordinal),
|
|
184
|
+
links: linksByDocumentId.get(document.id) ?? []
|
|
185
|
+
}))
|
|
186
|
+
.sort((left, right) => left.document.path.localeCompare(right.document.path));
|
|
187
|
+
},
|
|
123
188
|
search: async (query, limit, agentId, mode = 'hybrid', queryEmbedding = []) => {
|
|
124
189
|
const index = await load();
|
|
125
190
|
const documentsById = new Map(index.documents.map((document) => [document.id, document]));
|
|
@@ -138,6 +203,7 @@ export const openFileIndex = (vaultPath) => {
|
|
|
138
203
|
title: document.title,
|
|
139
204
|
path: document.path,
|
|
140
205
|
chunkId: chunk.id,
|
|
206
|
+
chunkOrdinal: chunk.ordinal,
|
|
141
207
|
content: chunk.content,
|
|
142
208
|
tags: document.tags,
|
|
143
209
|
embedding: chunk.embedding
|
|
@@ -200,7 +266,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
200
266
|
title: document.title,
|
|
201
267
|
path: document.path,
|
|
202
268
|
content: document.content,
|
|
203
|
-
tags: document.tags
|
|
269
|
+
tags: document.tags,
|
|
270
|
+
contextLinks: document.contextLinks ?? []
|
|
204
271
|
})),
|
|
205
272
|
edges
|
|
206
273
|
};
|
|
@@ -226,7 +293,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
226
293
|
title: document.title,
|
|
227
294
|
path: document.path,
|
|
228
295
|
content: '',
|
|
229
|
-
tags: document.tags
|
|
296
|
+
tags: document.tags,
|
|
297
|
+
contextLinks: document.contextLinks ?? []
|
|
230
298
|
})),
|
|
231
299
|
edges
|
|
232
300
|
};
|
|
@@ -243,7 +311,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
243
311
|
title: document.title,
|
|
244
312
|
path: document.path,
|
|
245
313
|
content: document.content,
|
|
246
|
-
tags: document.tags
|
|
314
|
+
tags: document.tags,
|
|
315
|
+
contextLinks: document.contextLinks ?? []
|
|
247
316
|
}
|
|
248
317
|
: undefined;
|
|
249
318
|
},
|
|
@@ -263,6 +332,7 @@ export const openFileIndex = (vaultPath) => {
|
|
|
263
332
|
title: document.title,
|
|
264
333
|
path: document.path,
|
|
265
334
|
chunkId: document.id,
|
|
335
|
+
chunkOrdinal: 0,
|
|
266
336
|
content: document.content,
|
|
267
337
|
tags: document.tags,
|
|
268
338
|
embedding: []
|
|
@@ -76,6 +76,21 @@ export const readMarkdownFiles = async (vaultPath) => {
|
|
|
76
76
|
};
|
|
77
77
|
}));
|
|
78
78
|
};
|
|
79
|
+
export const readMarkdownFileSummaries = async (vaultPath) => {
|
|
80
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
81
|
+
const paths = await walkMarkdownFiles(absoluteVaultPath);
|
|
82
|
+
const summaries = await Promise.all(paths.map(async (absolutePath) => {
|
|
83
|
+
const fileStats = await stat(absolutePath);
|
|
84
|
+
return {
|
|
85
|
+
absolutePath,
|
|
86
|
+
relativePath: relative(absoluteVaultPath, absolutePath),
|
|
87
|
+
createdAt: fileStats.birthtime,
|
|
88
|
+
updatedAt: fileStats.mtime,
|
|
89
|
+
size: fileStats.size
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
return summaries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
93
|
+
};
|
|
79
94
|
export const listVaultFiles = async (vaultPath) => {
|
|
80
95
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
81
96
|
return walkVaultFiles(absoluteVaultPath);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const indexStateFileName = 'index-state.json';
|
|
4
|
+
const toIndexStatePath = (vaultPath) => join(vaultPath, '.brainlink', indexStateFileName);
|
|
5
|
+
export const readIndexState = async (vaultPath) => {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(await readFile(toIndexStatePath(vaultPath), 'utf8'));
|
|
8
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.files)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const files = parsed.files.flatMap((entry) => {
|
|
12
|
+
if (!entry || typeof entry !== 'object') {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const row = entry;
|
|
16
|
+
if (typeof row.path !== 'string' || typeof row.mtimeMs !== 'number' || typeof row.size !== 'number') {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
path: row.path,
|
|
22
|
+
mtimeMs: row.mtimeMs,
|
|
23
|
+
size: row.size
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
30
|
+
chunkSize: typeof parsed.chunkSize === 'number' ? parsed.chunkSize : 1200,
|
|
31
|
+
embeddingProvider: typeof parsed.embeddingProvider === 'string' ? parsed.embeddingProvider : 'none',
|
|
32
|
+
graphLinkModelVersion: typeof parsed.graphLinkModelVersion === 'number' ? parsed.graphLinkModelVersion : 1,
|
|
33
|
+
searchPackRowChunkSize: typeof parsed.searchPackRowChunkSize === 'number' ? parsed.searchPackRowChunkSize : 5_000,
|
|
34
|
+
searchPackCompressionLevel: typeof parsed.searchPackCompressionLevel === 'number' ? parsed.searchPackCompressionLevel : 5,
|
|
35
|
+
searchPackUseDictionary: typeof parsed.searchPackUseDictionary === 'boolean' ? parsed.searchPackUseDictionary : true,
|
|
36
|
+
files,
|
|
37
|
+
pendingPackChanges: typeof parsed.pendingPackChanges === 'number' && parsed.pendingPackChanges >= 0 ? parsed.pendingPackChanges : 0
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
export const writeIndexState = async (vaultPath, state) => {
|
|
45
|
+
const payload = {
|
|
46
|
+
version: 1,
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
chunkSize: state.chunkSize,
|
|
49
|
+
embeddingProvider: state.embeddingProvider,
|
|
50
|
+
graphLinkModelVersion: state.graphLinkModelVersion,
|
|
51
|
+
searchPackRowChunkSize: state.searchPackRowChunkSize,
|
|
52
|
+
searchPackCompressionLevel: state.searchPackCompressionLevel,
|
|
53
|
+
searchPackUseDictionary: state.searchPackUseDictionary,
|
|
54
|
+
files: [...state.files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
55
|
+
pendingPackChanges: Math.max(0, Math.floor(state.pendingPackChanges))
|
|
56
|
+
};
|
|
57
|
+
await writeFile(toIndexStatePath(vaultPath), `${JSON.stringify(payload)}\n`, 'utf8');
|
|
58
|
+
};
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib';
|
|
2
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { getBrainlinkHomePath } from './paths.js';
|
|
6
6
|
const magic = Buffer.from('BLPK2', 'ascii');
|
|
7
|
-
const
|
|
7
|
+
const legacyVersion = 1;
|
|
8
|
+
const currentVersion = 2;
|
|
8
9
|
const nonceLength = 12;
|
|
9
10
|
const authTagLength = 16;
|
|
10
11
|
const algorithm = 'aes-256-gcm';
|
|
12
|
+
const compressionLevelMask = 0x0f;
|
|
13
|
+
const compressionDictionaryMask = 0x10;
|
|
14
|
+
const defaultCompressionLevel = 5;
|
|
15
|
+
const builtinDictionary = Buffer.from([
|
|
16
|
+
'"documentId","agentId","title","path","chunkId","chunkOrdinal","content","tags"',
|
|
17
|
+
'"searchMode","textScore","semanticScore","weight","priority","shared"',
|
|
18
|
+
'agents/shared memory-hub architecture context index search graph markdown tags links',
|
|
19
|
+
'#memory #architecture #context #graph #search #index [[Memory Hub]] [[Architecture]]',
|
|
20
|
+
'The quick brown fox jumps over the lazy dog. Brainlink context package metadata.',
|
|
21
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-:/.#[]{}(), '
|
|
22
|
+
].join('\n'), 'utf8');
|
|
11
23
|
const keyFilePath = (vaultPath) => {
|
|
12
24
|
const vaultHash = createHash('sha256').update(vaultPath).digest('hex').slice(0, 24);
|
|
13
25
|
return join(getBrainlinkHomePath(), 'keys', `search-pack-${vaultHash}.key`);
|
|
@@ -40,34 +52,83 @@ const parseHeader = (payload) => {
|
|
|
40
52
|
throw new Error('Invalid private pack payload: too short.');
|
|
41
53
|
}
|
|
42
54
|
const payloadMagic = payload.subarray(0, magic.length);
|
|
43
|
-
const payloadVersion = payload[magic.length];
|
|
44
|
-
if (!payloadMagic.equals(magic) || payloadVersion !==
|
|
55
|
+
const payloadVersion = payload[magic.length] ?? 0;
|
|
56
|
+
if (!payloadMagic.equals(magic) || (payloadVersion !== legacyVersion && payloadVersion !== currentVersion)) {
|
|
45
57
|
throw new Error('Invalid private pack payload: unsupported format.');
|
|
46
58
|
}
|
|
47
|
-
const
|
|
59
|
+
const hasCompressionSettings = payloadVersion >= 2;
|
|
60
|
+
const settingsByte = hasCompressionSettings ? payload[magic.length + 1] ?? 0 : null;
|
|
61
|
+
const nonceStart = magic.length + 1 + (hasCompressionSettings ? 1 : 0);
|
|
48
62
|
const authTagStart = nonceStart + nonceLength;
|
|
49
63
|
const dataStart = authTagStart + authTagLength;
|
|
50
64
|
return {
|
|
65
|
+
compression: settingsByte != null
|
|
66
|
+
? {
|
|
67
|
+
compressionLevel: settingsByte & compressionLevelMask,
|
|
68
|
+
useDictionary: (settingsByte & compressionDictionaryMask) !== 0
|
|
69
|
+
}
|
|
70
|
+
: {
|
|
71
|
+
compressionLevel: defaultCompressionLevel,
|
|
72
|
+
useDictionary: false
|
|
73
|
+
},
|
|
51
74
|
nonce: payload.subarray(nonceStart, authTagStart),
|
|
52
75
|
authTag: payload.subarray(authTagStart, dataStart),
|
|
53
76
|
ciphertext: payload.subarray(dataStart)
|
|
54
77
|
};
|
|
55
78
|
};
|
|
56
|
-
|
|
79
|
+
const toCompressionLevel = (value) => {
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
81
|
+
return defaultCompressionLevel;
|
|
82
|
+
}
|
|
83
|
+
const normalized = Math.round(value);
|
|
84
|
+
if (normalized < 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (normalized > 11) {
|
|
88
|
+
return 11;
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
};
|
|
92
|
+
const encodeCompressionSettings = (settings) => (settings.compressionLevel & compressionLevelMask) | (settings.useDictionary ? compressionDictionaryMask : 0);
|
|
93
|
+
const brotliEncode = (content, settings) => {
|
|
94
|
+
const options = {
|
|
95
|
+
params: {
|
|
96
|
+
[zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
|
|
97
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: settings.compressionLevel
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (settings.useDictionary) {
|
|
101
|
+
options.dictionary = builtinDictionary;
|
|
102
|
+
}
|
|
103
|
+
return brotliCompressSync(content, options);
|
|
104
|
+
};
|
|
105
|
+
const brotliDecode = (content, settings) => {
|
|
106
|
+
const options = {};
|
|
107
|
+
if (settings.useDictionary) {
|
|
108
|
+
options.dictionary = builtinDictionary;
|
|
109
|
+
}
|
|
110
|
+
return brotliDecompressSync(content, options);
|
|
111
|
+
};
|
|
112
|
+
export const encodePrivatePack = async (vaultPath, content, settings) => {
|
|
57
113
|
const key = await readOrCreateKey(vaultPath);
|
|
58
114
|
const nonce = randomBytes(nonceLength);
|
|
59
|
-
const
|
|
115
|
+
const normalizedSettings = {
|
|
116
|
+
compressionLevel: toCompressionLevel(settings?.compressionLevel),
|
|
117
|
+
useDictionary: settings?.useDictionary ?? true
|
|
118
|
+
};
|
|
119
|
+
const compressed = brotliEncode(content, normalizedSettings);
|
|
60
120
|
const cipher = createCipheriv(algorithm, key, nonce);
|
|
61
121
|
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
62
122
|
const authTag = cipher.getAuthTag();
|
|
63
|
-
|
|
123
|
+
const settingsByte = Buffer.from([encodeCompressionSettings(normalizedSettings)]);
|
|
124
|
+
return Buffer.concat([magic, Buffer.from([currentVersion]), settingsByte, nonce, authTag, ciphertext]);
|
|
64
125
|
};
|
|
65
126
|
export const decodePrivatePack = async (vaultPath, payload) => {
|
|
66
127
|
const key = await readOrCreateKey(vaultPath);
|
|
67
|
-
const { nonce, authTag, ciphertext } = parseHeader(payload);
|
|
128
|
+
const { nonce, authTag, ciphertext, compression } = parseHeader(payload);
|
|
68
129
|
const decipher = createDecipheriv(algorithm, key, nonce);
|
|
69
130
|
decipher.setAuthTag(authTag);
|
|
70
131
|
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
71
|
-
return
|
|
132
|
+
return brotliDecode(compressed, compression);
|
|
72
133
|
};
|
|
73
134
|
export const isPrivatePackPayload = (payload) => payload.length >= magic.length + 1 && payload.subarray(0, magic.length).equals(magic);
|