@andespindola/brainlink 0.1.0-beta.8 → 0.1.0-beta.9

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.
@@ -1,13 +1,18 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, relative, resolve } from 'node:path';
2
4
  import { addNote } from '../../application/add-note.js';
5
+ import { buildContextPackage } from '../../application/build-context.js';
3
6
  import { indexVault } from '../../application/index-vault.js';
4
- import { migrateVaultContent, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
7
+ import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
5
8
  import { startServer } from '../../application/start-server.js';
6
9
  import { startVaultWatcher } from '../../application/watch-vault.js';
7
- import { doctorVault } from '../../application/analyze-vault.js';
8
- import { defaultBrainlinkConfig } from '../../infrastructure/config.js';
10
+ import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
11
+ import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
9
12
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
10
13
  import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
14
+ import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
15
+ import { installAgentIntegration } from './agent-commands.js';
11
16
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
12
17
  const resolveAddContent = (options) => {
13
18
  if (options.content != null && options.content.trim().length > 0) {
@@ -43,6 +48,57 @@ export const registerWriteCommands = (program) => {
43
48
  return `Initialized Brainlink vault at ${path}.${migrated}`;
44
49
  });
45
50
  });
51
+ program
52
+ .command('migrate-vault')
53
+ .option('--from <vault>', 'source vault path')
54
+ .option('--to <vault>', 'target vault path')
55
+ .option('--dry-run', 'preview migration without writing files')
56
+ .option('--report <path>', 'write detailed per-file migration report to JSON file')
57
+ .option('--no-index', 'skip reindexing target vault after migration')
58
+ .option('--json', 'print machine-readable JSON')
59
+ .description('copy markdown memory from one vault to another with conflict preservation')
60
+ .action(async (options) => {
61
+ const config = await loadBrainlinkConfig();
62
+ const sourceVault = assertVaultAllowed(options.from ?? config.vault, config.allowedVaults);
63
+ const targetVault = assertVaultAllowed(options.to ?? defaultBrainlinkConfig.vault, config.allowedVaults);
64
+ const sourceRoot = await ensureVault(sourceVault);
65
+ const targetRoot = await ensureVault(targetVault);
66
+ const preview = await previewVaultMigration(sourceVault, targetVault);
67
+ const actions = await planVaultMigration(sourceRoot, targetRoot);
68
+ const reportEntries = actions.map((action) => ({
69
+ kind: action.kind,
70
+ sourcePath: action.sourcePath,
71
+ sourceRelativePath: relative(sourceRoot, action.sourcePath),
72
+ targetPath: action.targetPath,
73
+ targetRelativePath: relative(targetRoot, action.targetPath)
74
+ }));
75
+ const writeReport = async () => {
76
+ if (!options.report) {
77
+ return null;
78
+ }
79
+ const reportPath = resolve(options.report);
80
+ await mkdir(dirname(reportPath), { recursive: true });
81
+ await writeFile(reportPath, `${JSON.stringify({ source: sourceVault, target: targetVault, summary: preview, entries: reportEntries }, null, 2)}\n`, 'utf8');
82
+ return reportPath;
83
+ };
84
+ if (options.dryRun) {
85
+ const reportPath = await writeReport();
86
+ print(options.json, { dryRun: true, ...preview, entries: reportEntries, ...(reportPath ? { reportPath } : {}) }, () => `Dry run migration ${preview.source} -> ${preview.target}: copy=${preview.copied}, conflicts=${preview.conflicted}, unchanged=${preview.unchanged}${reportPath ? ` report=${reportPath}` : ''}`);
87
+ return;
88
+ }
89
+ const migration = await migrateVaultContent(sourceVault, targetVault);
90
+ const shouldIndex = options.index !== false && migration.copied + migration.conflicted > 0;
91
+ const index = shouldIndex ? await indexVault(targetVault) : undefined;
92
+ const reportPath = await writeReport();
93
+ print(options.json, { dryRun: false, ...migration, entries: reportEntries, ...(index ? { index } : {}), ...(reportPath ? { reportPath } : {}) }, () => {
94
+ const summary = `Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`;
95
+ const indexMessage = index
96
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
97
+ : '';
98
+ const reportMessage = reportPath ? ` Report written to ${reportPath}.` : '';
99
+ return `${summary}${indexMessage}${reportMessage}`;
100
+ });
101
+ });
46
102
  program
