@andespindola/brainlink 0.1.0-beta.7 → 0.1.0-beta.71

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 (63) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +58 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +266 -20
  6. package/SECURITY.md +1 -1
  7. package/dist/application/add-note.js +62 -13
  8. package/dist/application/analyze-vault.js +95 -8
  9. package/dist/application/build-context.js +56 -1
  10. package/dist/application/dedupe-notes.js +226 -0
  11. package/dist/application/frontend/client-css.js +138 -103
  12. package/dist/application/frontend/client-html.js +47 -41
  13. package/dist/application/frontend/client-js.js +2120 -128
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-layout.js +18 -6
  16. package/dist/application/get-graph-node.js +12 -0
  17. package/dist/application/get-graph-summary.js +12 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +252 -19
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/migrate-vault.js +91 -0
  24. package/dist/application/offline-pack-backup.js +44 -0
  25. package/dist/application/search-graph-node-ids.js +12 -0
  26. package/dist/application/search-knowledge.js +75 -5
  27. package/dist/application/server/routes.js +102 -1
  28. package/dist/application/start-server.js +75 -4
  29. package/dist/application/watch-vault.js +23 -2
  30. package/dist/benchmarks/large-vault.js +1 -1
  31. package/dist/cli/commands/agent-commands.js +419 -0
  32. package/dist/cli/commands/config-commands.js +167 -0
  33. package/dist/cli/commands/read-commands.js +25 -8
  34. package/dist/cli/commands/write-commands.js +989 -10
  35. package/dist/cli/main.js +4 -0
  36. package/dist/cli/runtime.js +5 -2
  37. package/dist/domain/context.js +53 -11
  38. package/dist/domain/embeddings.js +2 -1
  39. package/dist/domain/graph-layout.js +62 -15
  40. package/dist/domain/markdown.js +36 -4
  41. package/dist/domain/middle-out.js +18 -0
  42. package/dist/infrastructure/config.js +132 -8
  43. package/dist/infrastructure/file-index.js +358 -0
  44. package/dist/infrastructure/file-system-vault.js +30 -0
  45. package/dist/infrastructure/index-state.js +56 -0
  46. package/dist/infrastructure/paths.js +9 -1
  47. package/dist/infrastructure/private-pack-codec.js +134 -0
  48. package/dist/infrastructure/search-packs.js +452 -0
  49. package/dist/infrastructure/session-state.js +172 -0
  50. package/dist/mcp/main.js +11 -3
  51. package/dist/mcp/server.js +27 -2
  52. package/dist/mcp/startup.js +35 -0
  53. package/dist/mcp/tools.js +633 -19
  54. package/docs/AGENT_USAGE.md +178 -16
  55. package/docs/ARCHITECTURE.md +37 -26
  56. package/docs/QUICKSTART.md +111 -0
  57. package/package.json +6 -4
  58. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  59. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  60. package/dist/infrastructure/sqlite/schema.js +0 -111
  61. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  62. package/dist/infrastructure/sqlite/types.js +0 -1
  63. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -0,0 +1,419 @@
