@kinqs/brainrouter-cli 0.3.5 → 0.3.6
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 +55 -48
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +212 -2
- package/dist/agent/agent.js +428 -38
- package/dist/cli/banner.d.ts +60 -0
- package/dist/cli/banner.js +199 -0
- package/dist/cli/cliPrompt.d.ts +69 -0
- package/dist/cli/cliPrompt.js +287 -0
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/mcp.d.ts +17 -0
- package/dist/cli/commands/mcp.js +121 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +97 -45
- package/dist/cli/commands/workflow.d.ts +18 -0
- package/dist/cli/commands/workflow.js +314 -43
- package/dist/cli/repl.js +219 -132
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/config/config.d.ts +40 -0
- package/dist/config/config.js +45 -73
- package/dist/index.js +80 -13
- package/dist/memory/briefing.d.ts +10 -0
- package/dist/memory/briefing.js +69 -1
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +124 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +90 -2
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +5 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { formatBudget, readGoal } from '../state/goalStore.js';
|
|
3
|
+
import { readPlan } from '../state/taskStore.js';
|
|
4
|
+
import { getCurrentWorkflow, listWorkflows } from '../state/workflowArtifacts.js';
|
|
5
|
+
import { listSessions } from '../orchestration/orchestrator.js';
|
|
6
|
+
import { readPreferences, resolveEffort } from '../state/preferencesStore.js';
|
|
7
|
+
import { BOX } from './theme.js';
|
|
8
|
+
const RECALL_LIMIT = 5;
|
|
9
|
+
const PLAN_LIMIT = 8;
|
|
10
|
+
const CHILDREN_LIMIT = 6;
|
|
11
|
+
function indent(line, depth = 2) {
|
|
12
|
+
return ' '.repeat(depth) + line;
|
|
13
|
+
}
|
|
14
|
+
function renderHeader(title, theme) {
|
|
15
|
+
return theme.heading(`${BOX.midLeft}${BOX.horizontal} ${title}`);
|
|
16
|
+
}
|
|
17
|
+
function renderWorkspace(inputs, theme) {
|
|
18
|
+
const base = path.basename(inputs.workspaceRoot) || inputs.workspaceRoot;
|
|
19
|
+
const dim = theme.muted;
|
|
20
|
+
// /where is the "tell me everything" surface, so we show the effort level
|
|
21
|
+
// regardless of whether it's at default — unlike the statusline, which
|
|
22
|
+
// hides medium to keep the prompt quiet. Tag the source in parens when
|
|
23
|
+
// env beat the preference so users can see why the value differs from
|
|
24
|
+
// what they set with /effort.
|
|
25
|
+
const effortLine = inputs.effortSource === 'env'
|
|
26
|
+
? `effort ${inputs.effort} ${dim('(env)')}`
|
|
27
|
+
: `effort ${inputs.effort}`;
|
|
28
|
+
const lines = [
|
|
29
|
+
renderHeader('Workspace', theme),
|
|
30
|
+
indent(theme.plain(`${base} ${dim('(' + inputs.workspaceRoot + ')')}`)),
|
|
31
|
+
indent(dim(`session ${inputs.sessionKey.slice(0, 8)} · model ${inputs.model} · mode ${inputs.accessMode}`)),
|
|
32
|
+
indent(dim(`exec ${inputs.executionMode} · review ${inputs.reviewPolicy} · ${effortLine}`)),
|
|
33
|
+
indent(dim(`mcp ${inputs.mcpProfile} · ${inputs.mcpTransport} · ${inputs.mcpOnline ? 'online' : 'offline'}`)),
|
|
34
|
+
];
|
|
35
|
+
// 10c: distinct `brain` line for the BrainRouter cloud brain. Shown
|
|
36
|
+
// unconditionally regardless of state when identity is brainrouter (vs.
|
|
37
|
+
// the statusline which hides online to stay quiet). Third-party MCPs
|
|
38
|
+
// skip the line entirely; `unknown` is pre-detection so we wait.
|
|
39
|
+
if (inputs.mcpIdentity === 'brainrouter') {
|
|
40
|
+
const brainState = inputs.mcpOnline ? '🟢 online' : '🔴 offline · cloud unreachable';
|
|
41
|
+
lines.push(indent(dim(`brain ${brainState}`)));
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
function renderWorkflow(inputs, theme) {
|
|
46
|
+
if (!inputs.workflowSlug)
|
|
47
|
+
return [];
|
|
48
|
+
const meta = inputs.workflowMeta;
|
|
49
|
+
const dim = theme.muted;
|
|
50
|
+
const lines = [renderHeader('Workflow', theme)];
|
|
51
|
+
lines.push(indent(theme.info(inputs.workflowSlug) + (meta ? ' ' + dim(`(${meta.kind} · ${meta.status})`) : '')));
|
|
52
|
+
if (meta)
|
|
53
|
+
lines.push(indent(dim(`title: ${meta.title}`)));
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
function renderGoal(inputs, theme) {
|
|
57
|
+
const goal = inputs.goal;
|
|
58
|
+
if (!goal)
|
|
59
|
+
return [];
|
|
60
|
+
const dim = theme.muted;
|
|
61
|
+
const cap = formatBudget(goal.budget.maxIterations);
|
|
62
|
+
const used = goal.budget.iterationsUsed;
|
|
63
|
+
const statusColor = goal.status === 'complete' ? theme.success :
|
|
64
|
+
goal.status === 'blocked' ? theme.danger :
|
|
65
|
+
goal.status === 'usage_limited' ? theme.warning :
|
|
66
|
+
goal.status === 'paused' ? theme.warning :
|
|
67
|
+
theme.info;
|
|
68
|
+
const lines = [renderHeader('Goal', theme)];
|
|
69
|
+
lines.push(indent(statusColor(goal.status.toUpperCase().replace('_', ' '))));
|
|
70
|
+
// Wrap the goal text at a sensible width so long objectives don't
|
|
71
|
+
// produce one giant unreadable line.
|
|
72
|
+
lines.push(indent(theme.plain(wrapText(goal.text, 76))));
|
|
73
|
+
const tokenLine = goal.budget.maxTokens
|
|
74
|
+
? ` · tokens ${(goal.budget.tokensUsed ?? 0).toLocaleString()}/${goal.budget.maxTokens.toLocaleString()}`
|
|
75
|
+
: '';
|
|
76
|
+
lines.push(indent(dim(`iterations ${used}/${cap}${tokenLine}`)));
|
|
77
|
+
if (goal.blockedReason)
|
|
78
|
+
lines.push(indent(dim(`reason: ${goal.blockedReason}`)));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
function renderPlan(inputs, theme) {
|
|
82
|
+
if (!inputs.plan.items.length)
|
|
83
|
+
return [];
|
|
84
|
+
const dim = theme.muted;
|
|
85
|
+
const lines = [renderHeader('Plan', theme)];
|
|
86
|
+
if (inputs.plan.explanation) {
|
|
87
|
+
lines.push(indent(dim(inputs.plan.explanation)));
|
|
88
|
+
}
|
|
89
|
+
for (const item of inputs.plan.items.slice(0, PLAN_LIMIT)) {
|
|
90
|
+
const mark = item.status === 'completed' ? theme.success('✓') :
|
|
91
|
+
item.status === 'in_progress' ? theme.warning('⏳') :
|
|
92
|
+
dim('☐');
|
|
93
|
+
const text = item.status === 'completed' ? dim(item.step) : theme.plain(item.step);
|
|
94
|
+
lines.push(indent(`${mark} ${text}`));
|
|
95
|
+
}
|
|
96
|
+
if (inputs.plan.items.length > PLAN_LIMIT) {
|
|
97
|
+
lines.push(indent(dim(`…and ${inputs.plan.items.length - PLAN_LIMIT} more`)));
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
function renderRecall(inputs, theme) {
|
|
102
|
+
if (!inputs.recalledRecords.length && !inputs.briefingSources.length)
|
|
103
|
+
return [];
|
|
104
|
+
const dim = theme.muted;
|
|
105
|
+
const lines = [renderHeader('Recent recall', theme)];
|
|
106
|
+
if (inputs.briefingSources.length) {
|
|
107
|
+
lines.push(indent(dim(`sources: ${inputs.briefingSources.join(', ')}`)));
|
|
108
|
+
}
|
|
109
|
+
for (const rec of inputs.recalledRecords.slice(0, RECALL_LIMIT)) {
|
|
110
|
+
const typeTag = rec.type ? theme.secondary(`[${rec.type}] `) : '';
|
|
111
|
+
const score = typeof rec.priority === 'number' ? dim(` (p=${rec.priority.toFixed(2)})`) : '';
|
|
112
|
+
const snippet = (rec.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
113
|
+
lines.push(indent(`${typeTag}${theme.plain(snippet || rec.recordId)}${score}`));
|
|
114
|
+
}
|
|
115
|
+
if (inputs.recalledRecords.length > RECALL_LIMIT) {
|
|
116
|
+
lines.push(indent(dim(`…and ${inputs.recalledRecords.length - RECALL_LIMIT} more`)));
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
function renderChildren(inputs, theme) {
|
|
121
|
+
// Live children = anything currently pending or running. Stale/completed
|
|
122
|
+
// are visible in /agents; /where stays focused on what's blocking attention.
|
|
123
|
+
const live = inputs.childSessions.filter((s) => s.status === 'pending' || s.status === 'running');
|
|
124
|
+
if (!live.length)
|
|
125
|
+
return [];
|
|
126
|
+
const dim = theme.muted;
|
|
127
|
+
const lines = [renderHeader(`Active children (${live.length})`, theme)];
|
|
128
|
+
for (const s of live.slice(0, CHILDREN_LIMIT)) {
|
|
129
|
+
const statusColor = s.status === 'running' ? theme.success : theme.warning;
|
|
130
|
+
const role = theme.secondary(s.role);
|
|
131
|
+
const id = theme.info(s.id);
|
|
132
|
+
const promptPreview = s.prompt
|
|
133
|
+
? ' ' + dim(s.prompt.replace(/\s+/g, ' ').slice(0, 80))
|
|
134
|
+
: '';
|
|
135
|
+
lines.push(indent(`${statusColor(s.status)} ${id} ${role}${promptPreview}`));
|
|
136
|
+
}
|
|
137
|
+
if (live.length > CHILDREN_LIMIT) {
|
|
138
|
+
lines.push(indent(dim(`…and ${live.length - CHILDREN_LIMIT} more`)));
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|
|
142
|
+
function wrapText(text, width) {
|
|
143
|
+
if (text.length <= width)
|
|
144
|
+
return text;
|
|
145
|
+
const words = text.split(/\s+/);
|
|
146
|
+
const lines = [];
|
|
147
|
+
let current = '';
|
|
148
|
+
for (const word of words) {
|
|
149
|
+
if (!current) {
|
|
150
|
+
current = word;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (current.length + 1 + word.length > width) {
|
|
154
|
+
lines.push(current);
|
|
155
|
+
current = word;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
current += ' ' + word;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (current)
|
|
162
|
+
lines.push(current);
|
|
163
|
+
return lines.join('\n' + ' '.repeat(2));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Render the full /where block. Sections are dropped when empty; a fresh
|
|
167
|
+
* workspace with no goal / plan / children renders just WORKSPACE.
|
|
168
|
+
*/
|
|
169
|
+
export function renderWhere(inputs, theme) {
|
|
170
|
+
const sections = [];
|
|
171
|
+
sections.push(renderWorkspace(inputs, theme));
|
|
172
|
+
const workflow = renderWorkflow(inputs, theme);
|
|
173
|
+
if (workflow.length)
|
|
174
|
+
sections.push(workflow);
|
|
175
|
+
const goal = renderGoal(inputs, theme);
|
|
176
|
+
if (goal.length)
|
|
177
|
+
sections.push(goal);
|
|
178
|
+
const plan = renderPlan(inputs, theme);
|
|
179
|
+
if (plan.length)
|
|
180
|
+
sections.push(plan);
|
|
181
|
+
const recall = renderRecall(inputs, theme);
|
|
182
|
+
if (recall.length)
|
|
183
|
+
sections.push(recall);
|
|
184
|
+
const children = renderChildren(inputs, theme);
|
|
185
|
+
if (children.length)
|
|
186
|
+
sections.push(children);
|
|
187
|
+
return sections.map((lines) => lines.join('\n')).join('\n\n');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Gather the snapshot the renderer needs. Single function so the command
|
|
191
|
+
* handler is two lines (gather + render + print).
|
|
192
|
+
*/
|
|
193
|
+
export function gatherWhereInputs(args) {
|
|
194
|
+
const workflowSlug = (() => {
|
|
195
|
+
// 9d-bugfix: session-scoped binding so a fresh CLI shows no workflow
|
|
196
|
+
// even when an earlier session in the same workspace had one bound.
|
|
197
|
+
try {
|
|
198
|
+
return getCurrentWorkflow(args.workspaceRoot, args.sessionKey);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
const workflowMeta = workflowSlug
|
|
205
|
+
? listWorkflows(args.workspaceRoot).find((w) => w.slug === workflowSlug)
|
|
206
|
+
: undefined;
|
|
207
|
+
const goal = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return readGoal(args.workspaceRoot, args.sessionKey) ?? undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
const plan = (() => {
|
|
216
|
+
try {
|
|
217
|
+
return readPlan(args.workspaceRoot, args.sessionKey);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { items: [], updatedAt: '' };
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
const childSessions = (() => {
|
|
224
|
+
try {
|
|
225
|
+
return listSessions(args.workspaceRoot);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
const prefs = readPreferences(args.workspaceRoot);
|
|
232
|
+
const resolvedEffort = resolveEffort(args.workspaceRoot);
|
|
233
|
+
return {
|
|
234
|
+
...args,
|
|
235
|
+
executionMode: prefs.executionMode,
|
|
236
|
+
reviewPolicy: prefs.reviewPolicy,
|
|
237
|
+
effort: resolvedEffort.effort,
|
|
238
|
+
effortSource: resolvedEffort.source,
|
|
239
|
+
workflowSlug,
|
|
240
|
+
workflowMeta,
|
|
241
|
+
goal,
|
|
242
|
+
plan,
|
|
243
|
+
childSessions,
|
|
244
|
+
};
|
|
245
|
+
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -5,6 +5,25 @@ export interface ServerConfig {
|
|
|
5
5
|
env?: Record<string, string>;
|
|
6
6
|
url?: string;
|
|
7
7
|
apiKey?: string;
|
|
8
|
+
/**
|
|
9
|
+
* 0.3.6 item 10a: identity tag for distinguishing the BrainRouter cloud
|
|
10
|
+
* brain ("our MCP") from third-party MCPs the user might attach (GitHub,
|
|
11
|
+
* filesystem, Slack, etc.). Drives status surfaces (banner / statusline /
|
|
12
|
+
* `/where`) and the offline-mode prompt swap: when "the brain" is down
|
|
13
|
+
* the user gets a clear signal, not a generic "MCP offline" message.
|
|
14
|
+
*
|
|
15
|
+
* Detection priority when this field is unset:
|
|
16
|
+
* 1. Server profile name starts with `brainrouter` (case-insensitive).
|
|
17
|
+
* 2. URL hostname matches `*.brainrouter.cloud` or `*.brainrouter.dev`.
|
|
18
|
+
* 3. (Run-time fallback) first successful `listTools()` includes
|
|
19
|
+
* both `memory_recall` AND `list_skills` — the BrainRouter signature
|
|
20
|
+
* pair. See `detectMcpIdentity` in `runtime/mcpClient.ts`.
|
|
21
|
+
*
|
|
22
|
+
* Explicit values always win — if the user marks a third-party MCP as
|
|
23
|
+
* `identity: 'brainrouter'`, that's their call (e.g. they're running a
|
|
24
|
+
* local fork that exposes the same tool surface).
|
|
25
|
+
*/
|
|
26
|
+
identity?: 'brainrouter' | 'third-party';
|
|
8
27
|
}
|
|
9
28
|
export interface LLMConfig {
|
|
10
29
|
provider: 'openai';
|
|
@@ -18,5 +37,26 @@ export interface Config {
|
|
|
18
37
|
llm?: LLMConfig;
|
|
19
38
|
}
|
|
20
39
|
export declare function getConfigPath(): string;
|
|
40
|
+
/**
|
|
41
|
+
* Read the existing config.json or exit with a clear error. The CLI owns
|
|
42
|
+
* READS of this file — writes are the user's job (via `brainrouter login`,
|
|
43
|
+
* `brainrouter config`, or direct edit). Auto-fabricating a default config
|
|
44
|
+
* was a holdover from the monorepo dev story; it only ever produced a
|
|
45
|
+
* broken stdio profile pointing at a sibling `brainrouter/` package that
|
|
46
|
+
* doesn't exist outside the monorepo, so npm-installed users got a config
|
|
47
|
+
* file they had to fix anyway.
|
|
48
|
+
*
|
|
49
|
+
* Setup commands (login / config) that need to BUILD a fresh config from
|
|
50
|
+
* scratch should call `loadOrInitConfig` instead — it returns an empty
|
|
51
|
+
* skeleton when no file exists rather than exiting.
|
|
52
|
+
*/
|
|
21
53
|
export declare function loadConfig(): Config;
|
|
54
|
+
/**
|
|
55
|
+
* Setup-wizard variant of `loadConfig`. Returns the existing config when
|
|
56
|
+
* one is on disk, or an empty skeleton when none exists yet. Used by
|
|
57
|
+
* `brainrouter login` and `brainrouter config` so a first-run user can
|
|
58
|
+
* BUILD their config interactively without hitting the strict
|
|
59
|
+
* "no config — run setup" error from `loadConfig`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function loadOrInitConfig(): Config;
|
|
22
62
|
export declare function saveConfig(config: Config): void;
|
package/dist/config/config.js
CHANGED
|
@@ -1,46 +1,68 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
5
4
|
const CONFIG_DIR = path.join(os.homedir(), '.config', 'brainrouter');
|
|
6
5
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
6
|
export function getConfigPath() {
|
|
8
7
|
return CONFIG_FILE;
|
|
9
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Read the existing config.json or exit with a clear error. The CLI owns
|
|
11
|
+
* READS of this file — writes are the user's job (via `brainrouter login`,
|
|
12
|
+
* `brainrouter config`, or direct edit). Auto-fabricating a default config
|
|
13
|
+
* was a holdover from the monorepo dev story; it only ever produced a
|
|
14
|
+
* broken stdio profile pointing at a sibling `brainrouter/` package that
|
|
15
|
+
* doesn't exist outside the monorepo, so npm-installed users got a config
|
|
16
|
+
* file they had to fix anyway.
|
|
17
|
+
*
|
|
18
|
+
* Setup commands (login / config) that need to BUILD a fresh config from
|
|
19
|
+
* scratch should call `loadOrInitConfig` instead — it returns an empty
|
|
20
|
+
* skeleton when no file exists rather than exiting.
|
|
21
|
+
*/
|
|
10
22
|
export function loadConfig() {
|
|
11
|
-
let config;
|
|
12
23
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
13
|
-
config
|
|
24
|
+
console.error(`No BrainRouter config found at ${CONFIG_FILE}.`);
|
|
25
|
+
console.error(`Run \`brainrouter login\` to connect to a hosted MCP server, or \`brainrouter config\` to set one up.`);
|
|
26
|
+
process.exit(1);
|
|
14
27
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// Backfill standard properties if missing
|
|
20
|
-
if (!parsed.servers)
|
|
21
|
-
parsed.servers = {};
|
|
22
|
-
if (!parsed.activeServer)
|
|
23
|
-
parsed.activeServer = 'default';
|
|
24
|
-
config = parsed;
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
27
|
-
console.error(`Warning: Failed to parse config file at ${CONFIG_FILE}. Using default config.`);
|
|
28
|
-
config = createDefaultConfig();
|
|
29
|
-
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
31
|
+
parsed = JSON.parse(raw);
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
catch (error) {
|
|
34
|
+
console.error(`Error: Failed to parse config file at ${CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
console.error(`Fix the file by hand, or delete it and run \`brainrouter config\` to recreate.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
if (!parsed.servers)
|
|
39
|
+
parsed.servers = {};
|
|
40
|
+
if (!parsed.activeServer)
|
|
41
|
+
parsed.activeServer = '';
|
|
33
42
|
// The default config writes `llm.apiKey: ''` so it never appears as a
|
|
34
43
|
// secret in the committed file. Backfill from the standard env vars at
|
|
35
44
|
// load time so every downstream consumer (callOpenAI, mcpClient env
|
|
36
45
|
// propagation, the cognitive extractor LLM runner) sees a real value
|
|
37
46
|
// instead of the empty string.
|
|
38
|
-
if (
|
|
47
|
+
if (parsed.llm && !parsed.llm.apiKey.trim()) {
|
|
39
48
|
const envKey = process.env.OPENAI_API_KEY || process.env.BRAINROUTER_LLM_API_KEY;
|
|
40
49
|
if (envKey)
|
|
41
|
-
|
|
50
|
+
parsed.llm.apiKey = envKey;
|
|
42
51
|
}
|
|
43
|
-
return
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Setup-wizard variant of `loadConfig`. Returns the existing config when
|
|
56
|
+
* one is on disk, or an empty skeleton when none exists yet. Used by
|
|
57
|
+
* `brainrouter login` and `brainrouter config` so a first-run user can
|
|
58
|
+
* BUILD their config interactively without hitting the strict
|
|
59
|
+
* "no config — run setup" error from `loadConfig`.
|
|
60
|
+
*/
|
|
61
|
+
export function loadOrInitConfig() {
|
|
62
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
63
|
+
return { activeServer: '', servers: {} };
|
|
64
|
+
}
|
|
65
|
+
return loadConfig();
|
|
44
66
|
}
|
|
45
67
|
export function saveConfig(config) {
|
|
46
68
|
try {
|
|
@@ -53,53 +75,3 @@ export function saveConfig(config) {
|
|
|
53
75
|
console.error(`Error: Failed to save config to ${CONFIG_FILE}:`, error instanceof Error ? error.message : error);
|
|
54
76
|
}
|
|
55
77
|
}
|
|
56
|
-
function createDefaultConfig() {
|
|
57
|
-
// Derive path to the default local MCP server dist relative to this module.
|
|
58
|
-
// After build: brainrouter-cli/dist/config/config.js → walk three levels up
|
|
59
|
-
// to the monorepo root, then into the sibling `brainrouter/` package
|
|
60
|
-
// (formerly `mcp/`) which is the MCP server.
|
|
61
|
-
const defaultMcpPath = path.resolve(import.meta.dirname, '..', '..', '..', 'brainrouter', 'dist', 'index.js');
|
|
62
|
-
const config = {
|
|
63
|
-
activeServer: 'default',
|
|
64
|
-
servers: {
|
|
65
|
-
default: {
|
|
66
|
-
type: 'stdio',
|
|
67
|
-
command: 'node',
|
|
68
|
-
args: [defaultMcpPath, '--root', './'],
|
|
69
|
-
env: {
|
|
70
|
-
BRAINROUTER_API_KEY: 'br_admin_key_placeholder'
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
llm: {
|
|
75
|
-
provider: 'openai',
|
|
76
|
-
apiKey: '',
|
|
77
|
-
model: 'gpt-4o-mini',
|
|
78
|
-
endpoint: 'https://api.openai.com/v1'
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
saveConfig(config);
|
|
82
|
-
return config;
|
|
83
|
-
}
|
|
84
|
-
function resolveDefaultApiKey(config) {
|
|
85
|
-
const defaultServer = config.servers.default;
|
|
86
|
-
if (defaultServer &&
|
|
87
|
-
defaultServer.type === 'stdio' &&
|
|
88
|
-
defaultServer.env &&
|
|
89
|
-
defaultServer.env.BRAINROUTER_API_KEY === 'br_admin_key_placeholder') {
|
|
90
|
-
const dbPath = process.env.BRAINROUTER_MEMORY_DB || path.join(os.homedir(), '.brainrouter', 'memory.db');
|
|
91
|
-
if (fs.existsSync(dbPath)) {
|
|
92
|
-
try {
|
|
93
|
-
const db = new DatabaseSync(dbPath);
|
|
94
|
-
const row = db.prepare("SELECT api_key FROM users WHERE is_admin = 1 LIMIT 1").get();
|
|
95
|
-
if (row && row.api_key) {
|
|
96
|
-
defaultServer.env.BRAINROUTER_API_KEY = row.api_key;
|
|
97
|
-
saveConfig(config);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
catch (error) {
|
|
101
|
-
// ignore errors
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Filter out Node.js platform warnings that the user has no way to act on
|
|
4
|
+
* and that scroll real CLI banner content off-screen on short terminals.
|
|
5
|
+
*
|
|
6
|
+
* - `ExperimentalWarning: SQLite is an experimental feature` — emitted by
|
|
7
|
+
* `node:sqlite`. The CLI itself no longer imports sqlite, but the
|
|
8
|
+
* stdio MCP child process does, and its warnings surface on the parent's
|
|
9
|
+
* stderr. Stable in Node 22+ in practice; the warning is correct but
|
|
10
|
+
* uninformative.
|
|
11
|
+
* - `DeprecationWarning: ... dotenv ...` — dotenv@16 prints a teaser for
|
|
12
|
+
* its hosted product on every load on newer Node releases.
|
|
13
|
+
*
|
|
14
|
+
* BrainRouter's own warnings flow through unchanged. `NODE_NO_WARNINGS=1`
|
|
15
|
+
* would silence those too, so we intercept selectively instead.
|
|
16
|
+
*
|
|
17
|
+
* Two interception points: (1) remove Node's built-in `warning` listener
|
|
18
|
+
* and add our own filtered one — this catches warnings emitted from
|
|
19
|
+
* subprocesses or transitive imports during ESM resolution; (2) replace
|
|
20
|
+
* `process.emitWarning` so future direct callers also get the filter.
|
|
21
|
+
* Both are needed because ESM hoists imports above any code in this file,
|
|
22
|
+
* so an emitWarning override alone misses import-time warnings.
|
|
23
|
+
*/
|
|
24
|
+
function isSuppressibleWarning(message, type) {
|
|
25
|
+
const looksExperimental = type === 'ExperimentalWarning' ||
|
|
26
|
+
/experimental feature|SQLite is an experimental/i.test(message);
|
|
27
|
+
const looksDotenvNoise = /dotenv@\d|dotenvx|dotenv\.org/i.test(message);
|
|
28
|
+
return looksExperimental || looksDotenvNoise;
|
|
29
|
+
}
|
|
30
|
+
// Detach Node's default warning printer and replace with a filtered one.
|
|
31
|
+
// process.listeners returns each Function attached; the default one is a
|
|
32
|
+
// single internal listener that does the stderr printing.
|
|
33
|
+
for (const listener of process.listeners('warning')) {
|
|
34
|
+
process.removeListener('warning', listener);
|
|
35
|
+
}
|
|
36
|
+
process.on('warning', (warning) => {
|
|
37
|
+
if (isSuppressibleWarning(warning?.message ?? '', warning?.name ?? ''))
|
|
38
|
+
return;
|
|
39
|
+
// Mirror Node's default formatting for everything else so users see the
|
|
40
|
+
// familiar "(node:PID) <Name>: <message>" shape.
|
|
41
|
+
process.stderr.write(`(node:${process.pid}) ${warning?.name ?? 'Warning'}: ${warning?.message ?? warning}\n`);
|
|
42
|
+
});
|
|
43
|
+
const originalEmitWarning = process.emitWarning.bind(process);
|
|
44
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
45
|
+
const message = typeof warning === 'string' ? warning : warning?.message ?? '';
|
|
46
|
+
const type = typeof rest[0] === 'string' ? rest[0]
|
|
47
|
+
: (rest[0] && typeof rest[0] === 'object' && 'type' in rest[0]) ? rest[0].type
|
|
48
|
+
: (warning instanceof Error ? warning.name : '');
|
|
49
|
+
if (isSuppressibleWarning(message, type))
|
|
50
|
+
return;
|
|
51
|
+
return originalEmitWarning(warning, ...rest);
|
|
52
|
+
});
|
|
2
53
|
import fs from 'node:fs';
|
|
3
54
|
import path from 'node:path';
|
|
4
55
|
import url from 'node:url';
|
|
5
56
|
import { Command } from 'commander';
|
|
6
57
|
import inquirer from 'inquirer';
|
|
7
58
|
import chalk from 'chalk';
|
|
8
|
-
import { loadConfig, saveConfig } from './config/config.js';
|
|
59
|
+
import { loadConfig, loadOrInitConfig, saveConfig } from './config/config.js';
|
|
9
60
|
import { McpClientWrapper } from './runtime/mcpClient.js';
|
|
10
61
|
import { Agent } from './agent/agent.js';
|
|
11
62
|
import { startREPL } from './cli/repl.js';
|
|
@@ -174,13 +225,23 @@ program
|
|
|
174
225
|
.option('-m, --model <name>', 'LLM model override')
|
|
175
226
|
.option('-w, --workspace <path>', 'Workspace root for files, commands, memory session, and MCP --root')
|
|
176
227
|
.option('--strict-mcp', 'Exit if the MCP server is unreachable (default: continue in offline mode with local tools only)')
|
|
228
|
+
.option('--quiet', 'Suppress recall tables, briefing dumps, and tool-completion previews (model prose only). Toggle in-session with /quiet.')
|
|
177
229
|
.action(async (options) => {
|
|
178
230
|
if (options.workspace) {
|
|
179
231
|
process.env.BRAINROUTER_WORKSPACE = options.workspace;
|
|
180
232
|
}
|
|
233
|
+
if (options.quiet) {
|
|
234
|
+
// Quiet mode is durable in preferences, but `--quiet` should turn it
|
|
235
|
+
// on for THIS session without permanently flipping the user's saved
|
|
236
|
+
// setting. Set a process env that the REPL preference-merger checks.
|
|
237
|
+
process.env.BRAINROUTER_QUIET = '1';
|
|
238
|
+
}
|
|
181
239
|
const workspace = findWorkspaceRoot();
|
|
182
240
|
applyWorkspaceRoot(workspace.workspaceRoot);
|
|
183
|
-
|
|
241
|
+
// Workspace path + detection reason intentionally NOT printed here — the
|
|
242
|
+
// boxed startup banner shows the workspace row, and `/workspace` exposes
|
|
243
|
+
// the launch CWD + detection reason on demand. Keeping a duplicate
|
|
244
|
+
// stale-chrome line above the banner undermines the banner-first design.
|
|
184
245
|
const config = loadConfig();
|
|
185
246
|
const profileName = options.profile || config.activeServer;
|
|
186
247
|
const configuredServer = config.servers[profileName];
|
|
@@ -206,10 +267,14 @@ program
|
|
|
206
267
|
llm.model = options.model;
|
|
207
268
|
}
|
|
208
269
|
const mcpClient = new McpClientWrapper();
|
|
209
|
-
|
|
270
|
+
// "Connecting..." / "Successfully connected!" status lines intentionally
|
|
271
|
+
// dropped — they printed for the ~1-2s of connect AND scrolled the
|
|
272
|
+
// banner up. On success the banner's `mcp ... online` row IS the
|
|
273
|
+
// success signal; on failure the catch-block error + the post-banner
|
|
274
|
+
// OFFLINE MODE warning in startREPL together cover both diagnosis and
|
|
275
|
+
// remediation.
|
|
210
276
|
try {
|
|
211
|
-
await mcpClient.connect(serverConfig, llm);
|
|
212
|
-
console.log(chalk.green('Successfully connected to BrainRouter MCP Server!'));
|
|
277
|
+
await mcpClient.connect(serverConfig, llm, profileName);
|
|
213
278
|
}
|
|
214
279
|
catch (err) {
|
|
215
280
|
// Degraded "offline mode": the MCP server is the cognitive memory layer
|
|
@@ -224,9 +289,8 @@ program
|
|
|
224
289
|
console.error(chalk.gray('--strict-mcp set; exiting.'));
|
|
225
290
|
process.exit(1);
|
|
226
291
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
' Start the MCP server and restart the CLI to restore full functionality.\n'));
|
|
292
|
+
// The banner-adjacent OFFLINE MODE warning in startREPL covers the
|
|
293
|
+
// remediation hint. No second warning here.
|
|
230
294
|
}
|
|
231
295
|
const agent = new Agent(mcpClient, llm, {
|
|
232
296
|
workspaceRoot: workspace.workspaceRoot,
|
|
@@ -302,7 +366,7 @@ program
|
|
|
302
366
|
llm.model = options.model;
|
|
303
367
|
const mcpClient = new McpClientWrapper();
|
|
304
368
|
try {
|
|
305
|
-
await mcpClient.connect(serverConfig, llm);
|
|
369
|
+
await mcpClient.connect(serverConfig, llm, profileName);
|
|
306
370
|
}
|
|
307
371
|
catch (err) {
|
|
308
372
|
console.error(`MCP connect failed: ${err.message}`);
|
|
@@ -395,10 +459,11 @@ program
|
|
|
395
459
|
type: 'http',
|
|
396
460
|
url: answers.url,
|
|
397
461
|
apiKey: answers.apiKey || undefined
|
|
398
|
-
});
|
|
462
|
+
}, undefined, answers.profileName);
|
|
399
463
|
await mcpClient.close();
|
|
400
|
-
// Save to config
|
|
401
|
-
|
|
464
|
+
// Save to config — `loadOrInitConfig` lets first-run users build a
|
|
465
|
+
// fresh config.json instead of hitting the strict no-config error.
|
|
466
|
+
const config = loadOrInitConfig();
|
|
402
467
|
config.servers[answers.profileName] = {
|
|
403
468
|
type: 'http',
|
|
404
469
|
url: answers.url,
|
|
@@ -419,7 +484,9 @@ program
|
|
|
419
484
|
.command('config')
|
|
420
485
|
.description('Interactively configure your LLM provider and MCP servers')
|
|
421
486
|
.action(async () => {
|
|
422
|
-
|
|
487
|
+
// `loadOrInitConfig` because this command IS the first-run setup
|
|
488
|
+
// wizard — it must work even when no config.json exists yet.
|
|
489
|
+
const config = loadOrInitConfig();
|
|
423
490
|
const menu = await inquirer.prompt([
|
|
424
491
|
{
|
|
425
492
|
type: 'list',
|
|
@@ -10,6 +10,16 @@ export interface BriefingInputs {
|
|
|
10
10
|
activeSkill?: string;
|
|
11
11
|
/** Cap on injected briefing content per source — guards against runaway payloads eating the context window. */
|
|
12
12
|
maxCharsPerSource?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Set by the caller when a `goal-anchor` system message is already
|
|
15
|
+
* carrying the current objective. The briefing skips `memory_task_state`
|
|
16
|
+
* in that case to avoid double-injecting the "what we're doing right
|
|
17
|
+
* now" context — the goal-anchor is the authoritative owner. When
|
|
18
|
+
* there is no active goal (pre-goal exploration, after `/goal pause`,
|
|
19
|
+
* silent child agents) the task-state surface still fires so handover
|
|
20
|
+
* notes and prior blockers stay visible. Part of 0.3.6 item 9d.
|
|
21
|
+
*/
|
|
22
|
+
hasActiveGoal?: boolean;
|
|
13
23
|
}
|
|
14
24
|
export interface RecalledRecord {
|
|
15
25
|
recordId: string;
|