@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.
- package/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- 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[];
|