@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.41

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 (59) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +45 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +216 -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 +214 -100
  12. package/dist/application/frontend/client-html.js +60 -45
  13. package/dist/application/frontend/client-js.js +818 -94
  14. package/dist/application/get-graph-layout.js +22 -7
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +143 -20
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/migrate-vault.js +91 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +75 -5
  25. package/dist/application/server/routes.js +27 -1
  26. package/dist/benchmarks/large-vault.js +1 -1
  27. package/dist/cli/commands/agent-commands.js +412 -0
  28. package/dist/cli/commands/config-commands.js +167 -0
  29. package/dist/cli/commands/read-commands.js +25 -8
  30. package/dist/cli/commands/write-commands.js +669 -9
  31. package/dist/cli/main.js +4 -0
  32. package/dist/cli/runtime.js +5 -2
  33. package/dist/domain/context.js +53 -11
  34. package/dist/domain/embeddings.js +2 -1
  35. package/dist/domain/graph-layout.js +20 -14
  36. package/dist/domain/markdown.js +36 -4
  37. package/dist/domain/middle-out.js +18 -0
  38. package/dist/infrastructure/config.js +94 -8
  39. package/dist/infrastructure/file-index.js +358 -0
  40. package/dist/infrastructure/file-system-vault.js +30 -0
  41. package/dist/infrastructure/index-state.js +50 -0
  42. package/dist/infrastructure/paths.js +9 -1
  43. package/dist/infrastructure/private-pack-codec.js +73 -0
  44. package/dist/infrastructure/search-packs.js +348 -0
  45. package/dist/infrastructure/session-state.js +172 -0
  46. package/dist/mcp/main.js +11 -3
  47. package/dist/mcp/server.js +27 -2
  48. package/dist/mcp/startup.js +35 -0
  49. package/dist/mcp/tools.js +633 -19
  50. package/docs/AGENT_USAGE.md +144 -16
  51. package/docs/ARCHITECTURE.md +37 -26
  52. package/docs/QUICKSTART.md +111 -0
  53. package/package.json +6 -4
  54. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  55. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  56. package/dist/infrastructure/sqlite/schema.js +0 -111
  57. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  58. package/dist/infrastructure/sqlite/types.js +0 -1
  59. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -0,0 +1,91 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, relative } from 'node:path';