1
+ import { access, lstat, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { loadBrainlinkConfig } from '../../infrastructure/config.js';
7
+ import { getBootstrapPolicy, getBootstrapSessionStatus, getContextSessionStatus, getSessionStatePath, setBootstrapPolicy } from '../../infrastructure/session-state.js';
8
+ import { print } from '../runtime.js';
9
+ const getCodexConfigPath = () => join(homedir(), '.codex', 'config.toml');
10
+ const getMarketplacePath = () => join(homedir(), '.agents', 'plugins', 'marketplace.json');
11
+ const getDefaultPluginSourcePath = () => resolve(process.cwd(), 'plugins', 'brainlink');
12
+ const getPluginSymlinkPath = () => join(homedir(), 'plugins', 'brainlink');
13
+ const execFileAsync = promisify(execFile);
14
+ const toTomlValue = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
15
+ const removeBrainlinkMcpSection = (content) => {
16
+ const lines = content.split('\n');
17
+ const output = [];
18
+ let skip = false;
19
+ for (const line of lines) {
20
+ const sectionMatch = line.match(/^\[(.+)\]\s*$/);
21
+ if (!skip && sectionMatch?.[1] === 'mcp_servers.brainlink') {
22
+ skip = true;
23
+ continue;
24
+ }
25
+ if (skip && sectionMatch) {
26
+ skip = false;
27
+ }
28
+ if (!skip) {
29
+ output.push(line);
30
+ }
31
+ }
32
+ return output.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
33
+ };
34
+ const buildBrainlinkMcpSection = (options) => {
35
+ const envEntries = [];
36
+ if (options.allowedVaults) {
37
+ envEntries.push(`BRAINLINK_ALLOWED_VAULTS = ${toTomlValue(options.allowedVaults)}`);
38
+ }
39
+ if (options.brainlinkHome) {
40
+ envEntries.push(`BRAINLINK_HOME = ${toTomlValue(options.brainlinkHome)}`);
41
+ }
42
+ return [
43
+ '[mcp_servers.brainlink]',
44
+ `command = ${toTomlValue('brainlink-mcp')}`,
45
+ ...(envEntries.length > 0 ? [`env = { ${envEntries.join(', ')} }`] : [])
46
+ ].join('\n');
47
+ };
48
+ const upsertCodexMcpConfig = async (configPath, options) => {
49
+ let existing = '';
50
+ try {
51
+ existing = await readFile(configPath, 'utf8');
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
55
+ throw error;
56
+ }
57
+ }
58
+ const withoutSection = removeBrainlinkMcpSection(existing);
59
+ const section = buildBrainlinkMcpSection(options);
60
+ const merged = `${withoutSection}\n\n${section}\n`.replace(/^\n+/, '');
61
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
62
+ await writeFile(configPath, merged, { encoding: 'utf8', mode: 0o600 });
63
+ };
64
+ const ensurePluginSymlink = async (sourcePath, symlinkPath) => {
65
+ await access(sourcePath);
66
+ await mkdir(dirname(symlinkPath), { recursive: true, mode: 0o700 });
67
+ try {
68
+ const info = await lstat(symlinkPath);
69
+ if (info.isSymbolicLink()) {
70
+ await rm(symlinkPath, { force: true });
71
+ }
72
+ else {
73
+ await rm(symlinkPath, { recursive: true, force: true });
74
+ }
75
+ }
76
+ catch (error) {
77
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
78
+ throw error;
79
+ }
80
+ }
81
+ await symlink(sourcePath, symlinkPath);
82
+ };
83
+ const loadMarketplace = async (path) => {
84
+ try {
85
+ const content = await readFile(path, 'utf8');
86
+ const parsed = JSON.parse(content);
87
+ const plugins = Array.isArray(parsed.plugins) ? parsed.plugins : [];
88
+ return {
89
+ name: typeof parsed.name === 'string' ? parsed.name : 'local',
90
+ interface: {
91
+ displayName: typeof parsed.interface?.displayName === 'string' ? parsed.interface.displayName : 'Local'
92
+ },
93
+ plugins: plugins
94
+ };
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
98
+ throw error;
99
+ }
100
+ return {
101
+ name: 'local',
102
+ interface: {
103
+ displayName: 'Local'
104
+ },
105
+ plugins: []
106
+ };
107
+ }
108
+ };
109
+ const upsertMarketplacePlugin = async (marketplacePath) => {
110
+ const marketplace = await loadMarketplace(marketplacePath);
111
+ const pluginEntry = {
112
+ name: 'brainlink',
113
+ source: {
114
+ source: 'local',
115
+ path: './plugins/brainlink'
116
+ },
117
+ policy: {
118
+ installation: 'AVAILABLE',
119
+ authentication: 'ON_INSTALL'
120
+ },
121
+ category: 'Productivity'
122
+ };
123
+ const plugins = marketplace.plugins.filter((plugin) => plugin?.name !== 'brainlink');
124
+ const next = {
125
+ ...marketplace,
126
+ plugins: [...plugins, pluginEntry]
127
+ };
128
+ await mkdir(dirname(marketplacePath), { recursive: true, mode: 0o700 });
129
+ await writeFile(marketplacePath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
+ };
131
+ const parseAllowedVaults = (value) => {
132
+ if (!value) {
133
+ return undefined;
134
+ }
135
+ const normalized = value
136
+ .split(',')
137
+ .map((item) => item.trim())
138
+ .filter(Boolean);
139
+ return normalized.length > 0 ? normalized.join(',') : undefined;
140
+ };
141
+ export const installAgentIntegration = async (input) => {
142
+ const codexConfigPath = getCodexConfigPath();
143
+ const allowedVaults = parseAllowedVaults(input.allowedVaults);
144
+ const bootstrapPolicy = await setBootstrapPolicy({
145
+ enforceBootstrap: true,
146
+ enforceContextFirst: true,
147
+ autoBootstrapOnRead: true,
148
+ autoBootstrapOnStartup: true
149
+ });
150
+ await upsertCodexMcpConfig(codexConfigPath, {
151
+ allowedVaults,
152
+ brainlinkHome: input.brainlinkHome
153
+ });
154
+ const warnings = [];
155
+ const pluginSourcePath = input.pluginPath ? resolve(input.pluginPath) : getDefaultPluginSourcePath();
156
+ const pluginSymlinkPath = getPluginSymlinkPath();
157
+ const marketplacePath = getMarketplacePath();
158
+ if (input.mcpOnly !== true) {
159
+ try {
160
+ await ensurePluginSymlink(pluginSourcePath, pluginSymlinkPath);
161
+ await upsertMarketplacePlugin(marketplacePath);
162
+ }
163
+ catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ warnings.push(`Plugin marketplace setup skipped: ${message}. MCP is configured, but install repository plugin files to enable local gallery auto-discovery.`);
166
+ }
167
+ }
168
+ const selfTestResult = input.selfTest
169
+ ? await (async () => {
170
+ const codexConfig = await readFile(codexConfigPath, 'utf8');
171
+ const mcp = isBrainlinkConfigured(codexConfig);
172
+ const mcpCommandInPath = await hasMcpCommandInPath();
173
+ const pluginSymlinkExists = input.mcpOnly === true
174
+ ? null
175
+ : await (async () => {
176
+ try {
177
+ return (await lstat(pluginSymlinkPath)).isSymbolicLink();
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ })();
183
+ const marketplaceEntryExists = input.mcpOnly === true
184
+ ? null
185
+ : (await loadMarketplace(marketplacePath)).plugins.some((plugin) => plugin?.name === 'brainlink');
186
+ return {
187
+ ok: mcp.hasMcpSection &&
188
+ mcp.hasCommand &&
189
+ mcpCommandInPath &&
190
+ (input.mcpOnly === true || (Boolean(pluginSymlinkExists) && Boolean(marketplaceEntryExists))),
191
+ mcpCommandInPath,
192
+ hasMcpSection: mcp.hasMcpSection,
193
+ hasCommand: mcp.hasCommand,
194
+ pluginSymlinkExists,
195
+ marketplaceEntryExists
196
+ };
197
+ })()
198
+ : undefined;
199
+ return {
200
+ installed: true,
201
+ codexConfigPath,
202
+ mcpServer: 'brainlink',
203
+ command: 'brainlink-mcp',
204
+ bootstrapPolicy,
205
+ ...(input.mcpOnly !== true ? { pluginSourcePath, pluginSymlinkPath, marketplacePath } : {}),
206
+ ...(selfTestResult ? { selfTest: selfTestResult } : {}),
207
+ ...(warnings.length > 0 ? { warnings } : {})
208
+ };
209
+ };
210
+ const hasMcpCommandInPath = async () => {
211
+ try {
212
+ const { stdout } = await execFileAsync('sh', ['-lc', 'command -v brainlink-mcp'], { maxBuffer: 1024 * 1024 });
213
+ return stdout.trim().length > 0;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ };
219
+ const isBrainlinkConfigured = (codexConfig) => {
220
+ const hasMcpSection = codexConfig.includes('[mcp_servers.brainlink]');
221
+ const hasCommand = /(^|\n)\s*command\s*=\s*"brainlink-mcp"\s*(\n|$)/m.test(codexConfig);
222
+ return { hasMcpSection, hasCommand };
223
+ };
224
+ const parseBooleanOption = (value, name) => {
225
+ if (value === undefined) {
226
+ return undefined;
227
+ }
228
+ if (value === 'true') {
229
+ return true;
230
+ }
231
+ if (value === 'false') {
232
+ return false;
233
+ }
234
+ throw new Error(`Invalid value for ${name}: ${value}. Use true or false.`);
235
+ };
236
+ const parsePositiveIntegerOption = (value, name) => {
237
+ if (value === undefined) {
238
+ return undefined;
239
+ }
240
+ const parsed = Number.parseInt(value, 10);
241
+ if (!Number.isFinite(parsed) || parsed <= 0) {
242
+ throw new Error(`Invalid value for ${name}: ${value}. Use a positive integer.`);
243
+ }
244
+ return parsed;
245
+ };
246
+ const applyPolicyPreset = (preset) => {
247
+ if (!preset) {
248
+ return {};
249
+ }
250
+ if (preset === 'fully-auto') {
251
+ return {
252
+ enforceBootstrap: true,
253
+ enforceContextFirst: true,
254
+ autoBootstrapOnRead: true,
255
+ autoBootstrapOnStartup: true
256
+ };
257
+ }
258
+ if (preset === 'strict') {
259
+ return {
260
+ enforceBootstrap: true,
261
+ enforceContextFirst: true,
262
+ autoBootstrapOnRead: false,
263
+ autoBootstrapOnStartup: false
264
+ };
265
+ }
266
+ throw new Error(`Unknown policy preset: ${preset}. Use "fully-auto" or "strict".`);
267
+ };
268
+ export const registerAgentCommands = (program) => {
269
+ const agent = program.command('agent').description('install or inspect Brainlink agent integration');
270
+ agent
271
+ .command('install')
272
+ .option('--mcp-only', 'only configure MCP server in Codex config')
273
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
274
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
275
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
276
+ .option('--self-test', 'run post-install checks and include diagnostics in the result')
277
+ .option('--json', 'print machine-readable JSON')
278
+ .description('install Brainlink as default MCP memory integration for the local agent')
279
+ .action(async (options) => {
280
+ const result = await installAgentIntegration({
281
+ mcpOnly: options.mcpOnly,
282
+ pluginPath: options.pluginPath,
283
+ allowedVaults: options.allowedVaults,
284
+ brainlinkHome: options.brainlinkHome,
285
+ selfTest: options.selfTest
286
+ });
287
+ print(options.json, result, () => [
288
+ `Installed Brainlink MCP at ${result.codexConfigPath}`,
289
+ ...(options.mcpOnly === true ? [] : [`Plugin symlink: ${result.pluginSymlinkPath}`, `Marketplace: ${result.marketplacePath}`]),
290
+ ...(result.selfTest ? [`Self-test: ${result.selfTest.ok ? 'ok' : 'failed'}`] : []),
291
+ ...(result.warnings && result.warnings.length > 0 ? ['Warnings:', ...result.warnings.map((warning) => `- ${warning}`)] : [])
292
+ ].join('\n'));
293
+ });
294
+ agent
295
+ .command('upgrade')
296
+ .option('--mcp-only', 'only configure MCP server in Codex config')
297
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
298
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
299
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
300
+ .option('--json', 'print machine-readable JSON')
301
+ .description('reapply latest Brainlink agent integration defaults for legacy installs')
302
+ .action(async (options) => {
303
+ const result = await installAgentIntegration({
304
+ mcpOnly: options.mcpOnly,
305
+ pluginPath: options.pluginPath,
306
+ allowedVaults: options.allowedVaults,
307
+ brainlinkHome: options.brainlinkHome,
308
+ selfTest: true
309
+ });
310
+ print(options.json, {
311
+ upgraded: true,
312
+ ...result
313
+ }, () => `Upgraded Brainlink agent integration at ${result.codexConfigPath}. Self-test: ${result.selfTest?.ok ? 'ok' : 'failed'}`);
314
+ });
315
+ agent
316
+ .command('policy')
317
+ .option('--preset <preset>', 'policy preset: fully-auto or strict')
318
+ .option('--enforce-bootstrap <true|false>', 'override enforceBootstrap')
319
+ .option('--enforce-context-first <true|false>', 'override enforceContextFirst')
320
+ .option('--auto-bootstrap-on-read <true|false>', 'override autoBootstrapOnRead')
321
+ .option('--auto-bootstrap-on-startup <true|false>', 'override autoBootstrapOnStartup')
322
+ .option('--stale-after-minutes <minutes>', 'override staleAfterMinutes with positive integer')
323
+ .option('--json', 'print machine-readable JSON')
324
+ .description('read or update Brainlink MCP bootstrap policy')
325
+ .action(async (options) => {
326
+ const presetPatch = applyPolicyPreset(options.preset);
327
+ const enforceBootstrap = parseBooleanOption(options.enforceBootstrap, '--enforce-bootstrap');
328
+ const enforceContextFirst = parseBooleanOption(options.enforceContextFirst, '--enforce-context-first');
329
+ const autoBootstrapOnRead = parseBooleanOption(options.autoBootstrapOnRead, '--auto-bootstrap-on-read');
330
+ const autoBootstrapOnStartup = parseBooleanOption(options.autoBootstrapOnStartup, '--auto-bootstrap-on-startup');
331
+ const staleAfterMinutes = parsePositiveIntegerOption(options.staleAfterMinutes, '--stale-after-minutes');
332
+ const overridePatch = {
333
+ ...(enforceBootstrap !== undefined ? { enforceBootstrap } : {}),
334
+ ...(enforceContextFirst !== undefined ? { enforceContextFirst } : {}),
335
+ ...(autoBootstrapOnRead !== undefined ? { autoBootstrapOnRead } : {}),
336
+ ...(autoBootstrapOnStartup !== undefined ? { autoBootstrapOnStartup } : {}),
337
+ ...(staleAfterMinutes !== undefined ? { staleAfterMinutes } : {})
338
+ };
339
+ const patch = {
340
+ ...presetPatch,
341
+ ...overridePatch
342
+ };
343
+ const policy = Object.keys(patch).length === 0 ? await getBootstrapPolicy() : await setBootstrapPolicy(patch);
344
+ print(options.json, {
345
+ policy,
346
+ ...(options.preset ? { presetApplied: options.preset } : {})
347
+ }, () => [
348
+ ...(options.preset ? [`presetApplied=${options.preset}`] : []),
349
+ `enforceBootstrap=${policy.enforceBootstrap}`,
350
+ `enforceContextFirst=${policy.enforceContextFirst}`,
351
+ `autoBootstrapOnRead=${policy.autoBootstrapOnRead}`,
352
+ `autoBootstrapOnStartup=${policy.autoBootstrapOnStartup}`,
353
+ `staleAfterMinutes=${policy.staleAfterMinutes}`
354
+ ].join('\n'));
355
+ });
356
+ agent
357
+ .command('status')
358
+ .option('-a, --agent <agent>', 'agent memory namespace for bootstrap session status')
359
+ .option('--json', 'print machine-readable JSON')
360
+ .description('check if Brainlink MCP integration is configured for the local agent')
361
+ .action(async (options) => {
362
+ const codexConfigPath = getCodexConfigPath();
363
+ let codexConfig = '';
364
+ try {
365
+ codexConfig = await readFile(codexConfigPath, 'utf8');
366
+ }
367
+ catch (error) {
368
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
369
+ throw error;
370
+ }
371
+ }
372
+ const { hasMcpSection, hasCommand } = isBrainlinkConfigured(codexConfig);
373
+ const pluginSymlinkPath = getPluginSymlinkPath();
374
+ const marketplacePath = getMarketplacePath();
375
+ let pluginSymlinkExists = false;
376
+ let marketplaceEntryExists = false;
377
+ try {
378
+ pluginSymlinkExists = (await lstat(pluginSymlinkPath)).isSymbolicLink();
379
+ }
380
+ catch { }
381
+ try {
382
+ const marketplace = await loadMarketplace(marketplacePath);
383
+ marketplaceEntryExists = marketplace.plugins.some((plugin) => plugin?.name === 'brainlink');
384
+ }
385
+ catch { }
386
+ const config = await loadBrainlinkConfig();
387
+ const policy = await getBootstrapPolicy();
388
+ const bootstrapStatus = await getBootstrapSessionStatus(config.vault, options.agent ?? config.defaultAgent);
389
+ const contextStatus = await getContextSessionStatus(config.vault, options.agent ?? config.defaultAgent);
390
+ const sessionStatePath = getSessionStatePath();
391
+ print(options.json, {
392
+ configured: hasMcpSection && hasCommand,
393
+ codexConfigPath,
394
+ hasMcpSection,
395
+ hasCommand,
396
+ pluginSymlinkPath,
397
+ pluginSymlinkExists,
398
+ marketplacePath,
399
+ marketplaceEntryExists,
400
+ sessionStatePath,
401
+ vault: config.vault,
402
+ agent: options.agent ?? config.defaultAgent ?? '*',
403
+ bootstrapPolicy: policy,
404
+ bootstrapStatus,
405
+ contextStatus
406
+ }, () => [
407
+ `codexConfigPath=${codexConfigPath}`,
408
+ `configured=${hasMcpSection && hasCommand}`,
409
+ `pluginSymlinkExists=${pluginSymlinkExists}`,
410
+ `marketplaceEntryExists=${marketplaceEntryExists}`,
411
+ `vault=${config.vault}`,
412
+ `agent=${options.agent ?? config.defaultAgent ?? '*'}`,
413
+ `bootstrapReady=${bootstrapStatus.ready}`,
414
+ `bootstrapStale=${bootstrapStatus.stale}`,
415
+ `contextReady=${contextStatus.ready}`,
416
+ `contextStale=${contextStatus.stale}`
417
+ ].join('\n'));
418
+ });
419
+ };
@@ -0,0 +1,167 @@
1
+ import { doctorVault } from '../../application/analyze-vault.js';
2
+ import { indexVault } from '../../application/index-vault.js';
3
+ import { migrateVaultContent, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
4
+ import { defaultBrainlinkConfig, detectVaultConfigSource, loadBrainlinkConfig, loadLegacyLocalRawConfig, loadRawConfig, resolveConfigPath, writeRawConfig } from '../../infrastructure/config.js';
5
+ import { assertVaultAllowed } from '../../infrastructure/file-system-vault.js';
6
+ import { print } from '../runtime.js';
7
+ const resolveScope = (globalOption) => globalOption ? 'global' : 'local';
8
+ const normalizeVaultPath = (vault) => assertVaultAllowed(vault, []);
9
+ const uniqueValues = (values) => Array.from(new Set(values));
10
+ const resolveScopeFromSource = (source) => source === 'global' || source === 'default' ? 'global' : 'local';
11
+ export const registerConfigCommands = (program) => {
12
+ const configCommand = program.command('config').description('read or update Brainlink configuration');
13
+ configCommand
14
+ .command('get [key]')
15
+ .option('--json', 'print machine-readable JSON')
16
+ .description('read effective Brainlink config values')
17
+ .action(async (key, options) => {
18
+ const config = await loadBrainlinkConfig();
19
+ if (!key) {
20
+ print(options.json, config, () => JSON.stringify(config, null, 2));
21
+ return;
22
+ }
23
+ if (!(key in config)) {
24
+ throw new Error(`Unknown config key: ${key}`);
25
+ }
26
+ const value = config[key];
27
+ print(options.json, { key, value }, () => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`);
28
+ });
29
+ configCommand
30
+ .command('set-vault <vault>')
31
+ .option('--global', 'write to global config in $BRAINLINK_HOME/brainlink.config.json')
32
+ .option('--no-allowlist', 'do not append the vault to allowedVaults in the target config file')
33
+ .option('--migrate-from <vault>', 'copy existing Markdown memory from another vault into the configured vault')
34
+ .option('--no-migrate', 'skip migration step')
35
+ .option('--no-index', 'skip reindex after migration')
36
+ .option('--json', 'print machine-readable JSON')
37
+ .description('set the default vault path in Brainlink config')
38
+ .action(async (vault, options) => {
39
+ const scope = resolveScope(options.global);
40
+ const before = await loadBrainlinkConfig();
41
+ const targetVault = normalizeVaultPath(vault);
42
+ const rawConfig = await loadRawConfig(scope);
43
+ const configPath = resolveConfigPath(scope);
44
+ const shouldAllowlist = options.allowlist !== false;
45
+ const nextAllowedVaults = shouldAllowlist
46
+ ? uniqueValues([...(rawConfig.allowedVaults ?? []), targetVault])
47
+ : rawConfig.allowedVaults;
48
+ const nextRawConfig = {
49
+ ...rawConfig,
50
+ vault: targetVault,
51
+ ...(nextAllowedVaults ? { allowedVaults: nextAllowedVaults } : {})
52
+ };
53
+ await writeRawConfig(scope, nextRawConfig);
54
+ const shouldMigrate = options.migrate !== false;
55
+ const explicitSource = options.migrateFrom ? normalizeVaultPath(options.migrateFrom) : undefined;
56
+ const shouldAutoMigrate = shouldMigrate &&
57
+ explicitSource === undefined &&
58
+ (await shouldMigrateDefaultVault(before.vault, targetVault));
59
+ const migrationSource = shouldMigrate ? explicitSource ?? (shouldAutoMigrate ? before.vault : undefined) : undefined;
60
+ const migration = migrationSource ? await migrateVaultContent(migrationSource, targetVault) : undefined;
61
+ const shouldIndex = options.index !== false && migration !== undefined && migration.copied + migration.conflicted > 0;
62
+ const index = shouldIndex ? await indexVault(targetVault) : undefined;
63
+ const after = await loadBrainlinkConfig();
64
+ print(options.json, {
65
+ scope,
66
+ configPath,
67
+ beforeVault: before.vault,
68
+ vault: targetVault,
69
+ migration: migration ?? null,
70
+ index: index ?? null,
71
+ config: after
72
+ }, () => {
73
+ const migrationMessage = migration
74
+ ? ` Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`
75
+ : '';
76
+ const indexMessage = index
77
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
78
+ : '';
79
+ return `Configured ${scope} vault at ${targetVault} in ${configPath}.${migrationMessage}${indexMessage}`;
80
+ });
81
+ });
82
+ configCommand
83
+ .command('where')
84
+ .option('--json', 'print machine-readable JSON')
85
+ .description('show effective vault path and config file locations')
86
+ .action(async (options) => {
87
+ const config = await loadBrainlinkConfig();
88
+ print(options.json, {
89
+ vault: config.vault,
90
+ localConfigPath: resolveConfigPath('local'),
91
+ globalConfigPath: resolveConfigPath('global'),
92
+ defaultVault: defaultBrainlinkConfig.vault
93
+ }, () => [
94
+ `vault=${config.vault}`,
95
+ `localConfigPath=${resolveConfigPath('local')}`,
96
+ `globalConfigPath=${resolveConfigPath('global')}`,
97
+ `defaultVault=${defaultBrainlinkConfig.vault}`
98
+ ].join('\n'));
99
+ });
100
+ configCommand
101
+ .command('doctor')
102
+ .option('--fix', 'apply safe config fixes (without this flag, doctor is dry-run)')
103
+ .option('--json', 'print machine-readable JSON')
104
+ .description('inspect effective config sources and run vault readiness checks')
105
+ .action(async (options) => {
106
+ const config = await loadBrainlinkConfig();
107
+ const source = await detectVaultConfigSource();
108
+ const globalConfigPath = resolveConfigPath('global');
109
+ const localConfigPath = resolveConfigPath('local');
110
+ const allowedVaultCheck = assertVaultAllowed(config.vault, config.allowedVaults);
111
+ const vaultDoctor = await doctorVault(config.vault);
112
+ const targetScope = resolveScopeFromSource(source);
113
+ const rawConfig = source === 'local-legacy'
114
+ ? await loadLegacyLocalRawConfig()
115
+ : await loadRawConfig(targetScope);
116
+ const normalizedVault = normalizeVaultPath(typeof rawConfig.vault === 'string' ? rawConfig.vault : config.vault);
117
+ const normalizedAllowedVaults = uniqueValues([
118
+ ...(Array.isArray(rawConfig.allowedVaults) ? rawConfig.allowedVaults.filter((item) => typeof item === 'string') : []),
119
+ normalizedVault
120
+ ].map((value) => normalizeVaultPath(value)));
121
+ const nextRawConfig = {
122
+ ...rawConfig,
123
+ vault: normalizedVault,
124
+ allowedVaults: normalizedAllowedVaults
125
+ };
126
+ const plannedFixes = [
127
+ `normalize vault path in ${targetScope} config`,
128
+ `ensure allowedVaults includes ${normalizedVault}`,
129
+ ...(source === 'local-legacy' ? ['migrate .brainlink.json settings into brainlink.config.json'] : []),
130
+ ...(source === 'default' ? ['create global brainlink.config.json with explicit vault'] : [])
131
+ ];
132
+ let fixApplied = false;
133
+ let fixedConfigPath = null;
134
+ if (options.fix) {
135
+ fixedConfigPath = await writeRawConfig(targetScope, nextRawConfig);
136
+ fixApplied = true;
137
+ }
138
+ const response = {
139
+ vault: config.vault,
140
+ vaultSource: source,
141
+ allowedVaultCheck,
142
+ localConfigPath,
143
+ globalConfigPath,
144
+ doctor: vaultDoctor,
145
+ fix: {
146
+ dryRun: options.fix !== true,
147
+ applied: fixApplied,
148
+ scope: targetScope,
149
+ path: fixedConfigPath,
150
+ plannedFixes
151
+ }
152
+ };
153
+ print(options.json, response, () => [
154
+ `vault=${response.vault}`,
155
+ `vaultSource=${response.vaultSource}`,
156
+ `localConfigPath=${response.localConfigPath}`,
157
+ `globalConfigPath=${response.globalConfigPath}`,
158
+ `configFixDryRun=${response.fix.dryRun}`,
159
+ ...(response.fix.applied && response.fix.path ? [`configFixAppliedAt=${response.fix.path}`] : []),
160
+ ...(response.fix.plannedFixes.length > 0 ? ['Planned config fixes:', ...response.fix.plannedFixes.map((step) => `- ${step}`)] : []),
161
+ ...response.doctor.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`),
162
+ ...(response.doctor.recommendations && response.doctor.recommendations.length > 0
163
+ ? ['Recommended next steps:', ...response.doctor.recommendations.map((recommendation) => `- ${recommendation}`)]
164
+ : [])
165
+ ].join('\n'));
166
+ });
167
+ };
@@ -1,4 +1,4 @@
1
- import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
1
+ import { getBrokenLinksReport, getExtendedStats, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
2
2
  import { buildContextPackage } from '../../application/build-context.js';
3
3
  import { getGraph } from '../../application/get-graph.js';
4
4
  import { listAgents } from '../../application/list-agents.js';
@@ -12,14 +12,14 @@ export const registerReadCommands = (program) => {
12
12
  .argument('<query>', 'search query')
13
13
  .option('-v, --vault <vault>', 'vault directory')
14
14
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
15
- .option('-l, --limit <limit>', 'maximum results', '10')
15
+ .option('-l, --limit <limit>', 'maximum results')
16
16
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
17
17
  .option('--json', 'print machine-readable JSON')
18
18
  .description('search indexed knowledge')
19
19
  .action(async (query, options) => {
20
20
  const resolved = await resolveOptions(options);
21
- const limit = parsePositiveInteger(options.limit ?? String(resolved.config.defaultSearchLimit), resolved.config.defaultSearchLimit);
22
- const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
21
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
22
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
23
23
  const results = await searchKnowledge(resolved.vault, query, limit, resolved.agent, mode);
24
24
  print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
25
25
  .map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
@@ -58,15 +58,15 @@ export const registerReadCommands = (program) => {
58
58
  .argument('<query>', 'context query')
59
59
  .option('-v, --vault <vault>', 'vault directory')
60
60
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
61
- .option('-l, --limit <limit>', 'maximum search results before context selection', '12')
62
- .option('-t, --tokens <tokens>', 'maximum estimated context tokens', '2000')
61
+ .option('-l, --limit <limit>', 'maximum search results before context selection')
62
+ .option('-t, --tokens <tokens>', 'maximum estimated context tokens')
63
63
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
64
64
  .option('--json', 'print machine-readable JSON')
65
65
  .description('build a compact context package for an agent')
66
66
  .action(async (query, options) => {
67
67
  const resolved = await resolveOptions(options);
68
- const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
69
- const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), resolved.agent, mode);
68
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
69
+ const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode);
70
70
  print(options.json, contextPackage, () => contextPackage.content);
71
71
  });
72
72
  program
@@ -94,10 +94,27 @@ export const registerReadCommands = (program) => {
94
94
  .command('stats')
95
95
  .option('-v, --vault <vault>', 'vault directory')
96
96
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
97
+ .option('--extended', 'include storage, quality and latency observability probes')
97
98
  .option('--json', 'print machine-readable JSON')
98
99
  .description('print indexed vault statistics')
99
100
  .action(async (options) => {
100
101
  const resolved = await resolveOptions(options);
102
+ if (options.extended) {
103
+ const stats = await getExtendedStats(resolved.vault, resolved.agent);
104
+ print(options.json, stats, () => [
105
+ `Documents: ${stats.stats.documentCount}`,
106
+ `Links: ${stats.stats.linkCount}`,
107
+ `Resolved links: ${stats.stats.resolvedLinkCount}`,
108
+ `Broken links: ${stats.stats.brokenLinkCount}`,
109
+ `Orphans: ${stats.stats.orphanCount}`,
110
+ `Tags: ${stats.stats.tagCount}`,
111
+ `Total files: ${stats.storage.totalFileCount}`,
112
+ `Markdown files: ${stats.storage.markdownFileCount}`,
113
+ `Vault bytes: ${stats.storage.totalBytes}`,
114
+ `Latency index/search/context (ms): ${stats.observability.latenciesMs.index}/${stats.observability.latenciesMs.search}/${stats.observability.latenciesMs.context}`
115
+ ].join('\n'));
116
+ return;
117
+ }
101
118
  const stats = await getStats(resolved.vault, resolved.agent);
102
119
  print(options.json, stats, () => [
103
120
  `Documents: ${stats.documentCount}`,