@kinqs/brainrouter-cli 0.3.4

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 (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
package/dist/index.js ADDED
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import url from 'node:url';
5
+ import { Command } from 'commander';
6
+ import inquirer from 'inquirer';
7
+ import chalk from 'chalk';
8
+ import { loadConfig, saveConfig } from './config/config.js';
9
+ import { McpClientWrapper } from './runtime/mcpClient.js';
10
+ import { Agent } from './agent/agent.js';
11
+ import { startREPL } from './cli/repl.js';
12
+ import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
13
+ /**
14
+ * Load `.env` files into the CLI's `process.env`.
15
+ *
16
+ * The CLI and the MCP server have separate concerns and now ship separate
17
+ * config files:
18
+ *
19
+ * - `brainrouter-cli/.env` — CLI-only knobs (chat LLM, tool loop,
20
+ * sandbox, web search, trace log).
21
+ * - `brainrouter/.env` — MCP-only knobs (extraction LLM, embeddings,
22
+ * reranker, memory engine, server auth).
23
+ *
24
+ * Loading order:
25
+ * 1) `brainrouter-cli/.env` (PRIMARY for CLI process).
26
+ * 2) `brainrouter/.env` (FALLBACK — only for the LLM credentials, so
27
+ * a user who set up only the MCP config still
28
+ * gets a working CLI agent and vice versa).
29
+ *
30
+ * Shell env (anything already in `process.env`) wins over both — explicit
31
+ * env > .env file, as is conventional.
32
+ *
33
+ * The MCP child uses `import "dotenv/config"` which resolves relative to
34
+ * `process.cwd()`. The CLI sets the spawned child's cwd to the MCP package
35
+ * directory (see runtime/mcpClient.ts), so `brainrouter/.env` is loaded by
36
+ * the child directly — the CLI does NOT need to pre-load it for the MCP's
37
+ * sake.
38
+ */
39
+ /**
40
+ * Vars the CLI process consumes from a sibling `brainrouter/.env` fallback.
41
+ *
42
+ * LLM credentials are deliberately EXCLUDED — `~/.config/brainrouter/config.json`
43
+ * is the canonical source for chat-LLM creds, endpoint, and model (set via
44
+ * `brainrouter login` or `brainrouter config`). Pulling them from `.env` in
45
+ * parallel created a silent precedence bug: env would shadow `config.json`
46
+ * because `loadBrainrouterEnv()` runs at module-load time before
47
+ * `loadConfig()`, and downstream callers like `mcpClient.connect()` check
48
+ * `mergedEnv.BRAINROUTER_LLM_ENDPOINT` before falling back to `llmConfig`.
49
+ *
50
+ * The only var we still allow through the fallback is `BRAINROUTER_API_KEY`
51
+ * — that's MCP-server auth (not LLM), and stdio mode propagates it from the
52
+ * CLI's process.env into the spawned child. If your `config.json` server
53
+ * profile already carries the API key in its `env` block, you don't need
54
+ * this fallback either, and it can go away in a follow-up cleanup.
55
+ *
56
+ * Anything outside this set is a pure MCP-server knob (embedding endpoint,
57
+ * JWT secret, extraction sweep config, prewarming, graph timeouts, admin
58
+ * creds) that just pollutes the CLI's environment with no effect — the MCP
59
+ * child loads `brainrouter/.env` directly via its own `dotenv/config`.
60
+ */
61
+ const CLI_FALLBACK_ALLOWLIST = new Set([
62
+ 'BRAINROUTER_API_KEY',
63
+ ]);
64
+ function loadEnvFile(file, allowlist) {
65
+ try {
66
+ const raw = fs.readFileSync(file, 'utf8');
67
+ let count = 0;
68
+ for (const line of raw.split('\n')) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed || trimmed.startsWith('#'))
71
+ continue;
72
+ const eq = trimmed.indexOf('=');
73
+ if (eq <= 0)
74
+ continue;
75
+ const key = trimmed.slice(0, eq).trim();
76
+ let value = trimmed.slice(eq + 1).trim();
77
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
78
+ value = value.slice(1, -1);
79
+ }
80
+ // Allowlist gate: when loading the MCP fallback file, only adopt vars
81
+ // the CLI actually reads. Primary CLI .env loads pass no allowlist and
82
+ // accept everything (it's the CLI's own config).
83
+ if (allowlist && !allowlist.has(key))
84
+ continue;
85
+ if (key && !(key in process.env)) {
86
+ process.env[key] = value;
87
+ count++;
88
+ }
89
+ }
90
+ return count;
91
+ }
92
+ catch {
93
+ return 0;
94
+ }
95
+ }
96
+ function loadBrainrouterEnv() {
97
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
98
+ let count = 0;
99
+ let primary;
100
+ let fallback;
101
+ // PRIMARY: brainrouter-cli/.env (this package's own config).
102
+ // dist/index.js → ../.. = brainrouter-cli/, so .env sits next to package.json.
103
+ const cliCandidates = [
104
+ path.resolve(here, '..', '..', '.env'), // monorepo: brainrouter-cli/.env
105
+ path.resolve(here, '..', '..', '..', 'brainrouter-cli', '.env'), // installed/nested
106
+ path.resolve(process.cwd(), 'brainrouter-cli', '.env'), // running from repo root
107
+ ];
108
+ for (const file of cliCandidates) {
109
+ if (fs.existsSync(file)) {
110
+ primary = file;
111
+ count += loadEnvFile(file);
112
+ break;
113
+ }
114
+ }
115
+ // FALLBACK: brainrouter/.env (MCP-side config). Only used to backstop the
116
+ // LLM credentials so a partial setup still works. The MCP child loads
117
+ // brainrouter/.env on its own anyway via cwd hint, so we don't need to
118
+ // import its server-only knobs (embedding endpoint, JWT secret, sweep
119
+ // intervals, prewarming) — those just clutter the CLI's process.env. The
120
+ // allowlist limits the fallback to vars the CLI actually reads.
121
+ //
122
+ // Only record the fallback in the result when it actually contributed at
123
+ // least one new var. If the primary file already set all the LLM creds,
124
+ // mentioning the fallback path in the startup banner is noise — the user
125
+ // already has the CLI fully configured locally and doesn't need to know
126
+ // a sibling .env was read but ignored.
127
+ const mcpCandidates = [
128
+ path.resolve(here, '..', '..', '..', 'brainrouter', '.env'),
129
+ path.resolve(process.cwd(), 'brainrouter', '.env'),
130
+ ];
131
+ for (const file of mcpCandidates) {
132
+ if (fs.existsSync(file)) {
133
+ const added = loadEnvFile(file, CLI_FALLBACK_ALLOWLIST);
134
+ if (added > 0) {
135
+ fallback = file;
136
+ count += added;
137
+ }
138
+ break;
139
+ }
140
+ }
141
+ return { primary, fallback, count };
142
+ }
143
+ const envLoadResult = loadBrainrouterEnv();
144
+ if (envLoadResult.primary || envLoadResult.fallback) {
145
+ // Something contributed at least one var — show what loaded so the user can
146
+ // trace where runtime knobs (sandbox, timeouts, trace log, web search) are
147
+ // coming from. LLM creds intentionally do NOT flow through this path; they
148
+ // live in ~/.config/brainrouter/config.json.
149
+ const sources = [];
150
+ if (envLoadResult.primary)
151
+ sources.push(envLoadResult.primary);
152
+ if (envLoadResult.fallback)
153
+ sources.push(`${envLoadResult.fallback} (fallback)`);
154
+ const tag = envLoadResult.count > 0
155
+ ? chalk.gray(` (${envLoadResult.count} new var${envLoadResult.count === 1 ? '' : 's'})`)
156
+ : chalk.gray(' (all keys already set in shell)');
157
+ console.error(chalk.gray(`env: loaded ${sources.join(', ')}`) + tag);
158
+ }
159
+ // No banner when nothing loaded — that's the normal case for users who
160
+ // configured the CLI via `brainrouter login` / `brainrouter config`. The old
161
+ // "set BRAINROUTER_LLM_API_KEY in your shell" hint contradicted the
162
+ // config.json-is-canonical design and confused users who already had a
163
+ // fully populated config.
164
+ const program = new Command();
165
+ program
166
+ .name('brainrouter')
167
+ .description('BrainRouter CLI — Premium interactive terminal-based agent client.')
168
+ .version('0.3.4');
169
+ // Chat Command (default)
170
+ program
171
+ .command('chat', { isDefault: true })
172
+ .description('Start interactive agent REPL chat session (default)')
173
+ .option('-p, --profile <name>', 'Connection profile name')
174
+ .option('-m, --model <name>', 'LLM model override')
175
+ .option('-w, --workspace <path>', 'Workspace root for files, commands, memory session, and MCP --root')
176
+ .option('--strict-mcp', 'Exit if the MCP server is unreachable (default: continue in offline mode with local tools only)')
177
+ .action(async (options) => {
178
+ if (options.workspace) {
179
+ process.env.BRAINROUTER_WORKSPACE = options.workspace;
180
+ }
181
+ const workspace = findWorkspaceRoot();
182
+ applyWorkspaceRoot(workspace.workspaceRoot);
183
+ console.log(chalk.gray(`Workspace: ${workspace.workspaceRoot} (${workspace.reason})`));
184
+ const config = loadConfig();
185
+ const profileName = options.profile || config.activeServer;
186
+ const configuredServer = config.servers[profileName];
187
+ if (!configuredServer) {
188
+ console.error(chalk.red(`Error: Profile "${profileName}" not found in config.`));
189
+ process.exit(1);
190
+ }
191
+ const serverConfig = { ...configuredServer };
192
+ if (serverConfig.type === 'stdio') {
193
+ const args = serverConfig.args ?? [];
194
+ const rootIndex = args.indexOf('--root');
195
+ serverConfig.args = rootIndex >= 0
196
+ ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
197
+ : [...args, '--root', workspace.workspaceRoot];
198
+ }
199
+ config.servers[profileName] = serverConfig;
200
+ const llm = config.llm || {
201
+ provider: 'openai',
202
+ model: 'gpt-4o-mini',
203
+ apiKey: ''
204
+ };
205
+ if (options.model) {
206
+ llm.model = options.model;
207
+ }
208
+ const mcpClient = new McpClientWrapper();
209
+ console.log(chalk.gray(`Connecting to MCP server profile "${profileName}"...`));
210
+ try {
211
+ await mcpClient.connect(serverConfig, llm);
212
+ console.log(chalk.green('Successfully connected to BrainRouter MCP Server!'));
213
+ }
214
+ catch (err) {
215
+ // Degraded "offline mode": the MCP server is the cognitive memory layer
216
+ // (recall, skills, capture, citations) — losing it is painful but not
217
+ // fatal. Local tools (read_file, write_file, list_dir, grep_search,
218
+ // run_command, spawn_agent) still work, and the agent's runTurn already
219
+ // try/catches every MCP call. Keep the REPL up so the user can edit
220
+ // code, drive shell commands, and recover when the server comes back.
221
+ // Pass --strict-mcp to flip back to hard-fail (useful in CI).
222
+ console.error(chalk.red(`Failed to connect to MCP server: ${err.message}`));
223
+ if (options.strictMcp) {
224
+ console.error(chalk.gray('--strict-mcp set; exiting.'));
225
+ process.exit(1);
226
+ }
227
+ console.warn(chalk.yellow('⚠️ Continuing in OFFLINE MODE — memory recall, skills, and capture are disabled.\n' +
228
+ ' Local tools (file edits, shell, web fetch, spawn_agent) remain available.\n' +
229
+ ' Start the MCP server and restart the CLI to restore full functionality.\n'));
230
+ }
231
+ const agent = new Agent(mcpClient, llm, {
232
+ workspaceRoot: workspace.workspaceRoot,
233
+ launchCwd: workspace.launchCwd,
234
+ });
235
+ startREPL(agent, mcpClient, config, workspace);
236
+ });
237
+ // One-shot non-interactive run — pipe-friendly for scripting/CI.
238
+ // brainrouter run "summarize the changes in src/"
239
+ // echo "what is this repo?" | brainrouter run -
240
+ // brainrouter run --print "..." → print answer only
241
+ // brainrouter run --json "..." → JSON-line with answer + usage
242
+ program
243
+ .command('run [prompt...]')
244
+ .description('Run a single agent turn non-interactively and print the answer (use "-" to read prompt from stdin)')
245
+ .option('-p, --profile <name>', 'Connection profile name')
246
+ .option('-m, --model <name>', 'LLM model override')
247
+ .option('-w, --workspace <path>', 'Workspace root')
248
+ .option('--print', 'Print the answer text only, no chrome')
249
+ .option('--json', 'Emit one JSON line { answer, usage, durationMs, sessionKey }')
250
+ .option('--session <key>', 'Resume a specific sessionKey')
251
+ .option('--timeout <ms>', 'LLM request timeout in ms')
252
+ .option('--strict-mcp', 'Exit if the MCP server is unreachable (default: continue in offline mode with local tools only)')
253
+ .action(async (promptParts, options) => {
254
+ if (options.workspace)
255
+ process.env.BRAINROUTER_WORKSPACE = options.workspace;
256
+ if (options.timeout)
257
+ process.env.BRAINROUTER_LLM_TIMEOUT_MS = String(options.timeout);
258
+ let prompt = (promptParts ?? []).join(' ').trim();
259
+ if (prompt === '-' || !prompt) {
260
+ // Read from stdin
261
+ prompt = await new Promise((resolve) => {
262
+ let buf = '';
263
+ process.stdin.setEncoding('utf8');
264
+ process.stdin.on('data', (chunk) => { buf += chunk; });
265
+ process.stdin.on('end', () => resolve(buf.trim()));
266
+ });
267
+ }
268
+ if (!prompt) {
269
+ console.error('Error: no prompt provided (pass as args or via stdin).');
270
+ process.exit(2);
271
+ }
272
+ // Reject slash commands in headless mode. The REPL handles them via
273
+ // handleSlashCommand, but `run` skips straight to agent.runTurn — so a
274
+ // user piping `/help` or `/sessions` was silently routed to the LLM and
275
+ // got back a confused chat response instead of a real CLI error.
276
+ // Headless mode now exits with a real error instead of consuming a turn.
277
+ if (prompt.startsWith('/')) {
278
+ const cmdName = prompt.split(/\s+/)[0];
279
+ console.error(`Error: slash commands are not supported in 'run' (headless) mode. ` +
280
+ `"${cmdName}" must be invoked from the interactive REPL (run \`brainrouter\` with no args).`);
281
+ console.error(`Hint: if you meant to send "${cmdName}" as a literal prompt, escape it with a leading space.`);
282
+ process.exit(2);
283
+ }
284
+ const workspace = findWorkspaceRoot();
285
+ applyWorkspaceRoot(workspace.workspaceRoot);
286
+ const config = loadConfig();
287
+ const profileName = options.profile || config.activeServer;
288
+ const serverConfig = { ...config.servers[profileName] };
289
+ if (!serverConfig) {
290
+ console.error(`Error: Profile "${profileName}" not found.`);
291
+ process.exit(1);
292
+ }
293
+ if (serverConfig.type === 'stdio') {
294
+ const args = serverConfig.args ?? [];
295
+ const rootIndex = args.indexOf('--root');
296
+ serverConfig.args = rootIndex >= 0
297
+ ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
298
+ : [...args, '--root', workspace.workspaceRoot];
299
+ }
300
+ const llm = config.llm ?? { provider: 'openai', model: 'gpt-4o-mini', apiKey: '' };
301
+ if (options.model)
302
+ llm.model = options.model;
303
+ const mcpClient = new McpClientWrapper();
304
+ try {
305
+ await mcpClient.connect(serverConfig, llm);
306
+ }
307
+ catch (err) {
308
+ console.error(`MCP connect failed: ${err.message}`);
309
+ if (options.strictMcp)
310
+ process.exit(1);
311
+ // Offline mode for one-shot: same rationale as the chat command — local
312
+ // tools still work, MCP-backed calls return error envelopes the agent
313
+ // already tolerates. Useful when piping a quick "read this file and
314
+ // summarize" while the MCP server is down. CI can pass --strict-mcp.
315
+ console.error('Continuing in offline mode (no memory recall / skills). Pass --strict-mcp to exit instead.');
316
+ }
317
+ const agent = new Agent(mcpClient, llm, {
318
+ workspaceRoot: workspace.workspaceRoot,
319
+ launchCwd: workspace.launchCwd,
320
+ sessionKey: options.session,
321
+ });
322
+ const startedAt = Date.now();
323
+ let answer = '';
324
+ try {
325
+ answer = await agent.runTurn(prompt, {
326
+ onStatusUpdate: () => { },
327
+ onToolStart: (name) => { if (!options.print && !options.json)
328
+ process.stderr.write(` · ${name}\n`); },
329
+ onToolEnd: () => { },
330
+ });
331
+ }
332
+ catch (err) {
333
+ console.error(`run failed: ${err.message}`);
334
+ await mcpClient.close();
335
+ process.exit(1);
336
+ }
337
+ const durationMs = Date.now() - startedAt;
338
+ await mcpClient.close();
339
+ if (options.json) {
340
+ process.stdout.write(JSON.stringify({
341
+ answer,
342
+ sessionKey: agent.sessionKey,
343
+ usage: agent.lastTurnUsage,
344
+ durationMs,
345
+ }) + '\n');
346
+ }
347
+ else {
348
+ process.stdout.write(answer + (answer.endsWith('\n') ? '' : '\n'));
349
+ if (!options.print) {
350
+ const u = agent.lastTurnUsage;
351
+ process.stderr.write(`\n[done · ${Math.round(durationMs / 1000)}s · ${u.promptTokens} in / ${u.completionTokens} out across ${u.calls} call${u.calls === 1 ? '' : 's'}]\n`);
352
+ }
353
+ }
354
+ process.exit(0);
355
+ });
356
+ // Login Command
357
+ program
358
+ .command('login')
359
+ .description('Configure and authenticate connection to a hosted HTTP/SSE BrainRouter server')
360
+ .action(async () => {
361
+ console.log(chalk.bold.hex('#CC9166')('\n🔑 hosted BrainRouter Authentication Setup'));
362
+ const answers = await inquirer.prompt([
363
+ {
364
+ type: 'input',
365
+ name: 'url',
366
+ message: 'Enter BrainRouter HTTP/SSE MCP Endpoint URL:',
367
+ default: 'http://localhost:3747/mcp',
368
+ validate: (input) => {
369
+ try {
370
+ new URL(input);
371
+ return true;
372
+ }
373
+ catch {
374
+ return 'Please enter a valid URL (e.g. http://localhost:3747/mcp)';
375
+ }
376
+ }
377
+ },
378
+ {
379
+ type: 'input',
380
+ name: 'apiKey',
381
+ message: 'Enter Authorization / API Key (leave empty if none):',
382
+ },
383
+ {
384
+ type: 'input',
385
+ name: 'profileName',
386
+ message: 'Enter profile name to save this connection as:',
387
+ default: 'hosted-team',
388
+ validate: (input) => input.trim() ? true : 'Profile name cannot be empty.'
389
+ }
390
+ ]);
391
+ const mcpClient = new McpClientWrapper();
392
+ const spinner = inquirer.ui.BottomBar ? null : console.log(chalk.gray('Testing connection...'));
393
+ try {
394
+ await mcpClient.connect({
395
+ type: 'http',
396
+ url: answers.url,
397
+ apiKey: answers.apiKey || undefined
398
+ });
399
+ await mcpClient.close();
400
+ // Save to config
401
+ const config = loadConfig();
402
+ config.servers[answers.profileName] = {
403
+ type: 'http',
404
+ url: answers.url,
405
+ apiKey: answers.apiKey || undefined
406
+ };
407
+ config.activeServer = answers.profileName;
408
+ saveConfig(config);
409
+ console.log(chalk.green(`\n✔ Successfully connected and saved profile "${answers.profileName}"!`));
410
+ console.log(`Set "${answers.profileName}" as the active connection profile.\n`);
411
+ }
412
+ catch (err) {
413
+ console.error(chalk.red(`\n✖ Connection test failed: ${err.message}`));
414
+ console.log(chalk.yellow('No profile changes were saved. Check the URL and credentials and try again.\n'));
415
+ }
416
+ });
417
+ // Config Command
418
+ program
419
+ .command('config')
420
+ .description('Interactively configure your LLM provider and MCP servers')
421
+ .action(async () => {
422
+ const config = loadConfig();
423
+ const menu = await inquirer.prompt([
424
+ {
425
+ type: 'list',
426
+ name: 'action',
427
+ message: 'Select configuration action:',
428
+ choices: [
429
+ 'Configure LLM Provider',
430
+ 'Configure Server Profile',
431
+ 'Set Active Server Profile',
432
+ 'View Configuration',
433
+ 'Cancel'
434
+ ]
435
+ }
436
+ ]);
437
+ if (menu.action === 'Configure LLM Provider') {
438
+ const llmAnswers = await inquirer.prompt([
439
+ {
440
+ type: 'input',
441
+ name: 'apiKey',
442
+ message: 'Enter LLM API Key (leave blank to use system env variables or local endpoints):',
443
+ default: config.llm?.apiKey || ''
444
+ },
445
+ {
446
+ type: 'input',
447
+ name: 'model',
448
+ message: 'Enter LLM Model (e.g. gpt-4o-mini, llama3):',
449
+ default: config.llm?.model || 'gpt-4o-mini'
450
+ },
451
+ {
452
+ type: 'input',
453
+ name: 'endpoint',
454
+ message: 'Enter Custom API Endpoint URL (optional, e.g. for Ollama/LM Studio):',
455
+ default: config.llm?.endpoint || ''
456
+ }
457
+ ]);
458
+ config.llm = {
459
+ provider: 'openai',
460
+ apiKey: llmAnswers.apiKey,
461
+ model: llmAnswers.model,
462
+ endpoint: llmAnswers.endpoint || undefined
463
+ };
464
+ saveConfig(config);
465
+ console.log(chalk.green('\n✔ LLM configuration updated successfully!\n'));
466
+ }
467
+ else if (menu.action === 'Configure Server Profile') {
468
+ const typeAnswer = await inquirer.prompt([
469
+ {
470
+ type: 'list',
471
+ name: 'type',
472
+ message: 'Select connection type:',
473
+ choices: ['stdio', 'http']
474
+ }
475
+ ]);
476
+ let serverOpts = { type: typeAnswer.type };
477
+ if (typeAnswer.type === 'stdio') {
478
+ const stdioAnswers = await inquirer.prompt([
479
+ {
480
+ type: 'input',
481
+ name: 'command',
482
+ message: 'Enter executable command (e.g., node, npx):',
483
+ default: 'node'
484
+ },
485
+ {
486
+ type: 'input',
487
+ name: 'args',
488
+ message: 'Enter space-separated arguments (e.g. dist/index.js --root .):',
489
+ }
490
+ ]);
491
+ serverOpts.command = stdioAnswers.command;
492
+ serverOpts.args = stdioAnswers.args.trim() ? stdioAnswers.args.split(' ') : [];
493
+ }
494
+ else {
495
+ const httpAnswers = await inquirer.prompt([
496
+ {
497
+ type: 'input',
498
+ name: 'url',
499
+ message: 'Enter Server URL (e.g., http://localhost:3747/mcp):',
500
+ default: 'http://localhost:3747/mcp'
501
+ },
502
+ {
503
+ type: 'input',
504
+ name: 'apiKey',
505
+ message: 'Enter API authorization key (if any):'
506
+ }
507
+ ]);
508
+ serverOpts.url = httpAnswers.url;
509
+ serverOpts.apiKey = httpAnswers.apiKey || undefined;
510
+ }
511
+ const nameAnswer = await inquirer.prompt([
512
+ {
513
+ type: 'input',
514
+ name: 'name',
515
+ message: 'Enter profile name for this server:',
516
+ default: 'custom-server'
517
+ }
518
+ ]);
519
+ config.servers[nameAnswer.name] = serverOpts;
520
+ saveConfig(config);
521
+ console.log(chalk.green(`\n✔ Server profile "${nameAnswer.name}" saved successfully!\n`));
522
+ }
523
+ else if (menu.action === 'Set Active Server Profile') {
524
+ const activeChoices = Object.keys(config.servers);
525
+ if (activeChoices.length === 0) {
526
+ console.log(chalk.red('\nNo server profiles exist. Create one first.\n'));
527
+ return;
528
+ }
529
+ const activeAnswers = await inquirer.prompt([
530
+ {
531
+ type: 'list',
532
+ name: 'active',
533
+ message: 'Select active server profile:',
534
+ choices: activeChoices,
535
+ default: config.activeServer
536
+ }
537
+ ]);
538
+ config.activeServer = activeAnswers.active;
539
+ saveConfig(config);
540
+ console.log(chalk.green(`\n✔ Active server profile set to "${activeAnswers.active}"!\n`));
541
+ }
542
+ else if (menu.action === 'View Configuration') {
543
+ console.log(chalk.bold('\n⚙️ Current configuration:'));
544
+ const scrubbed = JSON.parse(JSON.stringify(config));
545
+ if (scrubbed.llm?.apiKey)
546
+ scrubbed.llm.apiKey = 'br_••••••••';
547
+ for (const s of Object.values(scrubbed.servers)) {
548
+ const srv = s;
549
+ if (srv.apiKey)
550
+ srv.apiKey = 'br_••••••••';
551
+ if (srv.env?.BRAINROUTER_API_KEY)
552
+ srv.env.BRAINROUTER_API_KEY = 'br_••••••••';
553
+ }
554
+ console.log(chalk.gray(JSON.stringify(scrubbed, null, 2)));
555
+ console.log();
556
+ }
557
+ });
558
+ // `brainrouter agents` — list live + recent child sessions without entering the REPL.
559
+ // Lets scripting integrations (tmux-resurrect, status bars, agent pickers) pull
560
+ // the list without an interactive session. `--json` for machine-readable;
561
+ // default is human-readable.
562
+ program
563
+ .command('agents')
564
+ .description('List child agent sessions (workspace-scoped)')
565
+ .option('--json', 'Emit a single JSON line on stdout for scripting')
566
+ .option('-w, --workspace <path>', 'Workspace root override')
567
+ .action(async (options) => {
568
+ if (options.workspace)
569
+ process.env.BRAINROUTER_WORKSPACE = options.workspace;
570
+ const workspace = findWorkspaceRoot();
571
+ applyWorkspaceRoot(workspace.workspaceRoot);
572
+ // Reconcile + list happens locally — no MCP needed.
573
+ const { reconcileStale, listSessions } = await import('./orchestration/orchestrator.js');
574
+ reconcileStale(workspace.workspaceRoot);
575
+ const sessions = listSessions(workspace.workspaceRoot);
576
+ if (options.json) {
577
+ const payload = sessions.map((s) => ({
578
+ id: s.id,
579
+ role: s.role,
580
+ status: s.status,
581
+ label: s.label,
582
+ startedAt: s.startedAt,
583
+ updatedAt: s.updatedAt,
584
+ completedAt: s.completedAt,
585
+ prompt: s.prompt,
586
+ usage: s.usage,
587
+ parentSessionKey: s.parentSessionKey,
588
+ finalOutputPreview: s.finalOutput ? String(s.finalOutput).slice(0, 280) : undefined,
589
+ }));
590
+ process.stdout.write(JSON.stringify({ sessions: payload }) + '\n');
591
+ return;
592
+ }
593
+ if (sessions.length === 0) {
594
+ console.log(chalk.yellow('No child agents yet.'));
595
+ console.log(chalk.gray('Start one from the REPL with: /spawn <role> <prompt>'));
596
+ return;
597
+ }
598
+ console.log(chalk.bold(`\nChild Agent Sessions (${sessions.length}):`));
599
+ for (const s of sessions) {
600
+ const status = s.status === 'completed' ? chalk.green(s.status)
601
+ : s.status === 'failed' ? chalk.red(s.status)
602
+ : s.status === 'stale' ? chalk.yellow(s.status)
603
+ : s.status === 'closed' ? chalk.gray(s.status) : chalk.cyan(s.status);
604
+ console.log(` ${status} ${chalk.cyan(s.id)} ${chalk.magenta(s.role)} ${chalk.gray(s.startedAt)}`);
605
+ if (s.prompt)
606
+ console.log(chalk.gray(` ${s.prompt.replace(/\s+/g, ' ').slice(0, 100)}`));
607
+ }
608
+ console.log();
609
+ });
610
+ program.parse(process.argv);
@@ -0,0 +1,46 @@
1
+ import type { McpClientWrapper } from '../runtime/mcpClient.js';
2
+ export interface BriefingInputs {
3
+ mcpClient: McpClientWrapper;
4
+ mcpTools: Array<{
5
+ name: string;
6
+ }>;
7
+ sessionKey: string;
8
+ workspaceRoot: string;
9
+ query: string;
10
+ activeSkill?: string;
11
+ /** Cap on injected briefing content per source — guards against runaway payloads eating the context window. */
12
+ maxCharsPerSource?: number;
13
+ }
14
+ export interface RecalledRecord {
15
+ recordId: string;
16
+ content?: string;
17
+ type?: string;
18
+ priority?: number;
19
+ }
20
+ export interface BriefingResult {
21
+ /** A single markdown block to be injected as a system message before the turn. Empty if nothing was recalled. */
22
+ block: string;
23
+ /** Recalled record IDs, used downstream for memory_mark_cited. */
24
+ recalledRecordIds: string[];
25
+ /** Recalled record content snippets, used for the citation heuristic. */
26
+ recalledRecords: RecalledRecord[];
27
+ /** Names of MCP tools we actually consulted (for telemetry / /briefing). */
28
+ sourcesQueried: string[];
29
+ }
30
+ /**
31
+ * Run pre-turn memory queries in parallel and assemble a compact briefing block.
32
+ * This is the System-1 entry point: every turn pays a small fixed cost to ask
33
+ * the BrainRouter brain "what do I already know that matters here?" so the LLM
34
+ * does not redo work the agent has done before in this workspace.
35
+ */
36
+ export declare function buildMemoryBriefing(inputs: BriefingInputs): Promise<BriefingResult>;
37
+ /**
38
+ * Heuristic for which recalled records actually informed the assistant's
39
+ * final answer. We mark a record as "cited" when:
40
+ * - its recordId literally appears in the answer text, OR
41
+ * - a distinctive snippet (≥ 24 chars of non-trivial content) from its
42
+ * content appears verbatim in the answer.
43
+ * Conservative on purpose — false positives hurt memory quality more than
44
+ * false negatives, since uncited records get demoted next time around.
45
+ */
46
+ export declare function selectCitedRecordIds(records: RecalledRecord[], finalAnswer: string): string[];