3
+ import { ensureVault, isBucketVaultPath, listVaultFiles, resolveVaultPath, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
4
+ const directoryMode = 0o700;
5
+ const fileMode = 0o600;
6
+ const isMarkdownPath = (path) => extname(path).toLowerCase() === '.md';
7
+ const timestamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
8
+ const isPathInside = (parent, child) => {
9
+ const path = relative(parent, child);
10
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
11
+ };
12
+ const conflictPath = (targetPath) => {
13
+ const extension = extname(targetPath);
14
+ const base = extension ? targetPath.slice(0, -extension.length) : targetPath;
15
+ return `${base}.conflict-${timestamp()}${extension}`;
16
+ };
17
+ const writePreservedFile = async (absolutePath, content) => {
18
+ await mkdir(dirname(absolutePath), { recursive: true, mode: directoryMode });
19
+ await writeFile(absolutePath, content, { mode: fileMode });
20
+ await chmod(absolutePath, fileMode);
21
+ };
22
+ const writeMigratedFile = async (targetVault, targetRoot, absolutePath, content) => {
23
+ if (isBucketVaultPath(targetVault)) {
24
+ await writeMarkdownFile(targetVault, relative(targetRoot, absolutePath), content.toString('utf8'));
25
+ return;
26
+ }
27
+ await writePreservedFile(absolutePath, content);
28
+ };
29
+ export const planVaultMigration = async (source, target) => {
30
+ const sourceFiles = (await listVaultFiles(source)).filter(isMarkdownPath);
31
+ return sourceFiles.reduce(async (statePromise, sourceFile) => {
32
+ const state = await statePromise;
33
+ const targetFile = join(target, relative(source, sourceFile));
34
+ if (!isPathInside(target, targetFile)) {
35
+ return state;
36
+ }
37
+ const sourceContent = await readFile(sourceFile);
38
+ try {
39
+ const targetContent = await readFile(targetFile);
40
+ if (sourceContent.equals(targetContent)) {
41
+ return [...state, { kind: 'unchanged', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
42
+ }
43
+ return [...state, { kind: 'conflict', sourcePath: sourceFile, targetPath: conflictPath(targetFile), sourceContent }];
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
47
+ throw error;
48
+ }
49
+ return [...state, { kind: 'copy', sourcePath: sourceFile, targetPath: targetFile, sourceContent }];
50
+ }
51
+ }, Promise.resolve([]));
52
+ };
53
+ export const previewVaultMigration = async (sourceVault, targetVault) => {
54
+ const source = await ensureVault(sourceVault);
55
+ const target = await ensureVault(targetVault);
56
+ if (source === target) {
57
+ return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
58
+ }
59
+ const actions = await planVaultMigration(source, target);
60
+ const copied = actions.filter((action) => action.kind === 'copy').length;
61
+ const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
62
+ const conflicted = actions.filter((action) => action.kind === 'conflict').length;
63
+ return { source, target, copied, unchanged, conflicted };
64
+ };
65
+ export const migrateVaultContent = async (sourceVault, targetVault) => {
66
+ const source = await ensureVault(sourceVault);
67
+ const target = await ensureVault(targetVault);
68
+ if (source === target) {
69
+ return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
70
+ }
71
+ const actions = await planVaultMigration(source, target);
72
+ for (const action of actions) {
73
+ if (action.kind === 'unchanged') {
74
+ continue;
75
+ }
76
+ await writeMigratedFile(targetVault, target, action.targetPath, action.sourceContent);
77
+ }
78
+ const copied = actions.filter((action) => action.kind === 'copy').length;
79
+ const unchanged = actions.filter((action) => action.kind === 'unchanged').length;
80
+ const conflicted = actions.filter((action) => action.kind === 'conflict').length;
81
+ return { source, target, copied, unchanged, conflicted };
82
+ };
83
+ export const shouldMigrateDefaultVault = async (sourceVault, targetVault) => {
84
+ const source = resolveVaultPath(sourceVault);
85
+ const target = resolveVaultPath(targetVault);
86
+ if (source === target) {
87
+ return false;
88
+ }
89
+ const [sourceFiles, targetFiles] = await Promise.all([listVaultFiles(source), listVaultFiles(target)]);
90
+ return sourceFiles.filter(isMarkdownPath).length > 0 && targetFiles.filter(isMarkdownPath).length === 0;
91
+ };
@@ -0,0 +1,12 @@
1
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
2
+ import { openFileIndex } from '../infrastructure/file-index.js';
3
+ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
4
+ const absoluteVaultPath = await ensureVault(vaultPath);
5
+ const index = openFileIndex(absoluteVaultPath);
6
+ try {
7
+ return await index.searchGraphNodeIds(query, limit, agentId);
8
+ }
9
+ finally {
10
+ index.close();
11
+ }
12
+ };
@@ -1,19 +1,89 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
2
- import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
3
+ import { ensurePrivatePacksFromLegacyIndex, searchInPacks } from '../infrastructure/search-packs.js';
4
+ import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
3
5
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
6
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
7
+ const hybridCacheTtlMs = 30_000;
8
+ const hybridCacheMaxEntries = 200;
9
+ const hybridSearchCache = new Map();
10
+ const readIndexMtimeMs = async (vaultPath) => {
11
+ try {
12
+ return (await stat(indexStoragePath(vaultPath))).mtimeMs;
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ };
18
+ const toCacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
19
+ vaultPath,
20
+ query: query.trim().toLowerCase(),
21
+ limit,
22
+ agentId: agentId?.trim().toLowerCase() ?? '*'
23
+ });
24
+ const cacheGet = (key, indexMtimeMs) => {
25
+ const entry = hybridSearchCache.get(key);
26
+ if (!entry) {
27
+ return undefined;
28
+ }
29
+ const fresh = Date.now() - entry.createdAt <= hybridCacheTtlMs && entry.indexMtimeMs === indexMtimeMs;
30
+ if (!fresh) {
31
+ hybridSearchCache.delete(key);
32
+ return undefined;
33
+ }
34
+ return entry.results;
35
+ };
36
+ const cacheSet = (entry) => {
37
+ hybridSearchCache.set(entry.key, entry);
38
+ if (hybridSearchCache.size <= hybridCacheMaxEntries) {
39
+ return;
40
+ }
41
+ const overflow = hybridSearchCache.size - hybridCacheMaxEntries;
42
+ const keys = Array.from(hybridSearchCache.keys()).slice(0, overflow);
43
+ keys.forEach((key) => hybridSearchCache.delete(key));
44
+ };
5
45
  export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) => {
6
46
  const absoluteVaultPath = await ensureVault(vaultPath);
7
47
  const config = await loadBrainlinkConfig();
8
48
  const searchMode = sanitizeSearchMode(mode, config.defaultSearchMode);
49
+ await ensurePrivatePacksFromLegacyIndex(absoluteVaultPath);
50
+ const cacheKey = searchMode === 'hybrid' ? toCacheKey(absoluteVaultPath, query, limit, agentId) : undefined;
51
+ const indexMtimeMs = cacheKey ? await readIndexMtimeMs(absoluteVaultPath) : 0;
52
+ const cached = cacheKey ? cacheGet(cacheKey, indexMtimeMs) : undefined;
53
+ if (cached) {
54
+ return cached;
55
+ }
9
56
  const provider = createEmbeddingProvider(config.embeddingProvider);
10
57
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
11
58
  const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
12
- const index = openSqliteIndex(absoluteVaultPath);
13
59
  try {
14
- return index.search(query, limit, agentId, searchMode, queryEmbedding);
60
+ const index = openFileIndex(absoluteVaultPath);
61
+ try {
62
+ const results = await index.search(query, limit, agentId, searchMode, queryEmbedding);
63
+ if (cacheKey) {
64
+ cacheSet({
65
+ key: cacheKey,
66
+ createdAt: Date.now(),
67
+ indexMtimeMs,
68
+ results
69
+ });
70
+ }
71
+ return results;
72
+ }
73
+ finally {
74
+ index.close();
75
+ }
15
76
  }
16
- finally {
17
- index.close();
77
+ catch {
78
+ const fallbackResults = await searchInPacks(absoluteVaultPath, query, limit, agentId);
79
+ if (cacheKey) {
80
+ cacheSet({
81
+ key: cacheKey,
82
+ createdAt: Date.now(),
83
+ indexMtimeMs,
84
+ results: fallbackResults
85
+ });
86
+ }
87
+ return fallbackResults;
18
88
  }
19
89
  };
@@ -1,9 +1,11 @@
1
1
  import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
2
2
  import { buildContextPackage } from '../build-context.js';
3
3
  import { getGraph } from '../get-graph.js';
4
+ import { getGraphNode } from '../get-graph-node.js';
4
5
  import { getGraphLayout } from '../get-graph-layout.js';
5
6
  import { listAgents } from '../list-agents.js';
6
7
  import { listBacklinks, listLinks } from '../list-links.js';
8
+ import { searchGraphNodeIds } from '../search-graph-node-ids.js';
7
9
  import { searchKnowledge } from '../search-knowledge.js';
8
10
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
9
11
  import { createClientCss } from '../frontend/client-css.js';
@@ -49,6 +51,10 @@ const sameEntityTag = (candidate, signature) => {
49
51
  return decodeEntityTag(candidate) === signature;
50
52
  };
51
53
  const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
54
+ const stripLayoutContent = (layout) => ({
55
+ ...layout,
56
+ nodes: layout.nodes.map(({ content, ...node }) => node)
57
+ });
52
58
  export const route = async (request, url, vaultPath) => {
53
59
  if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
54
60
  return createResponse(createClientHtml(), 200, contentTypes['.html']);
@@ -67,7 +73,7 @@ export const route = async (request, url, vaultPath) => {
67
73
  const requestEtags = request.headers['if-none-match'];
68
74
  const notModified = sameEntityTag(requestEtags, signature);
69
75
  const etag = encodeEntityTag(signature);
70
- const body = createJsonResponse({ signature, layout });
76
+ const body = createJsonResponse({ signature, layout: stripLayoutContent(layout) });
71
77
  const jsonResponse = createResponse(body, 200, contentTypes['.json']);
72
78
  const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
73
79
  if (notModified) {
@@ -87,6 +93,26 @@ export const route = async (request, url, vaultPath) => {
87
93
  }
88
94
  };
89
95
  }
96
+ if (isReadMethod(request) && url.pathname === '/api/graph-node') {
97
+ const id = url.searchParams.get('id')?.trim() ?? '';
98
+ if (!id) {
99
+ return createResponse(createJsonResponse({ error: 'Missing id query parameter' }), 400, contentTypes['.json']);
100
+ }
101
+ const node = await getGraphNode(vaultPath, id, readAgentQuery(url));
102
+ if (!node) {
103
+ return createResponse(createJsonResponse({ error: 'Node not found' }), 404, contentTypes['.json']);
104
+ }
105
+ return createResponse(createJsonResponse({ node }), 200, contentTypes['.json']);
106
+ }
107
+ if (isReadMethod(request) && url.pathname === '/api/graph-filter') {
108
+ const query = url.searchParams.get('q')?.trim() ?? '';
109
+ const limit = parsePositiveInteger(url.searchParams.get('limit'), 1200);
110
+ if (!query) {
111
+ return createResponse(createJsonResponse({ query, nodeIds: [] }), 200, contentTypes['.json']);
112
+ }
113
+ const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url));
114
+ return createResponse(createJsonResponse({ query, nodeIds }), 200, contentTypes['.json']);
115
+ }
90
116
  if (isReadMethod(request) && url.pathname === '/api/agents') {
91
117
  return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
92
118
  }