47
103
  .command('add')
48
104
  .argument('<title>', 'note title')
@@ -82,7 +138,13 @@ export const registerWriteCommands = (program) => {
82
138
  .action(async (options) => {
83
139
  const resolved = await resolveOptions(options);
84
140
  const report = await doctorVault(resolved.vault);
85
- print(options.json, report, () => report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n'));
141
+ print(options.json, report, () => {
142
+ const checks = report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n');
143
+ const recommendations = report.recommendations && report.recommendations.length > 0
144
+ ? `\n\nRecommended next steps:\n${report.recommendations.map((step) => `- ${step}`).join('\n')}`
145
+ : '';
146
+ return `${checks}${recommendations}`;
147
+ });
86
148
  process.exitCode = report.ok ? 0 : 1;
87
149
  });
88
150
  program
@@ -133,4 +195,95 @@ export const registerWriteCommands = (program) => {
133
195
  });
134
196
  print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
135
197
  });
198
+ program
199
+ .command('quickstart')
200
+ .option('-v, --vault <vault>', 'vault directory')
201
+ .option('-a, --agent <agent>', 'agent memory namespace')
202
+ .option('--query <query>', 'optional task query to return immediate grounded context')
203
+ .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
204
+ .option('--limit <limit>', 'maximum context sections')
205
+ .option('--tokens <tokens>', 'maximum context token budget')
206
+ .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
207
+ .option('--mcp-only', 'when installing agent integration, only configure MCP section')
208
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
209
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
210
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
211
+ .option('--json', 'print machine-readable JSON')
212
+ .description('run plug-and-play setup for vault, MCP integration and bootstrap readiness')
213
+ .action(async (options) => {
214
+ const resolved = await resolveOptions(options);
215
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
216
+ const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
217
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
218
+ const index = await indexVault(resolved.vault);
219
+ const stats = await getStats(resolved.vault, resolved.agent);
220
+ const validation = await validateVault(resolved.vault, resolved.agent);
221
+ const doctor = await doctorVault(resolved.vault);
222
+ const session = await touchBootstrapSession(resolved.vault, resolved.agent);
223
+ const policy = await getBootstrapPolicy();
224
+ const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
225
+ const context = options.query
226
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
227
+ : null;
228
+ const agentIntegration = options.installAgent === false
229
+ ? null
230
+ : await installAgentIntegration({
231
+ mcpOnly: options.mcpOnly,
232
+ pluginPath: options.pluginPath,
233
+ allowedVaults: options.allowedVaults,
234
+ brainlinkHome: options.brainlinkHome,
235
+ selfTest: true
236
+ });
237
+ const nextActions = stats.documentCount === 0
238
+ ? [
239
+ {
240
+ priority: 'required',
241
+ command: `blink add "Architecture" --vault "${resolved.vault}" --content "Durable memory with [[Links]] and #tags."`,
242
+ reason: 'Seed your vault with at least one durable Markdown note.'
243
+ },
244
+ {
245
+ priority: 'required',
246
+ command: `blink index --vault "${resolved.vault}"`,
247
+ reason: 'Rebuild index after adding notes so retrieval can find new memory.'
248
+ }
249
+ ]
250
+ : options.query
251
+ ? [
252
+ {
253
+ priority: 'recommended',
254
+ command: `blink add "Task Update" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --content "<durable memory>"`,
255
+ reason: 'Persist important findings as Markdown notes after using the returned context.'
256
+ }
257
+ ]
258
+ : [
259
+ {
260
+ priority: 'recommended',
261
+ command: `blink context "<task>" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --mode ${mode}`,
262
+ reason: 'Retrieve grounded context for each task before responding.'
263
+ }
264
+ ];
265
+ print(options.json, {
266
+ vault: resolved.vault,
267
+ agent: resolved.agent ?? 'shared',
268
+ mode,
269
+ index,
270
+ stats,
271
+ validation,
272
+ doctor,
273
+ policy,
274
+ bootstrapStatus,
275
+ session,
276
+ context,
277
+ agentIntegration,
278
+ nextActions
279
+ }, () => [
280
+ `quickstart vault=${resolved.vault}`,
281
+ `agent=${resolved.agent ?? 'shared'}`,
282
+ `documents=${stats.documentCount}`,
283
+ `links=${stats.linkCount}`,
284
+ `bootstrapReady=${bootstrapStatus.ready}`,
285
+ ...(agentIntegration?.selfTest ? [`agentIntegrationSelfTest=${agentIntegration.selfTest.ok}`] : []),
286
+ ...(nextActions.length > 0 ? ['Next actions:', ...nextActions.map((step) => `- ${step.command}`)] : [])
287
+ ].join('\n'));
288
+ });
136
289
  };
