@andespindola/brainlink 0.1.0-beta.99 → 1.0.1
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 +6 -6
- package/CHANGELOG.md +14 -0
- package/README.md +198 -38
- package/dist/application/add-note.js +13 -44
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +119 -20
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/delete-note.js +80 -0
- package/dist/application/frontend/client-css.js +212 -42
- package/dist/application/frontend/client-html.js +42 -28
- package/dist/application/frontend/client-js.js +1294 -3222
- package/dist/application/frontend/client-render-worker-js.js +676 -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 +3 -33
- package/dist/application/index-vault.js +35 -22
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +197 -12
- package/dist/cli/commands/read-commands.js +39 -3
- package/dist/cli/commands/vault-commands.js +182 -0
- package/dist/cli/commands/write-commands.js +172 -12
- package/dist/cli/main.js +2 -0
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +1 -0
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +347 -21
- package/dist/domain/markdown.js +53 -9
- package/dist/infrastructure/config.js +105 -6
- package/dist/infrastructure/context-packs.js +122 -0
- package/dist/infrastructure/file-index.js +6 -3
- package/dist/infrastructure/file-system-vault.js +21 -1
- package/dist/infrastructure/index-state.js +2 -0
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/http-server.js +97 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +41 -13
- package/dist/mcp/tools.js +226 -14
- package/docs/AGENT_USAGE.md +60 -5
- package/docs/ARCHITECTURE.md +11 -0
- package/docs/QUICKSTART.md +3 -1
- package/docs/RELEASE.md +4 -3
- package/package.json +3 -1
|
@@ -10,8 +10,11 @@ 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,
|
|
16
|
+
defaultContextStrategy: 'rag',
|
|
17
|
+
defaultContextCacheTtlMs: 120_000,
|
|
15
18
|
embeddingProvider: 'local',
|
|
16
19
|
defaultSearchMode: 'hybrid',
|
|
17
20
|
chunkSize: 1200,
|
|
@@ -40,8 +43,10 @@ const safeCwd = () => {
|
|
|
40
43
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
44
|
const embeddingProviders = new Set(['none', 'local']);
|
|
42
45
|
const searchModes = new Set(['fts', 'semantic', 'hybrid']);
|
|
46
|
+
const contextStrategies = new Set(['rag', 'cag', 'auto']);
|
|
43
47
|
const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
|
|
44
48
|
export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
|
|
49
|
+
export const sanitizeContextStrategy = (value, fallback = 'rag') => typeof value === 'string' && contextStrategies.has(value) ? value : fallback;
|
|
45
50
|
const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
|
46
51
|
const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
47
52
|
const sanitizeIntegerInRange = (value, fallback, minimum, maximum) => {
|
|
@@ -83,10 +88,20 @@ const sanitizeAgentProfile = (value) => {
|
|
|
83
88
|
const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
|
|
84
89
|
? value.defaultSearchMode
|
|
85
90
|
: undefined;
|
|
91
|
+
const defaultContextStrategy = typeof value.defaultContextStrategy === 'string' && contextStrategies.has(value.defaultContextStrategy)
|
|
92
|
+
? value.defaultContextStrategy
|
|
93
|
+
: undefined;
|
|
94
|
+
const defaultContextCacheTtlMs = typeof value.defaultContextCacheTtlMs === 'number' &&
|
|
95
|
+
Number.isFinite(value.defaultContextCacheTtlMs) &&
|
|
96
|
+
value.defaultContextCacheTtlMs > 0
|
|
97
|
+
? Math.floor(value.defaultContextCacheTtlMs)
|
|
98
|
+
: undefined;
|
|
86
99
|
const profile = {
|
|
87
100
|
...(defaultSearchLimit ? { defaultSearchLimit } : {}),
|
|
88
101
|
...(defaultContextTokens ? { defaultContextTokens } : {}),
|
|
89
|
-
...(defaultSearchMode ? { defaultSearchMode } : {})
|
|
102
|
+
...(defaultSearchMode ? { defaultSearchMode } : {}),
|
|
103
|
+
...(defaultContextStrategy ? { defaultContextStrategy } : {}),
|
|
104
|
+
...(defaultContextCacheTtlMs ? { defaultContextCacheTtlMs } : {})
|
|
90
105
|
};
|
|
91
106
|
return Object.keys(profile).length > 0 ? profile : null;
|
|
92
107
|
};
|
|
@@ -159,12 +174,19 @@ const sanitizeConfig = (value) => ({
|
|
|
159
174
|
? sanitizeAgentId(value.defaultAgent)
|
|
160
175
|
: defaultBrainlinkConfig.defaultAgent,
|
|
161
176
|
autoIndexOnWrite: typeof value.autoIndexOnWrite === 'boolean' ? value.autoIndexOnWrite : defaultBrainlinkConfig.autoIndexOnWrite,
|
|
177
|
+
autoCanonicalContextLinks: typeof value.autoCanonicalContextLinks === 'boolean'
|
|
178
|
+
? value.autoCanonicalContextLinks
|
|
179
|
+
: defaultBrainlinkConfig.autoCanonicalContextLinks,
|
|
162
180
|
defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
|
|
163
181
|
? value.defaultSearchLimit
|
|
164
182
|
: defaultBrainlinkConfig.defaultSearchLimit,
|
|
165
183
|
defaultContextTokens: typeof value.defaultContextTokens === 'number' && value.defaultContextTokens > 0
|
|
166
184
|
? value.defaultContextTokens
|
|
167
185
|
: defaultBrainlinkConfig.defaultContextTokens,
|
|
186
|
+
defaultContextStrategy: sanitizeContextStrategy(value.defaultContextStrategy, defaultBrainlinkConfig.defaultContextStrategy),
|
|
187
|
+
defaultContextCacheTtlMs: typeof value.defaultContextCacheTtlMs === 'number' && Number.isFinite(value.defaultContextCacheTtlMs) && value.defaultContextCacheTtlMs > 0
|
|
188
|
+
? Math.floor(value.defaultContextCacheTtlMs)
|
|
189
|
+
: defaultBrainlinkConfig.defaultContextCacheTtlMs,
|
|
168
190
|
allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
|
|
169
191
|
chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
|
|
170
192
|
searchPack: sanitizeSearchPackConfig(value.searchPack),
|
|
@@ -178,15 +200,92 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
|
178
200
|
return {
|
|
179
201
|
defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
|
|
180
202
|
defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
|
|
181
|
-
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
|
|
203
|
+
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode,
|
|
204
|
+
defaultContextStrategy: profile?.defaultContextStrategy ?? config.defaultContextStrategy,
|
|
205
|
+
defaultContextCacheTtlMs: profile?.defaultContextCacheTtlMs ?? config.defaultContextCacheTtlMs
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
const mergeConfigLayers = (layers) => layers.reduce((state, config) => ({
|
|
209
|
+
...state,
|
|
210
|
+
...config
|
|
211
|
+
}), {});
|
|
212
|
+
export const getVaultConfigSourceDetails = async (cwd = safeCwd()) => {
|
|
213
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
214
|
+
loadRawConfig('global', cwd),
|
|
215
|
+
loadRawConfig('local', cwd),
|
|
216
|
+
loadLegacyLocalRawConfig(cwd)
|
|
217
|
+
]);
|
|
218
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
219
|
+
return {
|
|
220
|
+
source: 'local-legacy',
|
|
221
|
+
sourcePath: resolve(cwd, '.brainlink.json')
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
225
|
+
return {
|
|
226
|
+
source: 'local',
|
|
227
|
+
sourcePath: getLocalConfigPath(cwd)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
231
|
+
return {
|
|
232
|
+
source: 'global',
|
|
233
|
+
sourcePath: getGlobalConfigPath()
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
source: 'default',
|
|
238
|
+
sourcePath: null
|
|
182
239
|
};
|
|
183
240
|
};
|
|
184
241
|
export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
|
|
185
242
|
const globalConfig = await readJsonConfig(getGlobalConfigPath());
|
|
186
243
|
const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
|
|
187
|
-
const merged = [globalConfig, ...localConfigs]
|
|
188
|
-
...state,
|
|
189
|
-
...config
|
|
190
|
-
}), {});
|
|
244
|
+
const merged = mergeConfigLayers([globalConfig, ...localConfigs]);
|
|
191
245
|
return sanitizeConfig(merged);
|
|
192
246
|
};
|
|
247
|
+
export const loadBrainlinkConfigWithSource = async (cwd = safeCwd()) => {
|
|
248
|
+
const globalConfigPath = getGlobalConfigPath();
|
|
249
|
+
const localConfigPath = getLocalConfigPath(cwd);
|
|
250
|
+
const legacyLocalConfigPath = resolve(cwd, '.brainlink.json');
|
|
251
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
252
|
+
readJsonConfig(globalConfigPath),
|
|
253
|
+
readJsonConfig(localConfigPath),
|
|
254
|
+
readJsonConfig(legacyLocalConfigPath)
|
|
255
|
+
]);
|
|
256
|
+
const config = sanitizeConfig(mergeConfigLayers([globalConfig, localConfig, legacyLocalConfig]));
|
|
257
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
258
|
+
return {
|
|
259
|
+
config,
|
|
260
|
+
vaultSource: {
|
|
261
|
+
source: 'local-legacy',
|
|
262
|
+
sourcePath: legacyLocalConfigPath
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
267
|
+
return {
|
|
268
|
+
config,
|
|
269
|
+
vaultSource: {
|
|
270
|
+
source: 'local',
|
|
271
|
+
sourcePath: localConfigPath
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
276
|
+
return {
|
|
277
|
+
config,
|
|
278
|
+
vaultSource: {
|
|
279
|
+
source: 'global',
|
|
280
|
+
sourcePath: globalConfigPath
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
config,
|
|
286
|
+
vaultSource: {
|
|
287
|
+
source: 'default',
|
|
288
|
+
sourcePath: null
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
const normalizePackKey = (key) => ({
|
|
5
|
+
query: key.query.trim().toLowerCase(),
|
|
6
|
+
limit: key.limit,
|
|
7
|
+
maxTokens: key.maxTokens,
|
|
8
|
+
agentId: key.agentId?.trim().toLowerCase() || undefined,
|
|
9
|
+
mode: key.mode
|
|
10
|
+
});
|
|
11
|
+
export const contextPacksDirectory = (vaultPath) => join(vaultPath, '.brainlink', 'context-packs');
|
|
12
|
+
export const contextPackPath = (vaultPath, key) => {
|
|
13
|
+
const digest = createHash('sha256').update(JSON.stringify(normalizePackKey(key))).digest('hex');
|
|
14
|
+
return join(contextPacksDirectory(vaultPath), `${digest}.json`);
|
|
15
|
+
};
|
|
16
|
+
const isStoredContextPack = (value) => {
|
|
17
|
+
if (!value || typeof value !== 'object') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const candidate = value;
|
|
21
|
+
return candidate.version === 1 && typeof candidate.dataSignature === 'string' && Boolean(candidate.context);
|
|
22
|
+
};
|
|
23
|
+
const readStoredContextPack = async (path) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
26
|
+
return isStoredContextPack(parsed) ? parsed : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const toContextPackSummary = async (path, dataSignature) => {
|
|
33
|
+
const [info, stored] = await Promise.all([
|
|
34
|
+
stat(path),
|
|
35
|
+
readStoredContextPack(path)
|
|
36
|
+
]);
|
|
37
|
+
const stale = !stored || (typeof dataSignature === 'string' && stored.dataSignature !== dataSignature);
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
filename: basename(path),
|
|
41
|
+
createdAt: stored?.createdAt ?? null,
|
|
42
|
+
dataSignature: stored?.dataSignature ?? null,
|
|
43
|
+
key: stored?.key ?? null,
|
|
44
|
+
sizeBytes: info.size,
|
|
45
|
+
stale
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export const listContextPacks = async (vaultPath, dataSignature) => {
|
|
49
|
+
const directory = contextPacksDirectory(vaultPath);
|
|
50
|
+
try {
|
|
51
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
52
|
+
const paths = entries
|
|
53
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
54
|
+
.map((entry) => join(directory, entry.name));
|
|
55
|
+
const summaries = await Promise.all(paths.map((path) => toContextPackSummary(path, dataSignature)));
|
|
56
|
+
return summaries.sort((left, right) => (right.createdAt ?? '').localeCompare(left.createdAt ?? '') || left.filename.localeCompare(right.filename));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
export const clearContextPacks = async (vaultPath, options = {}) => {
|
|
66
|
+
const packs = await listContextPacks(vaultPath, options.dataSignature);
|
|
67
|
+
const removed = options.staleOnly ? packs.filter((pack) => pack.stale) : packs;
|
|
68
|
+
const kept = options.staleOnly ? packs.filter((pack) => !pack.stale) : [];
|
|
69
|
+
await Promise.all(removed.map((pack) => rm(pack.path, { force: true })));
|
|
70
|
+
return { removed, kept };
|
|
71
|
+
};
|
|
72
|
+
export const readContextPack = async (vaultPath, key, dataSignature) => {
|
|
73
|
+
const path = contextPackPath(vaultPath, key);
|
|
74
|
+
try {
|
|
75
|
+
const parsed = await readStoredContextPack(path);
|
|
76
|
+
if (!parsed) {
|
|
77
|
+
return { status: 'stale', path };
|
|
78
|
+
}
|
|
79
|
+
if (parsed.dataSignature !== dataSignature) {
|
|
80
|
+
return { status: 'stale', path };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
status: 'hit',
|
|
84
|
+
path,
|
|
85
|
+
context: {
|
|
86
|
+
...parsed.context,
|
|
87
|
+
strategy: 'cag',
|
|
88
|
+
cache: {
|
|
89
|
+
storage: 'context-pack',
|
|
90
|
+
status: 'hit',
|
|
91
|
+
dataSignature,
|
|
92
|
+
path
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { status: 'miss', path };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
export const writeContextPack = async (vaultPath, key, dataSignature, context) => {
|
|
102
|
+
const path = contextPackPath(vaultPath, key);
|
|
103
|
+
const stored = {
|
|
104
|
+
version: 1,
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
dataSignature,
|
|
107
|
+
key: normalizePackKey(key),
|
|
108
|
+
context: {
|
|
109
|
+
...context,
|
|
110
|
+
strategy: 'cag',
|
|
111
|
+
cache: {
|
|
112
|
+
storage: 'context-pack',
|
|
113
|
+
status: 'refresh',
|
|
114
|
+
dataSignature,
|
|
115
|
+
path
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
await mkdir(contextPacksDirectory(vaultPath), { recursive: true });
|
|
120
|
+
await writeFile(path, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
|
|
121
|
+
return path;
|
|
122
|
+
};
|
|
@@ -266,7 +266,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
266
266
|
title: document.title,
|
|
267
267
|
path: document.path,
|
|
268
268
|
content: document.content,
|
|
269
|
-
tags: document.tags
|
|
269
|
+
tags: document.tags,
|
|
270
|
+
contextLinks: document.contextLinks ?? []
|
|
270
271
|
})),
|
|
271
272
|
edges
|
|
272
273
|
};
|
|
@@ -292,7 +293,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
292
293
|
title: document.title,
|
|
293
294
|
path: document.path,
|
|
294
295
|
content: '',
|
|
295
|
-
tags: document.tags
|
|
296
|
+
tags: document.tags,
|
|
297
|
+
contextLinks: document.contextLinks ?? []
|
|
296
298
|
})),
|
|
297
299
|
edges
|
|
298
300
|
};
|
|
@@ -309,7 +311,8 @@ export const openFileIndex = (vaultPath) => {
|
|
|
309
311
|
title: document.title,
|
|
310
312
|
path: document.path,
|
|
311
313
|
content: document.content,
|
|
312
|
-
tags: document.tags
|
|
314
|
+
tags: document.tags,
|
|
315
|
+
contextLinks: document.contextLinks ?? []
|
|
313
316
|
}
|
|
314
317
|
: undefined;
|
|
315
318
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { resolvePath } from './paths.js';
|
|
4
4
|
import { getBucketVaultCachePath, isBucketVaultUri, parseBucketVaultUri, syncBucketVaultToCache, writeBucketMarkdownFile } from './bucket-vault.js';
|
|
@@ -109,3 +109,23 @@ export const writeMarkdownFile = async (vaultPath, filename, content) => {
|
|
|
109
109
|
await chmod(absolutePath, fileMode);
|
|
110
110
|
return absolutePath;
|
|
111
111
|
};
|
|
112
|
+
export const deleteMarkdownFile = async (vaultPath, filename) => {
|
|
113
|
+
if (isBucketVaultUri(vaultPath)) {
|
|
114
|
+
throw new Error('Deleting bucket vault notes is not supported from Brainlink yet. Remove bucket objects with your storage provider tooling.');
|
|
115
|
+
}
|
|
116
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
117
|
+
const absolutePath = isAbsolute(filename) ? resolve(filename) : resolve(absoluteVaultPath, filename);
|
|
118
|
+
if (!isPathInside(absoluteVaultPath, absolutePath)) {
|
|
119
|
+
throw new Error(`Refusing to delete outside vault: ${absolutePath}`);
|
|
120
|
+
}
|
|
121
|
+
if (extname(absolutePath).toLowerCase() !== '.md') {
|
|
122
|
+
throw new Error(`Refusing to delete non-Markdown vault item: ${absolutePath}`);
|
|
123
|
+
}
|
|
124
|
+
const relativePath = relative(absoluteVaultPath, absolutePath);
|
|
125
|
+
const segments = relativePath.split(/[\\/]+/);
|
|
126
|
+
if (segments.some((segment) => excludedDirectories.has(segment))) {
|
|
127
|
+
throw new Error(`Refusing to delete protected vault path: ${relativePath}`);
|
|
128
|
+
}
|
|
129
|
+
await rm(absolutePath, { force: false });
|
|
130
|
+
return absolutePath;
|
|
131
|
+
};
|
|
@@ -29,6 +29,7 @@ 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
|
+
graphLinkModelVersion: typeof parsed.graphLinkModelVersion === 'number' ? parsed.graphLinkModelVersion : 1,
|
|
32
33
|
searchPackRowChunkSize: typeof parsed.searchPackRowChunkSize === 'number' ? parsed.searchPackRowChunkSize : 5_000,
|
|
33
34
|
searchPackCompressionLevel: typeof parsed.searchPackCompressionLevel === 'number' ? parsed.searchPackCompressionLevel : 5,
|
|
34
35
|
searchPackUseDictionary: typeof parsed.searchPackUseDictionary === 'boolean' ? parsed.searchPackUseDictionary : true,
|
|
@@ -46,6 +47,7 @@ export const writeIndexState = async (vaultPath, state) => {
|
|
|
46
47
|
updatedAt: new Date().toISOString(),
|
|
47
48
|
chunkSize: state.chunkSize,
|
|
48
49
|
embeddingProvider: state.embeddingProvider,
|
|
50
|
+
graphLinkModelVersion: state.graphLinkModelVersion,
|
|
49
51
|
searchPackRowChunkSize: state.searchPackRowChunkSize,
|
|
50
52
|
searchPackCompressionLevel: state.searchPackCompressionLevel,
|
|
51
53
|
searchPackUseDictionary: state.searchPackUseDictionary,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getBrainlinkHomePath } from './paths.js';
|
|
4
|
+
const defaultState = {
|
|
5
|
+
byConfigKey: {}
|
|
6
|
+
};
|
|
7
|
+
const statePath = () => join(getBrainlinkHomePath(), 'vault-migration-state.json');
|
|
8
|
+
const sanitizeState = (value) => {
|
|
9
|
+
if (typeof value !== 'object' || value === null) {
|
|
10
|
+
return defaultState;
|
|
11
|
+
}
|
|
12
|
+
const record = value;
|
|
13
|
+
const byConfigKeyRecord = typeof record.byConfigKey === 'object' && record.byConfigKey !== null ? record.byConfigKey : {};
|
|
14
|
+
const byConfigKey = Object.entries(byConfigKeyRecord).reduce((state, [key, vault]) => {
|
|
15
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
if (typeof vault !== 'string' || vault.trim().length === 0) {
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
[key]: vault.trim()
|
|
24
|
+
};
|
|
25
|
+
}, {});
|
|
26
|
+
return {
|
|
27
|
+
byConfigKey
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const readState = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(statePath(), 'utf8');
|
|
33
|
+
return sanitizeState(JSON.parse(raw));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
37
|
+
return defaultState;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const writeState = async (state) => {
|
|
43
|
+
const path = statePath();
|
|
44
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
46
|
+
};
|
|
47
|
+
export const getVaultMigrationStatePath = () => statePath();
|
|
48
|
+
export const getLastConfiguredVaultForKey = async (configKey) => {
|
|
49
|
+
const state = await readState();
|
|
50
|
+
const value = state.byConfigKey[configKey];
|
|
51
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
52
|
+
};
|
|
53
|
+
export const setLastConfiguredVaultForKey = async (configKey, vault) => {
|
|
54
|
+
const key = configKey.trim();
|
|
55
|
+
const value = vault.trim();
|
|
56
|
+
if (key.length === 0 || value.length === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const state = await readState();
|
|
60
|
+
if (state.byConfigKey[key] === value) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await writeState({
|
|
64
|
+
byConfigKey: {
|
|
65
|
+
...state.byConfigKey,
|
|
66
|
+
[key]: value
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export const volatileMemoryStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'volatile.json');
|
|
4
|
+
const emptyStore = () => ({
|
|
5
|
+
version: 1,
|
|
6
|
+
entries: []
|
|
7
|
+
});
|
|
8
|
+
const normalizeToken = (value) => value
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
const tokens = (value) => value
|
|
13
|
+
.match(/[\p{L}\p{N}_-]+/gu)
|
|
14
|
+
?.map(normalizeToken)
|
|
15
|
+
.filter((token) => token.length > 1) ?? [];
|
|
16
|
+
const readStore = async (vaultPath) => {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(await readFile(volatileMemoryStoragePath(vaultPath), 'utf8'));
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
entries: Array.isArray(parsed.entries) ? parsed.entries.filter(isEntry) : []
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return emptyStore();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const writeStore = async (vaultPath, store) => {
|
|
29
|
+
const target = volatileMemoryStoragePath(vaultPath);
|
|
30
|
+
const temp = `${target}.tmp`;
|
|
31
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
32
|
+
await writeFile(temp, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
33
|
+
await rename(temp, target);
|
|
34
|
+
};
|
|
35
|
+
const isEntry = (value) => {
|
|
36
|
+
const record = value;
|
|
37
|
+
return Boolean(record &&
|
|
38
|
+
typeof record.id === 'string' &&
|
|
39
|
+
typeof record.agentId === 'string' &&
|
|
40
|
+
typeof record.content === 'string' &&
|
|
41
|
+
typeof record.createdAt === 'string' &&
|
|
42
|
+
typeof record.expiresAt === 'string' &&
|
|
43
|
+
Array.isArray(record.tags));
|
|
44
|
+
};
|
|
45
|
+
const activeEntries = (entries, now = Date.now()) => entries.filter((entry) => Date.parse(entry.expiresAt) > now);
|
|
46
|
+
const scoreEntry = (entry, query) => {
|
|
47
|
+
const queryTokens = tokens(query);
|
|
48
|
+
if (queryTokens.length === 0) {
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const haystack = normalizeToken([entry.content, entry.tags.join(' ')].join(' '));
|
|
52
|
+
return queryTokens.reduce((score, token) => score + (haystack.includes(token) ? 1 : 0), 0);
|
|
53
|
+
};
|
|
54
|
+
export const addVolatileMemory = async (vaultPath, content, agentId = 'shared', ttlMinutes = 240, tags = []) => {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const entry = {
|
|
57
|
+
id: `volatile-${now.getTime()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
58
|
+
agentId,
|
|
59
|
+
content,
|
|
60
|
+
createdAt: now.toISOString(),
|
|
61
|
+
expiresAt: new Date(now.getTime() + Math.max(1, ttlMinutes) * 60_000).toISOString(),
|
|
62
|
+
source: 'agent',
|
|
63
|
+
tags
|
|
64
|
+
};
|
|
65
|
+
const store = await readStore(vaultPath);
|
|
66
|
+
await writeStore(vaultPath, {
|
|
67
|
+
version: 1,
|
|
68
|
+
entries: [...activeEntries(store.entries), entry]
|
|
69
|
+
});
|
|
70
|
+
return entry;
|
|
71
|
+
};
|
|
72
|
+
export const searchVolatileMemory = async (vaultPath, query, limit, agentId, mode = 'hybrid') => {
|
|
73
|
+
const store = await readStore(vaultPath);
|
|
74
|
+
const entries = activeEntries(store.entries)
|
|
75
|
+
.filter((entry) => (agentId ? entry.agentId === agentId : true))
|
|
76
|
+
.map((entry) => ({ entry, score: scoreEntry(entry, query) }))
|
|
77
|
+
.filter(({ score }) => score > 0)
|
|
78
|
+
.sort((left, right) => right.score - left.score || right.entry.createdAt.localeCompare(left.entry.createdAt))
|
|
79
|
+
.slice(0, Math.max(0, limit));
|
|
80
|
+
if (entries.length !== store.entries.length) {
|
|
81
|
+
await writeStore(vaultPath, { version: 1, entries: activeEntries(store.entries) });
|
|
82
|
+
}
|
|
83
|
+
return entries.map(({ entry, score }) => ({
|
|
84
|
+
title: 'Volatile Memory',
|
|
85
|
+
path: `volatile://${entry.id}`,
|
|
86
|
+
content: entry.content,
|
|
87
|
+
score,
|
|
88
|
+
searchMode: mode,
|
|
89
|
+
tags: ['volatile', ...entry.tags],
|
|
90
|
+
volatile: true,
|
|
91
|
+
expiresAt: entry.expiresAt
|
|
92
|
+
}));
|
|
93
|
+
};
|
|
94
|
+
export const clearVolatileMemory = async (vaultPath, agentId) => {
|
|
95
|
+
const store = await readStore(vaultPath);
|
|
96
|
+
const active = activeEntries(store.entries);
|
|
97
|
+
const kept = agentId ? active.filter((entry) => entry.agentId !== agentId) : [];
|
|
98
|
+
await writeStore(vaultPath, { version: 1, entries: kept });
|
|
99
|
+
return active.length - kept.length;
|
|
100
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { indexVault } from '../application/index-vault.js';
|
|
4
|
+
import { createBrainlinkMcpServer } from './server.js';
|
|
5
|
+
import { runStartupBootstrap } from './startup.js';
|
|
6
|
+
const normalizePath = (path) => {
|
|
7
|
+
const trimmed = path.trim();
|
|
8
|
+
if (trimmed.length === 0) {
|
|
9
|
+
return '/mcp';
|
|
10
|
+
}
|
|
11
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
12
|
+
};
|
|
13
|
+
const writeJson = (response, statusCode, value) => {
|
|
14
|
+
response.writeHead(statusCode, {
|
|
15
|
+
'content-type': 'application/json; charset=utf-8',
|
|
16
|
+
'cache-control': 'no-store'
|
|
17
|
+
});
|
|
18
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
19
|
+
};
|
|
20
|
+
const readBearerToken = (authorization) => {
|
|
21
|
+
const value = Array.isArray(authorization) ? authorization[0] : authorization;
|
|
22
|
+
const match = value?.match(/^Bearer\s+(.+)$/i);
|
|
23
|
+
return match?.[1]?.trim();
|
|
24
|
+
};
|
|
25
|
+
const isAuthorized = (expectedToken, authorization) => expectedToken === undefined || readBearerToken(authorization) === expectedToken;
|
|
26
|
+
export const startRemoteMcpServer = async (input) => {
|
|
27
|
+
const mcpPath = normalizePath(input.path);
|
|
28
|
+
const startup = await runStartupBootstrap();
|
|
29
|
+
if (input.shouldIndex) {
|
|
30
|
+
await indexVault(input.vaultPath);
|
|
31
|
+
}
|
|
32
|
+
if (startup.error) {
|
|
33
|
+
console.error(`Brainlink MCP startup bootstrap warning: ${startup.error}`);
|
|
34
|
+
}
|
|
35
|
+
const server = createServer(async (request, response) => {
|
|
36
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
|
|
37
|
+
if (url.pathname === '/healthz') {
|
|
38
|
+
writeJson(response, 200, { ok: true });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (url.pathname === '/readyz') {
|
|
42
|
+
writeJson(response, startup.error ? 503 : 200, {
|
|
43
|
+
ok: !startup.error,
|
|
44
|
+
vault: input.vaultPath,
|
|
45
|
+
agent: input.agent ?? startup.agent ?? '*',
|
|
46
|
+
...(startup.error ? { error: startup.error } : {})
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname !== mcpPath) {
|
|
51
|
+
writeJson(response, 404, { error: 'Not found' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!isAuthorized(input.token, request.headers.authorization)) {
|
|
55
|
+
writeJson(response, 401, { error: 'Unauthorized' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const mcpServer = createBrainlinkMcpServer();
|
|
60
|
+
const transport = new StreamableHTTPServerTransport({
|
|
61
|
+
sessionIdGenerator: undefined,
|
|
62
|
+
enableJsonResponse: true
|
|
63
|
+
});
|
|
64
|
+
await mcpServer.connect(transport);
|
|
65
|
+
await transport.handleRequest(request, response);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
writeJson(response, 500, { error: message });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
server.once('error', reject);
|
|
74
|
+
server.listen(input.port, input.host, () => {
|
|
75
|
+
server.off('error', reject);
|
|
76
|
+
resolve();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
const address = server.address();
|
|
80
|
+
const port = typeof address === 'object' && address ? address.port : input.port;
|
|
81
|
+
const publicHost = input.host === '0.0.0.0' ? '127.0.0.1' : input.host;
|
|
82
|
+
const baseUrl = `http://${publicHost}:${port}`;
|
|
83
|
+
return {
|
|
84
|
+
url: `${baseUrl}${mcpPath}`,
|
|
85
|
+
healthUrl: `${baseUrl}/healthz`,
|
|
86
|
+
readyUrl: `${baseUrl}/readyz`,
|
|
87
|
+
close: () => new Promise((resolve, reject) => {
|
|
88
|
+
server.close((error) => {
|
|
89
|
+
if (error) {
|
|
90
|
+
reject(error);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
})
|
|
96
|
+
};
|
|
97
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
let cachedRuntimeMetadata = null;
|
|
5
|
+
const readPackageMetadata = () => {
|
|
6
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
7
|
+
return JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
8
|
+
};
|
|
9
|
+
export const getRuntimeMetadata = () => {
|
|
10
|
+
if (cachedRuntimeMetadata) {
|
|
11
|
+
return cachedRuntimeMetadata;
|
|
12
|
+
}
|
|
13
|
+
const metadata = readPackageMetadata();
|
|
14
|
+
cachedRuntimeMetadata = {
|
|
15
|
+
name: metadata.name ?? 'brainlink',
|
|
16
|
+
version: metadata.version ?? '0.0.0'
|
|
17
|
+
};
|
|
18
|
+
return cachedRuntimeMetadata;
|
|
19
|
+
};
|
|
20
|
+
export const getRuntimeVersion = () => getRuntimeMetadata().version;
|