@@ -21,7 +21,7 @@ const readOptions = (args) => ({
21
21
  });
22
22
  const topics = [
23
23
  'authentication jwt token refresh policy',
24
- 'sqlite graph backlinks markdown vault indexing',
24
+ 'graph backlinks markdown vault indexing',
25
25
  'frontend canvas layout graph interaction',
26
26
  'agent memory context retrieval summarization',
27
27
  'security local server vault path allowlist',
@@ -0,0 +1,412 @@
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
+ await upsertCodexMcpConfig(codexConfigPath, {
145
+ allowedVaults,
146
+ brainlinkHome: input.brainlinkHome
147
+ });
148
+ const warnings = [];
149
+ const pluginSourcePath = input.pluginPath ? resolve(input.pluginPath) : getDefaultPluginSourcePath();
150
+ const pluginSymlinkPath = getPluginSymlinkPath();
151
+ const marketplacePath = getMarketplacePath();
152
+ if (input.mcpOnly !== true) {
153
+ try {
154
+ await ensurePluginSymlink(pluginSourcePath, pluginSymlinkPath);
155
+ await upsertMarketplacePlugin(marketplacePath);
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ warnings.push(`Plugin marketplace setup skipped: ${message}. MCP is configured, but install repository plugin files to enable local gallery auto-discovery.`);
160
+ }
161
+ }
162
+ const selfTestResult = input.selfTest
163
+ ? await (async () => {
164
+ const codexConfig = await readFile(codexConfigPath, 'utf8');
165
+ const mcp = isBrainlinkConfigured(codexConfig);
166
+ const mcpCommandInPath = await hasMcpCommandInPath();
167
+ const pluginSymlinkExists = input.mcpOnly === true
168
+ ? null
169
+ : await (async () => {
170
+ try {
171
+ return (await lstat(pluginSymlinkPath)).isSymbolicLink();
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ })();
177
+ const marketplaceEntryExists = input.mcpOnly === true
178
+ ? null
179
+ : (await loadMarketplace(marketplacePath)).plugins.some((plugin) => plugin?.name === 'brainlink');
180
+ return {
181
+ ok: mcp.hasMcpSection &&
182
+ mcp.hasCommand &&
183
+ mcpCommandInPath &&
184
+ (input.mcpOnly === true || (Boolean(pluginSymlinkExists) && Boolean(marketplaceEntryExists))),
185
+ mcpCommandInPath,
186
+ hasMcpSection: mcp.hasMcpSection,
187
+ hasCommand: mcp.hasCommand,
188
+ pluginSymlinkExists,
189
+ marketplaceEntryExists
190
+ };
191
+ })()
192
+ : undefined;
193
+ return {
194
+ installed: true,
195
+ codexConfigPath,
196
+ mcpServer: 'brainlink',
197
+ command: 'brainlink-mcp',
198
+ ...(input.mcpOnly !== true ? { pluginSourcePath, pluginSymlinkPath, marketplacePath } : {}),
199
+ ...(selfTestResult ? { selfTest: selfTestResult } : {}),
200
+ ...(warnings.length > 0 ? { warnings } : {})
201
+ };
202
+ };
203
+ const hasMcpCommandInPath = async () => {
204
+ try {
205
+ const { stdout } = await execFileAsync('sh', ['-lc', 'command -v brainlink-mcp'], { maxBuffer: 1024 * 1024 });
206
+ return stdout.trim().length > 0;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ };
212
+ const isBrainlinkConfigured = (codexConfig) => {
213
+ const hasMcpSection = codexConfig.includes('[mcp_servers.brainlink]');
214
+ const hasCommand = /(^|\n)\s*command\s*=\s*"brainlink-mcp"\s*(\n|$)/m.test(codexConfig);
215
+ return { hasMcpSection, hasCommand };
216
+ };
217
+ const parseBooleanOption = (value, name) => {
218
+ if (value === undefined) {
219
+ return undefined;
220
+ }
221
+ if (value === 'true') {
222
+ return true;
223
+ }
224
+ if (value === 'false') {
225
+ return false;
226
+ }
227
+ throw new Error(`Invalid value for ${name}: ${value}. Use true or false.`);
228
+ };
229
+ const parsePositiveIntegerOption = (value, name) => {
230
+ if (value === undefined) {
231
+ return undefined;
232
+ }
233
+ const parsed = Number.parseInt(value, 10);
234
+ if (!Number.isFinite(parsed) || parsed <= 0) {
235
+ throw new Error(`Invalid value for ${name}: ${value}. Use a positive integer.`);
236
+ }
237
+ return parsed;
238
+ };
239
+ const applyPolicyPreset = (preset) => {
240
+ if (!preset) {
241
+ return {};
242
+ }
243
+ if (preset === 'fully-auto') {
244
+ return {
245
+ enforceBootstrap: true,
246
+ enforceContextFirst: true,
247
+ autoBootstrapOnRead: true,
248
+ autoBootstrapOnStartup: true
249
+ };
250
+ }
251
+ if (preset === 'strict') {
252
+ return {
253
+ enforceBootstrap: true,
254
+ enforceContextFirst: true,
255
+ autoBootstrapOnRead: false,
256
+ autoBootstrapOnStartup: false
257
+ };
258
+ }
259
+ throw new Error(`Unknown policy preset: ${preset}. Use "fully-auto" or "strict".`);
260
+ };
261
+ export const registerAgentCommands = (program) => {
262
+ const agent = program.command('agent').description('install or inspect Brainlink agent integration');
263
+ agent
264
+ .command('install')
265
+ .option('--mcp-only', 'only configure MCP server in Codex config')
266
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
267
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
268
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
269
+ .option('--self-test', 'run post-install checks and include diagnostics in the result')
270
+ .option('--json', 'print machine-readable JSON')
271
+ .description('install Brainlink as default MCP memory integration for the local agent')
272
+ .action(async (options) => {
273
+ const result = await installAgentIntegration({
274
+ mcpOnly: options.mcpOnly,
275
+ pluginPath: options.pluginPath,
276
+ allowedVaults: options.allowedVaults,
277
+ brainlinkHome: options.brainlinkHome,
278
+ selfTest: options.selfTest
279
+ });
280
+ print(options.json, result, () => [
281
+ `Installed Brainlink MCP at ${result.codexConfigPath}`,
282
+ ...(options.mcpOnly === true ? [] : [`Plugin symlink: ${result.pluginSymlinkPath}`, `Marketplace: ${result.marketplacePath}`]),
283
+ ...(result.selfTest ? [`Self-test: ${result.selfTest.ok ? 'ok' : 'failed'}`] : []),
284
+ ...(result.warnings && result.warnings.length > 0 ? ['Warnings:', ...result.warnings.map((warning) => `- ${warning}`)] : [])
285
+ ].join('\n'));
286
+ });
287
+ agent
288
+ .command('upgrade')
289
+ .option('--mcp-only', 'only configure MCP server in Codex config')
290
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
291
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
292
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
293
+ .option('--json', 'print machine-readable JSON')
294
+ .description('reapply latest Brainlink agent integration defaults for legacy installs')
295
+ .action(async (options) => {
296
+ const result = await installAgentIntegration({
297
+ mcpOnly: options.mcpOnly,
298
+ pluginPath: options.pluginPath,
299
+ allowedVaults: options.allowedVaults,
300
+ brainlinkHome: options.brainlinkHome,
301
+ selfTest: true
302
+ });
303
+ print(options.json, {
304
+ upgraded: true,
305
+ ...result
306
+ }, () => `Upgraded Brainlink agent integration at ${result.codexConfigPath}. Self-test: ${result.selfTest?.ok ? 'ok' : 'failed'}`);
307
+ });
308
+ agent
309
+ .command('policy')
310
+ .option('--preset <preset>', 'policy preset: fully-auto or strict')
311
+ .option('--enforce-bootstrap <true|false>', 'override enforceBootstrap')
312
+ .option('--enforce-context-first <true|false>', 'override enforceContextFirst')
313
+ .option('--auto-bootstrap-on-read <true|false>', 'override autoBootstrapOnRead')
314
+ .option('--auto-bootstrap-on-startup <true|false>', 'override autoBootstrapOnStartup')
315
+ .option('--stale-after-minutes <minutes>', 'override staleAfterMinutes with positive integer')
316
+ .option('--json', 'print machine-readable JSON')
317
+ .description('read or update Brainlink MCP bootstrap policy')
318
+ .action(async (options) => {
319
+ const presetPatch = applyPolicyPreset(options.preset);
320
+ const enforceBootstrap = parseBooleanOption(options.enforceBootstrap, '--enforce-bootstrap');
321
+ const enforceContextFirst = parseBooleanOption(options.enforceContextFirst, '--enforce-context-first');
322
+ const autoBootstrapOnRead = parseBooleanOption(options.autoBootstrapOnRead, '--auto-bootstrap-on-read');
323
+ const autoBootstrapOnStartup = parseBooleanOption(options.autoBootstrapOnStartup, '--auto-bootstrap-on-startup');
324
+ const staleAfterMinutes = parsePositiveIntegerOption(options.staleAfterMinutes, '--stale-after-minutes');
325
+ const overridePatch = {
326
+ ...(enforceBootstrap !== undefined ? { enforceBootstrap } : {}),
327
+ ...(enforceContextFirst !== undefined ? { enforceContextFirst } : {}),
328
+ ...(autoBootstrapOnRead !== undefined ? { autoBootstrapOnRead } : {}),
329
+ ...(autoBootstrapOnStartup !== undefined ? { autoBootstrapOnStartup } : {}),
330
+ ...(staleAfterMinutes !== undefined ? { staleAfterMinutes } : {})
331
+ };
332
+ const patch = {
333
+ ...presetPatch,
334
+ ...overridePatch
335
+ };
336
+ const policy = Object.keys(patch).length === 0 ? await getBootstrapPolicy() : await setBootstrapPolicy(patch);
337
+ print(options.json, {
338
+ policy,
339
+ ...(options.preset ? { presetApplied: options.preset } : {})
340
+ }, () => [
341
+ ...(options.preset ? [`presetApplied=${options.preset}`] : []),
342
+ `enforceBootstrap=${policy.enforceBootstrap}`,
343
+ `enforceContextFirst=${policy.enforceContextFirst}`,
344
+ `autoBootstrapOnRead=${policy.autoBootstrapOnRead}`,
345
+ `autoBootstrapOnStartup=${policy.autoBootstrapOnStartup}`,
346
+ `staleAfterMinutes=${policy.staleAfterMinutes}`
347
+ ].join('\n'));
348
+ });
349
+ agent
350
+ .command('status')
351
+ .option('-a, --agent <agent>', 'agent memory namespace for bootstrap session status')
352
+ .option('--json', 'print machine-readable JSON')
353
+ .description('check if Brainlink MCP integration is configured for the local agent')
354
+ .action(async (options) => {
355
+ const codexConfigPath = getCodexConfigPath();
356
+ let codexConfig = '';
357
+ try {
358
+ codexConfig = await readFile(codexConfigPath, 'utf8');
359
+ }
360
+ catch (error) {
361
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
362
+ throw error;
363
+ }
364
+ }
365
+ const { hasMcpSection, hasCommand } = isBrainlinkConfigured(codexConfig);
366
+ const pluginSymlinkPath = getPluginSymlinkPath();
367
+ const marketplacePath = getMarketplacePath();
368
+ let pluginSymlinkExists = false;
369
+ let marketplaceEntryExists = false;
370
+ try {
371
+ pluginSymlinkExists = (await lstat(pluginSymlinkPath)).isSymbolicLink();
372
+ }
373
+ catch { }
374
+ try {
375
+ const marketplace = await loadMarketplace(marketplacePath);
376
+ marketplaceEntryExists = marketplace.plugins.some((plugin) => plugin?.name === 'brainlink');
377
+ }
378
+ catch { }
379
+ const config = await loadBrainlinkConfig();
380
+ const policy = await getBootstrapPolicy();
381
+ const bootstrapStatus = await getBootstrapSessionStatus(config.vault, options.agent ?? config.defaultAgent);
382
+ const contextStatus = await getContextSessionStatus(config.vault, options.agent ?? config.defaultAgent);
383
+ const sessionStatePath = getSessionStatePath();
384
+ print(options.json, {
385
+ configured: hasMcpSection && hasCommand,
386
+ codexConfigPath,
387
+ hasMcpSection,
388
+ hasCommand,
389
+ pluginSymlinkPath,
390
+ pluginSymlinkExists,
391
+ marketplacePath,
392
+ marketplaceEntryExists,
393
+ sessionStatePath,
394
+ vault: config.vault,
395
+ agent: options.agent ?? config.defaultAgent ?? '*',
396
+ bootstrapPolicy: policy,
397
+ bootstrapStatus,
398
+ contextStatus
399
+ }, () => [
400
+ `codexConfigPath=${codexConfigPath}`,
401
+ `configured=${hasMcpSection && hasCommand}`,
402
+ `pluginSymlinkExists=${pluginSymlinkExists}`,
403
+ `marketplaceEntryExists=${marketplaceEntryExists}`,
404
+ `vault=${config.vault}`,
405
+ `agent=${options.agent ?? config.defaultAgent ?? '*'}`,
406
+ `bootstrapReady=${bootstrapStatus.ready}`,
407
+ `bootstrapStale=${bootstrapStatus.stale}`,
408
+ `contextReady=${contextStatus.ready}`,
409
+ `contextStale=${contextStatus.stale}`
410
+ ].join('\n'));
411
+ });
412
+ };