@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.
Files changed (49) hide show
  1. package/.env.example +55 -48
  2. package/bin/cli.cjs +71 -0
  3. package/dist/agent/agent.d.ts +212 -2
  4. package/dist/agent/agent.js +428 -38
  5. package/dist/cli/banner.d.ts +60 -0
  6. package/dist/cli/banner.js +199 -0
  7. package/dist/cli/cliPrompt.d.ts +69 -0
  8. package/dist/cli/cliPrompt.js +287 -0
  9. package/dist/cli/commands/_helpers.js +6 -6
  10. package/dist/cli/commands/guard.js +75 -10
  11. package/dist/cli/commands/mcp.d.ts +17 -0
  12. package/dist/cli/commands/mcp.js +121 -0
  13. package/dist/cli/commands/memory.js +2 -2
  14. package/dist/cli/commands/obs.js +22 -22
  15. package/dist/cli/commands/session.js +13 -5
  16. package/dist/cli/commands/ui.js +97 -45
  17. package/dist/cli/commands/workflow.d.ts +18 -0
  18. package/dist/cli/commands/workflow.js +314 -43
  19. package/dist/cli/repl.js +219 -132
  20. package/dist/cli/spinner.d.ts +34 -0
  21. package/dist/cli/spinner.js +36 -0
  22. package/dist/cli/statusline.d.ts +67 -0
  23. package/dist/cli/statusline.js +204 -0
  24. package/dist/cli/theme.d.ts +79 -0
  25. package/dist/cli/theme.js +106 -0
  26. package/dist/cli/whereView.d.ts +81 -0
  27. package/dist/cli/whereView.js +245 -0
  28. package/dist/config/config.d.ts +40 -0
  29. package/dist/config/config.js +45 -73
  30. package/dist/index.js +80 -13
  31. package/dist/memory/briefing.d.ts +10 -0
  32. package/dist/memory/briefing.js +69 -1
  33. package/dist/prompt/breadthHint.d.ts +5 -0
  34. package/dist/prompt/breadthHint.js +44 -0
  35. package/dist/prompt/systemPrompt.d.ts +34 -0
  36. package/dist/prompt/systemPrompt.js +124 -108
  37. package/dist/runtime/dangerousCommand.d.ts +53 -0
  38. package/dist/runtime/dangerousCommand.js +105 -0
  39. package/dist/runtime/mcpClient.d.ts +38 -1
  40. package/dist/runtime/mcpClient.js +90 -2
  41. package/dist/state/goalStore.d.ts +98 -17
  42. package/dist/state/goalStore.js +132 -42
  43. package/dist/state/preferencesStore.d.ts +67 -3
  44. package/dist/state/preferencesStore.js +84 -1
  45. package/dist/state/workflowArtifacts.d.ts +63 -2
  46. package/dist/state/workflowArtifacts.js +120 -8
  47. package/dist/tests/_helpers.d.ts +31 -0
  48. package/dist/tests/_helpers.js +91 -0
  49. 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
+ }
@@ -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;
@@ -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 = createDefaultConfig();
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
- else {
16
- try {
17
- const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
18
- const parsed = JSON.parse(raw);
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
- // Auto-resolve placeholder API keys if possible
32
- resolveDefaultApiKey(config);
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 (config.llm && !config.llm.apiKey.trim()) {
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
- config.llm.apiKey = envKey;
50
+ parsed.llm.apiKey = envKey;
42
51
  }
43
- return config;
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
- console.log(chalk.gray(`Workspace: ${workspace.workspaceRoot} (${workspace.reason})`));
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
- console.log(chalk.gray(`Connecting to MCP server profile "${profileName}"...`));
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
- 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'));
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
- const config = loadConfig();
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
- const config = loadConfig();
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;