@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.
Files changed (48) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +198 -38
  4. package/dist/application/add-note.js +13 -44
  5. package/dist/application/analyze-vault.js +1 -1
  6. package/dist/application/auto-migrate-configured-vault.js +37 -0
  7. package/dist/application/build-context.js +119 -20
  8. package/dist/application/canonical-context-links.js +209 -0
  9. package/dist/application/delete-note.js +80 -0
  10. package/dist/application/frontend/client-css.js +212 -42
  11. package/dist/application/frontend/client-html.js +42 -28
  12. package/dist/application/frontend/client-js.js +1294 -3222
  13. package/dist/application/frontend/client-render-worker-js.js +676 -0
  14. package/dist/application/get-graph-contexts.js +33 -0
  15. package/dist/application/get-graph-layout.js +62 -8
  16. package/dist/application/get-graph-stream-chunk.js +326 -0
  17. package/dist/application/get-graph-view.js +246 -0
  18. package/dist/application/graph-view-state.js +66 -0
  19. package/dist/application/import-legacy-sqlite.js +3 -33
  20. package/dist/application/index-vault.js +35 -22
  21. package/dist/application/migrate-context-links.js +79 -0
  22. package/dist/application/search-graph-node-ids.js +63 -3
  23. package/dist/application/server/routes.js +197 -12
  24. package/dist/cli/commands/read-commands.js +39 -3
  25. package/dist/cli/commands/vault-commands.js +182 -0
  26. package/dist/cli/commands/write-commands.js +172 -12
  27. package/dist/cli/main.js +2 -0
  28. package/dist/cli/runtime.js +10 -2
  29. package/dist/domain/context.js +1 -0
  30. package/dist/domain/graph-contexts.js +180 -0
  31. package/dist/domain/graph-layout.js +347 -21
  32. package/dist/domain/markdown.js +53 -9
  33. package/dist/infrastructure/config.js +105 -6
  34. package/dist/infrastructure/context-packs.js +122 -0
  35. package/dist/infrastructure/file-index.js +6 -3
  36. package/dist/infrastructure/file-system-vault.js +21 -1
  37. package/dist/infrastructure/index-state.js +2 -0
  38. package/dist/infrastructure/vault-migration-state.js +69 -0
  39. package/dist/infrastructure/volatile-memory.js +100 -0
  40. package/dist/mcp/http-server.js +97 -0
  41. package/dist/mcp/runtime.js +20 -0
  42. package/dist/mcp/server.js +41 -13
  43. package/dist/mcp/tools.js +226 -14
  44. package/docs/AGENT_USAGE.md +60 -5
  45. package/docs/ARCHITECTURE.md +11 -0
  46. package/docs/QUICKSTART.md +3 -1
  47. package/docs/RELEASE.md +4 -3
  48. 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].reduce((state, config) => ({
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;