package/dist/cli/main.js CHANGED
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { registerAgentCommands } from './commands/agent-commands.js';
7
+ import { registerConfigCommands } from './commands/config-commands.js';
6
8
  import { registerReadCommands } from './commands/read-commands.js';
7
9
  import { registerWriteCommands } from './commands/write-commands.js';
8
10
  const readPackageVersion = () => {
@@ -21,6 +23,8 @@ program
21
23
  .version(readPackageVersion());
22
24
  registerWriteCommands(program);
23
25
  registerReadCommands(program);
26
+ registerConfigCommands(program);
27
+ registerAgentCommands(program);
24
28
  program.parseAsync().catch((error) => {
25
29
  const message = error instanceof Error ? error.message : String(error);
26
30
  console.error(message);
@@ -1,4 +1,4 @@
1
- import { loadBrainlinkConfig } from '../infrastructure/config.js';
1
+ import { loadBrainlinkConfig, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
2
2
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
3
3
  export const parsePositiveInteger = (value, fallback) => {
4
4
  const parsed = Number.parseInt(value, 10);
@@ -8,10 +8,13 @@ export const resolveOptions = async (options) => {
8
8
  const config = await loadBrainlinkConfig();
9
9
  const vault = options.vault ?? config.vault;
10
10
  const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
11
+ const agent = options.agent ?? config.defaultAgent;
12
+ const defaults = resolveAgentRuntimeDefaults(config, agent);
11
13
  return {
12
14
  config,
13
15
  vault: allowedVault,
14
- agent: options.agent ?? config.defaultAgent
16
+ agent,
17
+ defaults
15
18
  };
16
19
  };
17
20
  export const print = (json, value, human) => {
@@ -77,11 +77,13 @@ export const extractWikiLinkReferences = (content) => visibleMarkdownLines(conte
77
77
  }));
78
78
  });
79
79
  const priorityFromWeight = (weight) => weight >= 8 ? 'critical' : weight >= 4 ? 'high' : 'normal';
80
+ const normalizeAccumulatedWeight = (weight) => Math.max(1, Math.min(12, weight));
80
81
  export const extractWikiLinkWeights = (content) => {
81
82
  const weights = extractWikiLinkReferences(content).reduce((state, reference) => {
82
83
  const titleKey = reference.title.toLowerCase();
83
84
  const current = state.get(titleKey);
84
- const weight = (current?.weight ?? 0) + reference.weight;
85
+ const rawWeight = (current?.weight ?? 0) + reference.weight;
86
+ const weight = normalizeAccumulatedWeight(rawWeight);
85
87
  const explicitPriority = reference.priority
86
88
  ? maxPriority(current?.priority ?? reference.priority, reference.priority)
87
89
  : current?.priority;
@@ -116,10 +118,38 @@ const normalizeChunkContent = (content) => content
116
118
  .join('\n')
117
119
  .replace(/\n{3,}/g, '\n\n')
118
120
  .trim();
121
+ const splitLongParagraph = (paragraph, maxCharacters) => {
122
+ if (paragraph.length <= maxCharacters) {
123
+ return [paragraph];
124
+ }
125
+ const sentences = paragraph
126
+ .split(/(?<=[.!?])\s+/)
127
+ .map((sentence) => sentence.trim())
128
+ .filter(Boolean);
129
+ if (sentences.length <= 1) {
130
+ const chunks = [];
131
+ for (let index = 0; index < paragraph.length; index += maxCharacters) {
132
+ chunks.push(paragraph.slice(index, index + maxCharacters).trim());
133
+ }
134
+ return chunks.filter(Boolean);
135
+ }
136
+ return sentences.reduce((state, sentence) => {
137
+ const last = state.at(-1);
138
+ if (!last) {
139
+ return [sentence];
140
+ }
141
+ const merged = `${last} ${sentence}`;
142
+ if (merged.length <= maxCharacters) {
143
+ return [...state.slice(0, -1), merged];
144
+ }
145
+ return [...state, sentence];
146
+ }, []);
147
+ };
119
148
  export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
120
149
  const paragraphs = normalizeChunkContent(stripFrontmatter(content))
121
150
  .split(/\n{2,}/)
122
- .filter(Boolean);
151
+ .filter(Boolean)
152
+ .flatMap((paragraph) => splitLongParagraph(paragraph, maxCharacters));
123
153
  const chunks = paragraphs.reduce((state, paragraph) => {
124
154
  const lastChunk = state.at(-1);
125
155
  if (!lastChunk) {
@@ -162,13 +192,15 @@ export const parseMarkdownDocument = (input) => {
162
192
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
163
193
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
164
194
  const linkWeights = new Map(extractWikiLinkWeights(document.content).map((link) => [link.title.toLowerCase(), link]));
165
- const links = document.links.map((toTitle) => ({
195
+ const links = document.links
196
+ .map((toTitle) => ({
166
197
  fromDocumentId: document.id,
167
198
  toTitle,
168
199
  toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null,
169
200
  weight: linkWeights.get(toTitle.toLowerCase())?.weight ?? 1,
170
201
  priority: linkWeights.get(toTitle.toLowerCase())?.priority ?? 'normal'
171
- }));
202
+ }))
203
+ .filter((link) => link.toDocumentId !== document.id);
172
204
  return {
173
205
  document,
174
206
  chunks,
@@ -1,7 +1,8 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import { sanitizeAgentId } from '../domain/agents.js';
4
- import { getDefaultVaultPath } from './paths.js';
5
+ import { getBrainlinkHomePath, getDefaultVaultPath } from './paths.js';
5
6
  export const defaultBrainlinkConfig = {
6
7
  vault: getDefaultVaultPath(),
7
8
  host: '127.0.0.1',
@@ -13,15 +14,61 @@ export const defaultBrainlinkConfig = {
13
14
  defaultContextTokens: 2000,
14
15
  embeddingProvider: 'local',
15
16
  defaultSearchMode: 'hybrid',
16
- chunkSize: 1200
17
+ chunkSize: 1200,
18
+ agentProfiles: {}
17
19
  };
18
20
  const configFilenames = ['brainlink.config.json', '.brainlink.json'];
21
+ const localConfigFilename = 'brainlink.config.json';
22
+ const globalConfigFilename = 'brainlink.config.json';
23
+ const globalConfigDirectoryMode = 0o700;
24
+ const globalConfigFileMode = 0o600;
25
+ const safeCwd = () => {
26
+ try {
27
+ return process.cwd();
28
+ }
29
+ catch {
30
+ return homedir();
31
+ }
32
+ };
19
33
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
34
  const embeddingProviders = new Set(['none', 'local']);
21
35
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
22
36
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
23
37
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
24
38
  const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
39
+ const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
40
+ const sanitizeAgentProfile = (value) => {
41
+ if (!isRecord(value)) {
42
+ return null;
43
+ }
44
+ const defaultSearchLimit = sanitizePositiveNumber(value.defaultSearchLimit);
45
+ const defaultContextTokens = sanitizePositiveNumber(value.defaultContextTokens);
46
+ const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
47
+ ? value.defaultSearchMode
48
+ : undefined;
49
+ const profile = {
50
+ ...(defaultSearchLimit ? { defaultSearchLimit } : {}),
51
+ ...(defaultContextTokens ? { defaultContextTokens } : {}),
52
+ ...(defaultSearchMode ? { defaultSearchMode } : {})
53
+ };
54
+ return Object.keys(profile).length > 0 ? profile : null;
55
+ };
56
+ const sanitizeAgentProfiles = (value) => {
57
+ if (!isRecord(value)) {
58
+ return {};
59
+ }
60
+ return Object.entries(value).reduce((state, [key, profile]) => {
61
+ const normalizedKey = key === '*' ? '*' : sanitizeAgentId(key);
62
+ const sanitizedProfile = sanitizeAgentProfile(profile);
63
+ if (!sanitizedProfile || normalizedKey.length === 0) {
64
+ return state;
65
+ }
66
+ return {
67
+ ...state,
68
+ [normalizedKey]: sanitizedProfile
69
+ };
70
+ }, {});
71
+ };
25
72
  const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
26
73
  .split(',')
27
74
  .map((value) => value.trim())
@@ -39,6 +86,34 @@ const readJsonConfig = async (path) => {
39
86
  throw error;
40
87
  }
41
88
  };
89
+ export const getGlobalConfigPath = () => join(getBrainlinkHomePath(), globalConfigFilename);
90
+ export const getLocalConfigPath = (cwd = safeCwd()) => resolve(cwd, localConfigFilename);
91
+ export const resolveConfigPath = (scope, cwd = safeCwd()) => scope === 'global' ? getGlobalConfigPath() : getLocalConfigPath(cwd);
92
+ export const loadRawConfig = async (scope, cwd = safeCwd()) => readJsonConfig(resolveConfigPath(scope, cwd));
93
+ export const loadLegacyLocalRawConfig = async (cwd = safeCwd()) => readJsonConfig(resolve(cwd, '.brainlink.json'));
94
+ export const detectVaultConfigSource = async (cwd = safeCwd()) => {
95
+ const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
96
+ loadRawConfig('global', cwd),
97
+ loadRawConfig('local', cwd),
98
+ loadLegacyLocalRawConfig(cwd)
99
+ ]);
100
+ if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
101
+ return 'local-legacy';
102
+ }
103
+ if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
104
+ return 'local';
105
+ }
106
+ if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
107
+ return 'global';
108
+ }
109
+ return 'default';
110
+ };
111
+ export const writeRawConfig = async (scope, value, cwd = safeCwd()) => {
112
+ const path = resolveConfigPath(scope, cwd);
113
+ await mkdir(dirname(path), { recursive: true, mode: globalConfigDirectoryMode });
114
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: globalConfigFileMode });
115
+ return path;
116
+ };
42
117
  const sanitizeConfig = (value) => ({
43
118
  ...defaultBrainlinkConfig,
44
119
  ...value,
@@ -56,11 +131,22 @@ const sanitizeConfig = (value) => ({
56
131
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
57
132
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
58
133
  embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
59
- defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
134
+ defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
135
+ agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
60
136
  });
61
- export const loadBrainlinkConfig = async (cwd = process.cwd()) => {
62
- const configs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
63
- const merged = configs.reduce((state, config) => ({
137
+ export const resolveAgentRuntimeDefaults = (config, agent) => {
138
+ const normalizedAgent = agent?.trim().length ? sanitizeAgentId(agent) : undefined;
139
+ const profile = (normalizedAgent ? config.agentProfiles[normalizedAgent] : undefined) ?? config.agentProfiles['*'];
140
+ return {
141
+ defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
142
+ defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
143
+ defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
144
+ };
145
+ };
146
+ export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
147
+ const globalConfig = await readJsonConfig(getGlobalConfigPath());
148
+ const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
149
+ const merged = [globalConfig, ...localConfigs].reduce((state, config) => ({
64
150
  ...state,
65
151
  ...config
66
152
  }), {});
@@ -2,8 +2,16 @@ import { homedir } from 'node:os';
2
2
  import { isAbsolute, join, resolve } from 'node:path';
3
3
  const defaultHomeDirectoryName = '.brainlink';
4
4
  const defaultVaultDirectoryName = 'vault';
5
+ const resolveSafeCwd = () => {
6
+ try {
7
+ return process.cwd();
8
+ }
9
+ catch {
10
+ return homedir();
11
+ }
12
+ };
5
13
  export const expandHomePath = (path) => path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
6
- export const resolvePath = (path, cwd = process.cwd()) => {
14
+ export const resolvePath = (path, cwd = resolveSafeCwd()) => {
7
15
  const expandedPath = expandHomePath(path);
8
16
  return isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
9
17
  };
@@ -0,0 +1,117 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { getBrainlinkHomePath } from './paths.js';
4
+ const defaultPolicy = {
5
+ enforceBootstrap: true,
6
+ autoBootstrapOnRead: true,
7
+ autoBootstrapOnStartup: true,
8
+ staleAfterMinutes: 120
9
+ };
10
+ const defaultState = {
11
+ policy: defaultPolicy,
12
+ bootstraps: []
13
+ };
14
+ const sessionStatePath = () => join(getBrainlinkHomePath(), 'session-state.json');
15
+ const normalizeAgent = (agent) => agent?.trim() ? agent.trim() : '*';
16
+ const safePositive = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
17
+ const sanitizeState = (value) => {
18
+ if (typeof value !== 'object' || value === null) {
19
+ return defaultState;
20
+ }
21
+ const record = value;
22
+ const policyRecord = typeof record.policy === 'object' && record.policy !== null ? record.policy : {};
23
+ const rawBootstraps = Array.isArray(record.bootstraps) ? record.bootstraps : [];
24
+ const bootstraps = rawBootstraps.flatMap((entry) => {
25
+ if (typeof entry !== 'object' || entry === null) {
26
+ return [];
27
+ }
28
+ const row = entry;
29
+ const vault = typeof row.vault === 'string' && row.vault.trim().length > 0 ? row.vault.trim() : undefined;
30
+ const agent = typeof row.agent === 'string' && row.agent.trim().length > 0 ? row.agent.trim() : undefined;
31
+ const lastBootstrappedAt = typeof row.lastBootstrappedAt === 'string' && row.lastBootstrappedAt.trim().length > 0 ? row.lastBootstrappedAt.trim() : undefined;
32
+ return vault && agent && lastBootstrappedAt ? [{ vault, agent, lastBootstrappedAt }] : [];
33
+ });
34
+ return {
35
+ policy: {
36
+ enforceBootstrap: typeof policyRecord.enforceBootstrap === 'boolean' ? policyRecord.enforceBootstrap : defaultPolicy.enforceBootstrap,
37
+ autoBootstrapOnRead: typeof policyRecord.autoBootstrapOnRead === 'boolean'
38
+ ? policyRecord.autoBootstrapOnRead
39
+ : defaultPolicy.autoBootstrapOnRead,
40
+ autoBootstrapOnStartup: typeof policyRecord.autoBootstrapOnStartup === 'boolean'
41
+ ? policyRecord.autoBootstrapOnStartup
42
+ : defaultPolicy.autoBootstrapOnStartup,
43
+ staleAfterMinutes: safePositive(policyRecord.staleAfterMinutes, defaultPolicy.staleAfterMinutes)
44
+ },
45
+ bootstraps
46
+ };
47
+ };
48
+ const readState = async () => {
49
+ try {
50
+ const content = await readFile(sessionStatePath(), 'utf8');
51
+ return sanitizeState(JSON.parse(content));
52
+ }
53
+ catch (error) {
54
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
55
+ return defaultState;
56
+ }
57
+ throw error;
58
+ }
59
+ };
60
+ const writeState = async (state) => {
61
+ const path = sessionStatePath();
62
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
63
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
64
+ };
65
+ export const getSessionStatePath = () => sessionStatePath();
66
+ export const getBootstrapPolicy = async () => (await readState()).policy;
67
+ export const setBootstrapPolicy = async (patch) => {
68
+ const state = await readState();
69
+ const next = {
70
+ enforceBootstrap: typeof patch.enforceBootstrap === 'boolean' ? patch.enforceBootstrap : state.policy.enforceBootstrap,
71
+ autoBootstrapOnRead: typeof patch.autoBootstrapOnRead === 'boolean' ? patch.autoBootstrapOnRead : state.policy.autoBootstrapOnRead,
72
+ autoBootstrapOnStartup: typeof patch.autoBootstrapOnStartup === 'boolean' ? patch.autoBootstrapOnStartup : state.policy.autoBootstrapOnStartup,
73
+ staleAfterMinutes: safePositive(patch.staleAfterMinutes, state.policy.staleAfterMinutes)
74
+ };
75
+ await writeState({
76
+ ...state,
77
+ policy: next
78
+ });
79
+ return next;
80
+ };
81
+ export const touchBootstrapSession = async (vault, agent) => {
82
+ const state = await readState();
83
+ const normalizedAgent = normalizeAgent(agent);
84
+ const entry = {
85
+ vault,
86
+ agent: normalizedAgent,
87
+ lastBootstrappedAt: new Date().toISOString()
88
+ };
89
+ const bootstraps = [
90
+ entry,
91
+ ...state.bootstraps.filter((item) => !(item.vault === entry.vault && item.agent === entry.agent))
92
+ ].slice(0, 500);
93
+ await writeState({
94
+ ...state,
95
+ bootstraps
96
+ });
97
+ return entry;
98
+ };
99
+ export const getBootstrapSessionStatus = async (vault, agent) => {
100
+ const state = await readState();
101
+ const normalizedAgent = normalizeAgent(agent);
102
+ const match = state.bootstraps.find((entry) => entry.vault === vault && entry.agent === normalizedAgent);
103
+ if (!match) {
104
+ return {
105
+ ready: false,
106
+ stale: true
107
+ };
108
+ }
109
+ const ageMinutes = Math.max(0, (Date.now() - new Date(match.lastBootstrappedAt).getTime()) / 60000);
110
+ const stale = ageMinutes > state.policy.staleAfterMinutes;
111
+ return {
112
+ ready: !stale,
113
+ stale,
114
+ lastBootstrappedAt: match.lastBootstrappedAt,
115
+ ageMinutes
116
+ };
117
+ };
package/dist/mcp/main.js CHANGED
@@ -1,9 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { createBrainlinkMcpServer } from './server.js';
4
- const server = createBrainlinkMcpServer();
5
- const transport = new StdioServerTransport();
6
- server.connect(transport).catch((error) => {
4
+ import { runStartupBootstrap } from './startup.js';
5
+ const start = async () => {
6
+ const startup = await runStartupBootstrap();
7
+ if (startup.error) {
8
+ console.error(`Brainlink MCP startup bootstrap warning: ${startup.error}`);
9
+ }
10
+ const server = createBrainlinkMcpServer();
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ };
14
+ start().catch((error) => {
7
15
  const message = error instanceof Error ? error.message : String(error);
8
16
  console.error(message);
9
17
  process.exitCode = 1;
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool } from './tools.js';
5
+ import { addNoteInputSchema, addFileInputSchema, addFileTool, addNoteTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, contextInputSchema, contextTool, graphInputSchema, graphTool, indexInputSchema, indexTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, statsInputSchema, statsTool, syncInputSchema, syncTool, validateInputSchema, validateTool } from './tools.js';
6
6
  const readPackageVersion = () => {
7
7
  const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
8
8
  const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
@@ -15,9 +15,24 @@ export const createBrainlinkMcpServer = () => {
15
15
  version: readPackageVersion(),
16
16
  description: 'Local-first Markdown memory tools for AI agents.'
17
17
  });
18
+ server.registerTool('brainlink_bootstrap', {
19
+ title: 'Bootstrap Brainlink For A Task (Default Entrypoint)',
20
+ description: 'Default entrypoint for agents. Run this first to index/check memory state, then optionally retrieve context for the current task query.',
21
+ inputSchema: bootstrapInputSchema
22
+ }, bootstrapTool);
23
+ server.registerTool('brainlink_policy', {
24
+ title: 'Brainlink Bootstrap Policy',
25
+ description: 'Read or update bootstrap enforcement policy and inspect bootstrap readiness for the current vault/agent.',
26
+ inputSchema: policyInputSchema
27
+ }, policyTool);
28
+ server.registerTool('brainlink_recommendations', {
29
+ title: 'Brainlink Recommended MCP Workflow',
30
+ description: 'Return a plug-and-play action plan for this vault/agent, including policy, bootstrap, context retrieval and durable write guidance.',
31
+ inputSchema: recommendationsInputSchema
32
+ }, recommendationsTool);
18
33
  server.registerTool('brainlink_context', {
19
34
  title: 'Build Brainlink Context',
20
- description: 'Read indexed Brainlink memory for a task or question. This is read-only and does not create graph links.',
35
+ description: 'Read indexed Brainlink memory for a task or question. Usually called after brainlink_bootstrap. This is read-only and does not create graph links.',
21
36
  inputSchema: contextInputSchema
22
37
  }, contextTool);
23
38
  server.registerTool('brainlink_search', {