@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
@@ -8,7 +8,7 @@
8
8
  * wrapper.
9
9
  */
10
10
  import chalk from 'chalk';
11
- import ora from 'ora';
11
+ import { spinner } from '../spinner.js';
12
12
  import { callMcpTool } from '../../runtime/mcpUtils.js';
13
13
  import { clampPayload, extractMemories, renderMemoryCards } from '../../memory/formatters.js';
14
14
  import { buildSkillPrompt, resolveSkill, SLASH_TO_SKILL } from '../../prompt/skillRunner.js';
@@ -19,9 +19,9 @@ import { buildSkillPrompt, resolveSkill, SLASH_TO_SKILL } from '../../prompt/ski
19
19
  * raw output only when no records can be parsed.
20
20
  */
21
21
  export async function printMemoryCards(mcpClient, toolName, args, heading) {
22
- const spinner = ora(chalk.gray(`${toolName}…`)).start();
22
+ const s = spinner(chalk.gray(`${toolName}…`)).start();
23
23
  const res = await callMcpTool(mcpClient, toolName, args);
24
- spinner.stop();
24
+ s.stop();
25
25
  console.log();
26
26
  if (res.isError) {
27
27
  console.log(chalk.red(`${heading}: tool error — ${res.text || '(no message)'}`));
@@ -44,9 +44,9 @@ export async function printMemoryCards(mcpClient, toolName, args, heading) {
44
44
  * the tool's text output under a heading.
45
45
  */
46
46
  export async function printMcpCall(mcpClient, toolName, args, heading) {
47
- const spinner = ora(chalk.gray(`${toolName}…`)).start();
47
+ const s = spinner(chalk.gray(`${toolName}…`)).start();
48
48
  const res = await callMcpTool(mcpClient, toolName, args);
49
- spinner.stop();
49
+ s.stop();
50
50
  console.log(chalk.bold(`\n${heading}`));
51
51
  if (res.isError) {
52
52
  console.log(chalk.red(` Tool error: ${res.text || '(no message)'}`));
@@ -113,7 +113,7 @@ export async function runSkillCommand(agent, mcpClient, slashCommand, userInput,
113
113
  await runSkillByName(agent, mcpClient, skillName, userInput, orchestration, runTurn);
114
114
  }
115
115
  export async function runSkillByName(agent, mcpClient, skillName, userInput, orchestration, runTurn) {
116
- const loader = ora(chalk.gray(`Loading skill: ${skillName}...`)).start();
116
+ const loader = spinner(chalk.gray(`Loading skill: ${skillName}...`)).start();
117
117
  let prompt;
118
118
  try {
119
119
  const skill = await resolveSkill(mcpClient, skillName, agent.workspaceRoot, 'full');
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import chalk from 'chalk';
8
- import { readPreferences, writePreferences } from '../../state/preferencesStore.js';
8
+ import { applyYoloOff, applyYoloOn, readPreferences, writePreferences } from '../../state/preferencesStore.js';
9
9
  import { addHook, readHooks, removeHook, setHookEnabled } from '../../state/hooksStore.js';
10
10
  import { createHookifyRule, deleteHookifyRule, listHookifyRules, toggleHookifyRule } from '../../state/hookifyStore.js';
11
11
  export async function tryHandleGuardCommand(ctx) {
@@ -79,26 +79,91 @@ export async function tryHandleGuardCommand(ctx) {
79
79
  console.log(chalk.red('\nUsage: /hooks [list | add <event> <cmd> | remove <id> | enable <id> | disable <id>]\n'));
80
80
  return true;
81
81
  }
82
- case '/yolo':
82
+ case '/mode':
83
+ {
84
+ const prefs = readPreferences(agent.workspaceRoot);
85
+ const arg = (args[0] ?? '').toLowerCase();
86
+ if (!arg) {
87
+ console.log(chalk.bold(`\nExecution mode: ${chalk.cyan(prefs.executionMode)}`));
88
+ console.log(chalk.gray(' planning — run_command asks before executing; agent leans toward clarify-before-act. (default)'));
89
+ console.log(chalk.gray(' fast — run_command auto-approves safe commands (dangerous ones still ask); agent jumps to implementation.'));
90
+ console.log(chalk.gray(' Toggle with: /mode planning | /mode fast\n'));
91
+ return true;
92
+ }
93
+ if (arg !== 'planning' && arg !== 'fast') {
94
+ console.log(chalk.red(`\nUnknown mode "${arg}". Choose: planning | fast\n`));
95
+ return true;
96
+ }
97
+ writePreferences(agent.workspaceRoot, { executionMode: arg });
98
+ agent.refreshSystemPrompt();
99
+ ctx.repl.refreshPromptForMode();
100
+ if (arg === 'fast') {
101
+ console.log(chalk.yellow(`\n✓ /mode fast — run_command auto-approves safe commands.`));
102
+ console.log(chalk.gray(' Dangerous commands (rm -rf, sudo, force-push, …) still prompt for confirmation.'));
103
+ console.log(chalk.gray(' Pair with /permissions write (no shell) or BRAINROUTER_SANDBOX=on for tighter guardrails.\n'));
104
+ }
105
+ else {
106
+ console.log(chalk.green(`\n✓ /mode planning — run_command asks before each shell call.\n`));
107
+ }
108
+ return true;
109
+ }
110
+ case '/review-policy':
83
111
  {
84
112
  const prefs = readPreferences(agent.workspaceRoot);
85
113
  const arg = (args[0] ?? '').toLowerCase();
86
114
  if (!arg) {
87
- console.log(chalk.bold(`\nAuto-approve shell: ${prefs.autoApproveShell ? chalk.red('ON') : chalk.green('off')}`));
88
- console.log(chalk.gray(' When ON, run_command skips the per-call confirmation prompt and executes immediately.'));
89
- console.log(chalk.gray(' Pair with BRAINROUTER_SANDBOX=on if you still want a safety net.'));
115
+ console.log(chalk.bold(`\nReview policy: ${chalk.cyan(prefs.reviewPolicy)}`));
116
+ console.log(chalk.gray(' request — at workflow/multi-file gates, agent surfaces the plan and waits for /approve. (default)'));
117
+ console.log(chalk.gray(' proceed — agent applies the plan and reports after; use /approve manually for explicit gates.'));
118
+ console.log(chalk.gray(' Toggle with: /review-policy request | /review-policy proceed\n'));
119
+ return true;
120
+ }
121
+ if (arg !== 'request' && arg !== 'proceed') {
122
+ console.log(chalk.red(`\nUnknown policy "${arg}". Choose: request | proceed\n`));
123
+ return true;
124
+ }
125
+ writePreferences(agent.workspaceRoot, { reviewPolicy: arg });
126
+ agent.refreshSystemPrompt();
127
+ if (arg === 'proceed') {
128
+ console.log(chalk.yellow(`\n✓ /review-policy proceed — agent will apply plans without halting for prose approval.`));
129
+ console.log(chalk.gray(' /approve still works as an explicit gesture for workflows that need one.\n'));
130
+ }
131
+ else {
132
+ console.log(chalk.green(`\n✓ /review-policy request — agent will summarize and ask before applying multi-file changes.\n`));
133
+ }
134
+ return true;
135
+ }
136
+ case '/yolo':
137
+ {
138
+ // /yolo is a one-release alias for `/mode fast` + `/review-policy proceed`.
139
+ // We keep it because the muscle memory is established; new docs point to
140
+ // the two split commands for finer control.
141
+ const arg = (args[0] ?? '').toLowerCase();
142
+ if (!arg) {
143
+ const prefs = readPreferences(agent.workspaceRoot);
144
+ const yoloOn = prefs.executionMode === 'fast' && prefs.reviewPolicy === 'proceed';
145
+ console.log(chalk.bold(`\nYolo (alias): ${yoloOn ? chalk.red('ON') : chalk.green('off')}`));
146
+ console.log(chalk.gray(' Shorthand for `/mode fast` + `/review-policy proceed` — flip both axes at once.'));
147
+ console.log(chalk.gray(` Current state: mode=${prefs.executionMode}, review-policy=${prefs.reviewPolicy}`));
148
+ console.log(chalk.gray(' Use /mode and /review-policy directly for finer control.'));
90
149
  console.log(chalk.gray(' Toggle with: /yolo on | /yolo off\n'));
91
150
  return true;
92
151
  }
93
152
  const next = arg === 'on' || arg === 'true' || arg === '1';
94
- writePreferences(agent.workspaceRoot, { autoApproveShell: next });
95
153
  if (next) {
96
- console.log(chalk.red('\n⚠ /yolo ON — run_command will now execute without asking.'));
97
- console.log(chalk.gray(' You are in access mode "shell" so the agent CAN call shell commands.'));
98
- console.log(chalk.gray(' Lower the risk with /permissions write (no shell), or set BRAINROUTER_SANDBOX=on.\n'));
154
+ applyYoloOn(agent.workspaceRoot);
155
+ agent.refreshSystemPrompt();
156
+ ctx.repl.refreshPromptForMode();
157
+ console.log(chalk.red('\n⚠ /yolo ON — shorthand for `/mode fast` + `/review-policy proceed`.'));
158
+ console.log(chalk.gray(' run_command will auto-approve safe commands; dangerous ones still prompt.'));
159
+ console.log(chalk.gray(' Agent will apply multi-file plans without the prose "ready?" pause.'));
160
+ console.log(chalk.gray(' Use /mode and /review-policy for finer control next time.\n'));
99
161
  }
100
162
  else {
101
- console.log(chalk.green('\n✓ /yolo off — run_command will prompt for confirmation again.\n'));
163
+ applyYoloOff(agent.workspaceRoot);
164
+ agent.refreshSystemPrompt();
165
+ ctx.repl.refreshPromptForMode();
166
+ console.log(chalk.green('\n✓ /yolo off — restored /mode planning + /review-policy request.\n'));
102
167
  }
103
168
  return true;
104
169
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 0.3.6 item 11: `/mcp` slash-command surface. Scope-limited foundation —
3
+ * the full multi-MCP federation (parallel cross-MCP tool calls, MCP
4
+ * marketplace, capability tiers) is deferred to 0.4.0. What ships here:
5
+ *
6
+ * /mcp — show status of the active MCP (alias for /mcp list)
7
+ * /mcp list — list every configured profile with identity + status
8
+ * /mcp reconnect — reconnect the currently-active profile
9
+ * /mcp tools — list MCP tools grouped by namespace (pre-Item-11
10
+ * `/mcp` no-arg behaviour, moved here verbatim)
11
+ *
12
+ * The reconnect path leans on `mcpClient.close()` + `mcpClient.connect()`
13
+ * with the same config the CLI launched against — no plumbing required
14
+ * for the user beyond typing the command.
15
+ */
16
+ import type { CommandContext } from './_context.js';
17
+ export declare function tryHandleMcpCommand(ctx: CommandContext): Promise<boolean>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 0.3.6 item 11: `/mcp` slash-command surface. Scope-limited foundation —
3
+ * the full multi-MCP federation (parallel cross-MCP tool calls, MCP
4
+ * marketplace, capability tiers) is deferred to 0.4.0. What ships here:
5
+ *
6
+ * /mcp — show status of the active MCP (alias for /mcp list)
7
+ * /mcp list — list every configured profile with identity + status
8
+ * /mcp reconnect — reconnect the currently-active profile
9
+ * /mcp tools — list MCP tools grouped by namespace (pre-Item-11
10
+ * `/mcp` no-arg behaviour, moved here verbatim)
11
+ *
12
+ * The reconnect path leans on `mcpClient.close()` + `mcpClient.connect()`
13
+ * with the same config the CLI launched against — no plumbing required
14
+ * for the user beyond typing the command.
15
+ */
16
+ import chalk from 'chalk';
17
+ import { spinner as makeSpinner } from '../spinner.js';
18
+ export async function tryHandleMcpCommand(ctx) {
19
+ const { command, args, mcpClient, config } = ctx;
20
+ if (command !== '/mcp')
21
+ return false;
22
+ const sub = (args[0] ?? 'list').toLowerCase();
23
+ if (sub === 'tools') {
24
+ // Pre-Item-11 `/mcp` no-arg behaviour: namespace-grouped tool listing.
25
+ // Kept verbatim under a subcommand so the muscle memory survives.
26
+ const profileName = config.activeServer;
27
+ const server = config.servers[profileName];
28
+ console.log(chalk.bold('\nMCP server'));
29
+ console.log(` Profile: ${chalk.green(profileName)} (${chalk.cyan(server?.type ?? 'unknown')})`);
30
+ if (server?.type === 'http') {
31
+ console.log(` URL: ${chalk.blue(server.url ?? '')}`);
32
+ }
33
+ else if (server?.type === 'stdio') {
34
+ console.log(` Cmd: ${chalk.blue(server.command ?? '')} ${server.args?.join(' ') || ''}`);
35
+ }
36
+ const spinner = makeSpinner(chalk.gray('Fetching MCP tool surface...')).start();
37
+ try {
38
+ const res = await mcpClient.listTools();
39
+ const tools = res.tools || [];
40
+ spinner.succeed(chalk.green(`${tools.length} MCP tools available`));
41
+ const namespaces = {};
42
+ for (const t of tools) {
43
+ const parts = (t.name || '').split('_');
44
+ const ns = parts.length > 1 ? parts[0] : 'misc';
45
+ (namespaces[ns] ||= []).push(t.name);
46
+ }
47
+ for (const ns of Object.keys(namespaces).sort()) {
48
+ console.log(`\n ${chalk.bold.cyan(ns)} (${namespaces[ns].length})`);
49
+ for (const name of namespaces[ns].sort()) {
50
+ console.log(` ${chalk.gray('•')} ${name}`);
51
+ }
52
+ }
53
+ }
54
+ catch (err) {
55
+ spinner.fail(chalk.red(`Failed: ${err.message}`));
56
+ }
57
+ console.log();
58
+ return true;
59
+ }
60
+ if (sub === 'list') {
61
+ const activeName = config.activeServer;
62
+ const profiles = Object.keys(config.servers ?? {});
63
+ if (profiles.length === 0) {
64
+ console.log(chalk.yellow('\nNo MCP profiles configured. Run `brainrouter login` or `brainrouter config` to set one up.\n'));
65
+ return true;
66
+ }
67
+ console.log(chalk.bold('\nConfigured MCP profiles'));
68
+ for (const name of profiles) {
69
+ const profile = config.servers[name];
70
+ const isActive = name === activeName;
71
+ // Identity: explicit config field > live wrapper > 'unknown'.
72
+ let identity = profile.identity ?? 'unknown';
73
+ if (isActive && typeof mcpClient.getIdentity === 'function') {
74
+ identity = mcpClient.getIdentity();
75
+ }
76
+ const onlineLabel = isActive
77
+ ? (mcpClient.isConnected() ? chalk.green('online') : chalk.red('offline'))
78
+ : chalk.gray('idle');
79
+ const idLabel = identity === 'brainrouter'
80
+ ? chalk.cyan('brainrouter')
81
+ : identity === 'third-party'
82
+ ? chalk.yellow('third-party')
83
+ : chalk.gray('unknown');
84
+ const marker = isActive ? chalk.bold('★ ') : ' ';
85
+ const transport = profile.type;
86
+ const target = profile.type === 'http' ? profile.url ?? '<no url>' : profile.command ?? '<no command>';
87
+ console.log(`${marker}${chalk.bold(name)} ${idLabel} ${transport} ${onlineLabel} ${chalk.gray(target)}`);
88
+ }
89
+ console.log(chalk.gray('\n★ = active profile. /mcp reconnect to refresh the active connection.\n'));
90
+ return true;
91
+ }
92
+ if (sub === 'reconnect') {
93
+ const activeName = config.activeServer;
94
+ const profile = config.servers?.[activeName];
95
+ if (!profile) {
96
+ console.log(chalk.red(`\nNo active MCP profile to reconnect (\`activeServer\` = ${JSON.stringify(activeName)}).\n`));
97
+ return true;
98
+ }
99
+ console.log(chalk.gray(`Reconnecting "${activeName}"…`));
100
+ try {
101
+ try {
102
+ await mcpClient.close();
103
+ }
104
+ catch { /* idempotent */ }
105
+ await mcpClient.connect(profile, config.llm, activeName);
106
+ // Re-probe tools so identity tagging and the prompt's tool list refresh.
107
+ try {
108
+ await mcpClient.listTools();
109
+ }
110
+ catch { /* tool-list failure is non-fatal */ }
111
+ console.log(chalk.green(`✓ Reconnected to "${activeName}" (${profile.type}).\n`));
112
+ }
113
+ catch (err) {
114
+ console.log(chalk.red(`✗ Reconnect failed: ${err?.message ?? err}\n`));
115
+ console.log(chalk.gray('The CLI stays in offline mode. Check the MCP server, then try `/mcp reconnect` again.\n'));
116
+ }
117
+ return true;
118
+ }
119
+ console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp reconnect | /mcp tools\n`));
120
+ return true;
121
+ }
@@ -11,7 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import chalk from 'chalk';
14
- import ora from 'ora';
14
+ import { spinner as makeSpinner } from '../spinner.js';
15
15
  import { callMcpTool } from '../../runtime/mcpUtils.js';
16
16
  import { extractMemories, renderMemoryCards } from '../../memory/formatters.js';
17
17
  import { consolidateMemories } from '../../memory/consolidation.js';
@@ -237,7 +237,7 @@ export async function tryHandleMemoryCommand(ctx) {
237
237
  return true;
238
238
  }
239
239
  if (sub === 'consolidate') {
240
- const spinner = ora(chalk.gray('Consolidating memories from MCP into filesystem artifacts...')).start();
240
+ const spinner = makeSpinner(chalk.gray('Consolidating memories from MCP into filesystem artifacts...')).start();
241
241
  try {
242
242
  const result = await consolidateMemories(mcpClient, agent.workspaceRoot, { sessionKey: agent.sessionKey });
243
243
  spinner.succeed(chalk.green(`Consolidated ${result.totalRecords} records.`));
@@ -98,21 +98,23 @@ export async function tryHandleObsCommand(ctx) {
98
98
  {
99
99
  const session = agent.sessionUsage;
100
100
  const metrics = agent.memoryMetrics;
101
- const children = listSessions(agent.workspaceRoot).filter((s) => s.usage);
101
+ // Scope to the live parent: sessions.json is workspace-wide and
102
+ // persists across CLI restarts, so an unfiltered list mixes in every
103
+ // child spawned by every prior CLI process. Filtering by
104
+ // parentSessionKey limits the row to children spawned by THIS parent.
105
+ const children = listSessions(agent.workspaceRoot).filter((s) => s.usage && s.parentSessionKey === agent.sessionKey);
102
106
  const childPrompt = children.reduce((acc, c) => acc + (c.usage?.promptTokens ?? 0), 0);
103
107
  const childCompletion = children.reduce((acc, c) => acc + (c.usage?.completionTokens ?? 0), 0);
104
108
  const childCalls = children.reduce((acc, c) => acc + (c.usage?.calls ?? 0), 0);
105
- // Memory savings estimate:
106
- // - Each recalled record (avg ~200 chars 50 tokens) supplies cross-
107
- // session context that would otherwise require either a manual
108
- // user explanation, a re-read of files, or skill re-discovery.
109
- // Conservative multiplier of to account for the "without memory
110
- // you would have read 3-5 files" replacement cost.
111
- // - Offloaded child output bytes are subtracted from what the parent
112
- // would otherwise have had to carry in context.
113
- const recallSavings = metrics.briefingTokensInjected * 5;
114
- const offloadSavings = Math.round(metrics.offloadCharsAvoided / 4);
115
- const totalSaved = recallSavings + offloadSavings;
109
+ // What we can actually measure:
110
+ // - offload: bytes of child output that did NOT land in the parent's
111
+ // context. These are real tokens not spent on the parent. We
112
+ // subtract the preview that DID land (OFFLOAD_PREVIEW_CHARS is
113
+ // already netted out in tools.ts before recordOffload fires).
114
+ // - briefing tokens: cost, not savings. They're already counted in
115
+ // session.promptTokens. We report them so the user can see how
116
+ // much of the prompt budget memory is consuming.
117
+ const offloadSavedTokens = Math.round(metrics.offloadCharsAvoided / 4);
116
118
  const totalSpent = session.promptTokens + session.completionTokens + childPrompt + childCompletion;
117
119
  console.log(chalk.bold('\nToken usage — this session'));
118
120
  console.log(` Parent: ${chalk.cyan(session.promptTokens.toLocaleString())}↑ ${chalk.cyan(session.completionTokens.toLocaleString())}↓ ${chalk.gray(`(${session.turns} turn${session.turns === 1 ? '' : 's'}, ${session.calls} LLM call${session.calls === 1 ? '' : 's'})`)}`);
@@ -126,17 +128,15 @@ export async function tryHandleObsCommand(ctx) {
126
128
  console.log(chalk.gray(` …and ${children.length - 5} more (see /agents)`));
127
129
  }
128
130
  console.log(` Total this session: ${chalk.bold.cyan(totalSpent.toLocaleString())} tokens`);
129
- console.log(chalk.bold('\nMemory savings (estimated)'));
130
- console.log(` Briefing tokens injected: ${chalk.gray(metrics.briefingTokensInjected.toLocaleString())} (${metrics.recallRecordsConsulted} records consulted)`);
131
- console.log(` Cross-session recall value: ~${chalk.green(recallSavings.toLocaleString())} tokens you'd otherwise spend re-reading files / re-explaining context`);
132
- console.log(` Offload bytes avoided: ${chalk.gray(metrics.offloadCharsAvoided.toLocaleString())} chars (large child outputs that stayed out of parent context)`);
133
- console.log(` → Offload value: ~${chalk.green(offloadSavings.toLocaleString())} tokens`);
134
- console.log(` ${chalk.bold('Total estimated savings:')} ${chalk.bold.green('~' + totalSaved.toLocaleString())} tokens`);
135
- if (totalSpent > 0) {
136
- const ratio = totalSaved / totalSpent;
137
- console.log(chalk.gray(` Ratio: for every 1 token spent, memory saved ~${ratio.toFixed(2)} tokens of context.`));
131
+ console.log(chalk.bold('\nMemory'));
132
+ console.log(` Briefing tokens injected: ${chalk.gray(metrics.briefingTokensInjected.toLocaleString())} ${chalk.gray(`(${metrics.recallRecordsConsulted} records consulted — already included in parent ↑)`)}`);
133
+ console.log(` Child output offloaded: ${chalk.gray(metrics.offloadCharsAvoided.toLocaleString())} chars ${chalk.gray(`(≈${offloadSavedTokens.toLocaleString()} parent tokens not spent)`)}`);
134
+ if (offloadSavedTokens > 0 && totalSpent > 0) {
135
+ const ratio = offloadSavedTokens / totalSpent;
136
+ const display = ratio >= 0.01 ? ratio.toFixed(2) : '<0.01';
137
+ console.log(chalk.gray(` Offload ratio: ~${display} saved per token spent.`));
138
138
  }
139
- console.log(chalk.gray('\n (Estimates use a 5× multiplier on briefing tokens a heuristic for "you would have needed to re-derive this from files/prompts otherwise". Treat as directional, not exact.)\n'));
139
+ console.log(chalk.gray('\n (Offload is measured; briefing tokens are an information-gain stat, not a savings number.)\n'));
140
140
  return true;
141
141
  }
142
142
  case '/feedback':
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import chalk from 'chalk';
7
- import ora from 'ora';
7
+ import { spinner as makeSpinner } from '../spinner.js';
8
8
  import { marked } from 'marked';
9
9
  import { listTranscripts, loadTranscript } from '../../state/sessionStore.js';
10
10
  import { readGoal, resumeGoal } from '../../state/goalStore.js';
@@ -51,6 +51,11 @@ export async function tryHandleSessionCommand(ctx) {
51
51
  return true;
52
52
  }
53
53
  agent.sessionKey = sessionKey;
54
+ // The persisted transcript doesn't record per-call token usage, so
55
+ // we can't reconstruct counters for the resumed session — start
56
+ // counting from this point forward instead of carrying over the
57
+ // pre-resume parent counts (which were for a different session).
58
+ agent.resetSessionCounters();
54
59
  const loaded = agent.loadHistory(entries);
55
60
  console.log(chalk.green(`\n✓ Resumed session ${chalk.cyan(sessionKey)} with ${loaded} prior messages.`));
56
61
  // If the resumed session has a goal that was suspended (paused,
@@ -73,9 +78,12 @@ export async function tryHandleSessionCommand(ctx) {
73
78
  // before/after. Just unpause and kick off the next iteration.
74
79
  const reactivated = resumeGoal(agent.workspaceRoot, sessionKey);
75
80
  if (reactivated) {
76
- // Same rationale as /goal resume drop any stale wrap-up
77
- // steering left over from the budget-trigger that paused us.
78
- agent.removeTaggedSystemMessage('goal-budget-steering');
81
+ // 9d: pre-9d this branch had to drop a `goal-budget-steering`
82
+ // tagged system message left over from a budget-trigger pause.
83
+ // That message no longer exists — the wrap-up directive is
84
+ // folded into the goal-anchor and the anchor is re-rendered
85
+ // by the next runTurn. `refreshSystemPrompt` is still useful
86
+ // here to rebuild any overlays that depend on the active goal.
79
87
  agent.refreshSystemPrompt();
80
88
  console.log(chalk.green(`\n▶ Goal resumed (${reactivated.budget.iterationsUsed}/${reactivated.budget.maxIterations} used). Starting next iteration…\n`));
81
89
  ctx.repl.runAgentTurn(buildGoalKickoffPrompt(reactivated, 'resume'));
@@ -123,7 +131,7 @@ export async function tryHandleSessionCommand(ctx) {
123
131
  }
124
132
  case '/compact':
125
133
  {
126
- const spinner = ora(chalk.gray('Summarizing conversation for compaction...')).start();
134
+ const spinner = makeSpinner(chalk.gray('Summarizing conversation for compaction...')).start();
127
135
  try {
128
136
  const result = await agent.compactHistory();
129
137
  if (!result) {
@@ -6,11 +6,11 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { execSync } from 'node:child_process';
8
8
  import chalk from 'chalk';
9
- import ora from 'ora';
9
+ import { spinner as makeSpinner } from '../spinner.js';
10
10
  import { LOCAL_TOOLS } from '../../agent/agent.js';
11
11
  import { callMcpTool } from '../../runtime/mcpUtils.js';
12
12
  import { listSessions, reconcileStale } from '../../orchestration/orchestrator.js';
13
- import { readPreferences, writePreferences } from '../../state/preferencesStore.js';
13
+ import { readPreferences, resolveEffort, writePreferences } from '../../state/preferencesStore.js';
14
14
  import { readPlan } from '../../state/taskStore.js';
15
15
  import { getConfigPath } from '../../config/config.js';
16
16
  import { copyToClipboard } from '../../runtime/clipboard.js';
@@ -41,7 +41,7 @@ export async function tryHandleUiCommand(ctx) {
41
41
  console.log(` LLM Endpoint: ${chalk.blue(llm.endpoint)}`);
42
42
  }
43
43
  }
44
- const spinner = ora(chalk.gray('Querying diagnostics & testing latency...')).start();
44
+ const spinner = makeSpinner(chalk.gray('Querying diagnostics & testing latency...')).start();
45
45
  try {
46
46
  const start = Date.now();
47
47
  const testRes = await mcpClient.callTool('list_skills', { scope: 'local' });
@@ -116,7 +116,7 @@ export async function tryHandleUiCommand(ctx) {
116
116
  else {
117
117
  console.log(` Endpoint: ${chalk.blue(server.url)}`);
118
118
  }
119
- const spinner = ora(chalk.gray('Checking MCP tool surface...')).start();
119
+ const spinner = makeSpinner(chalk.gray('Checking MCP tool surface...')).start();
120
120
  try {
121
121
  const startedAt = Date.now();
122
122
  const res = await mcpClient.listTools();
@@ -204,42 +204,10 @@ export async function tryHandleUiCommand(ctx) {
204
204
  console.log(chalk.green(`\n✓ Model switched: ${chalk.gray(previous)} → ${chalk.cyan(newModel)}\n`));
205
205
  return true;
206
206
  }
207
- case '/mcp':
208
- {
209
- const profileName = config.activeServer;
210
- const server = config.servers[profileName];
211
- console.log(chalk.bold('\nMCP server'));
212
- console.log(` Profile: ${chalk.green(profileName)} (${chalk.cyan(server?.type ?? 'unknown')})`);
213
- if (server?.type === 'http') {
214
- console.log(` URL: ${chalk.blue(server.url)}`);
215
- }
216
- else if (server?.type === 'stdio') {
217
- console.log(` Cmd: ${chalk.blue(server.command)} ${server.args?.join(' ') || ''}`);
218
- }
219
- const spinner = ora(chalk.gray('Fetching MCP tool surface...')).start();
220
- try {
221
- const res = await mcpClient.listTools();
222
- const tools = res.tools || [];
223
- spinner.succeed(chalk.green(`${tools.length} MCP tools available`));
224
- const namespaces = {};
225
- for (const t of tools) {
226
- const parts = (t.name || '').split('_');
227
- const ns = parts.length > 1 ? parts[0] : 'misc';
228
- (namespaces[ns] ||= []).push(t.name);
229
- }
230
- for (const ns of Object.keys(namespaces).sort()) {
231
- console.log(`\n ${chalk.bold.cyan(ns)} (${namespaces[ns].length})`);
232
- for (const name of namespaces[ns].sort()) {
233
- console.log(` ${chalk.gray('•')} ${name}`);
234
- }
235
- }
236
- }
237
- catch (err) {
238
- spinner.fail(chalk.red(`Failed: ${err.message}`));
239
- }
240
- console.log();
241
- return true;
242
- }
207
+ // /mcp moved to its own command file (commands/mcp.ts) as part of 0.3.6
208
+ // Item 11. The new dispatcher supports `/mcp list`, `/mcp reconnect`,
209
+ // and the original no-arg "show tools by namespace" behaviour is now
210
+ // covered by `/mcp tools` (handled in commands/mcp.ts).
243
211
  case '/copy':
244
212
  {
245
213
  if (!agent.lastAnswer) {
@@ -267,18 +235,18 @@ export async function tryHandleUiCommand(ctx) {
267
235
  {
268
236
  const prefs = readPreferences(agent.workspaceRoot);
269
237
  const arg = args.join(' ').trim();
238
+ const { SEGMENT_NAMES, isKnownSegment } = await import('../statusline.js');
270
239
  if (!arg) {
271
240
  console.log(chalk.bold('\nStatusline'));
272
241
  console.log(` Current: ${chalk.cyan(prefs.statusline)}`);
273
- console.log(chalk.gray(' Available segments: mode, branch, dirty, model, tokens, session'));
274
- console.log(chalk.gray(' Example: /statusline mode,branch,dirty,tokens\n'));
242
+ console.log(chalk.gray(` Available segments: ${SEGMENT_NAMES.join(', ')}`));
243
+ console.log(chalk.gray(' Example: /statusline mode,workflow,goal,model,session,plan\n'));
275
244
  return true;
276
245
  }
277
- const valid = new Set(['mode', 'branch', 'dirty', 'model', 'tokens', 'session']);
278
246
  const requested = arg.split(',').map((s) => s.trim()).filter(Boolean);
279
- const unknown = requested.filter((s) => !valid.has(s));
247
+ const unknown = requested.filter((s) => !isKnownSegment(s));
280
248
  if (unknown.length > 0) {
281
- console.log(chalk.red(`\nUnknown segment(s): ${unknown.join(', ')}. Valid: ${Array.from(valid).join(', ')}\n`));
249
+ console.log(chalk.red(`\nUnknown segment(s): ${unknown.join(', ')}. Valid: ${SEGMENT_NAMES.join(', ')}\n`));
282
250
  return true;
283
251
  }
284
252
  writePreferences(agent.workspaceRoot, { statusline: requested.join(',') });
@@ -373,6 +341,63 @@ export async function tryHandleUiCommand(ctx) {
373
341
  console.log(chalk.green(`\n✓ Raw scrollback ${next ? 'enabled' : 'disabled'}. Markdown rendering ${next ? 'OFF' : 'ON'} for next turn.\n`));
374
342
  return true;
375
343
  }
344
+ case '/effort':
345
+ {
346
+ const arg = (args[0] ?? '').toLowerCase();
347
+ const valid = ['low', 'medium', 'high'];
348
+ if (!arg) {
349
+ const resolved = resolveEffort(agent.workspaceRoot);
350
+ const sourceTag = resolved.source === 'env' ? chalk.gray(' (env: BRAINROUTER_EFFORT)') :
351
+ resolved.source === 'preference' ? chalk.gray(' (preference)') :
352
+ chalk.gray(' (default)');
353
+ console.log(chalk.bold(`\nReasoning depth: ${chalk.cyan(resolved.effort)}${sourceTag}`));
354
+ console.log(chalk.gray(' low — terse, one-paragraph answers; minimal ceremony.'));
355
+ console.log(chalk.gray(' medium — current default; no overlay, no provider reasoning slot. (default)'));
356
+ console.log(chalk.gray(' high — step-by-step reasoning; audits evidence before each tool call.'));
357
+ console.log(chalk.gray(' When the model supports it (gpt-5, o-series, gpt-oss, DeepSeek R1/V3+, Qwen3,'));
358
+ console.log(chalk.gray(' Magistral, *-reasoning, *-thinking — works on OpenAI, DeepSeek, OpenRouter,'));
359
+ console.log(chalk.gray(' LM Studio 0.3.29+, Ollama), the level is also forwarded as `reasoning_effort`.'));
360
+ console.log(chalk.gray(' Toggle with: /effort low | /effort medium | /effort high'));
361
+ console.log(chalk.gray(' Env override (one-shot): BRAINROUTER_EFFORT=high brainrouter\n'));
362
+ return true;
363
+ }
364
+ if (!valid.includes(arg)) {
365
+ console.log(chalk.red(`\nUnknown level "${arg}". Choose: ${valid.join(' | ')}\n`));
366
+ return true;
367
+ }
368
+ writePreferences(agent.workspaceRoot, { effort: arg });
369
+ agent.refreshSystemPrompt();
370
+ const after = resolveEffort(agent.workspaceRoot);
371
+ // Surface a friendly nudge when the env var would still shadow the new
372
+ // preference on the next process boot.
373
+ if (process.env.BRAINROUTER_EFFORT && after.source === 'env') {
374
+ console.log(chalk.yellow(`\n✓ Preference saved as ${arg}, but BRAINROUTER_EFFORT=${process.env.BRAINROUTER_EFFORT} is still active this process — env wins.\n`));
375
+ }
376
+ else {
377
+ console.log(chalk.green(`\n✓ Reasoning depth → ${arg}. Applies on the next turn.\n`));
378
+ }
379
+ return true;
380
+ }
381
+ case '/quiet':
382
+ {
383
+ const prefs = readPreferences(agent.workspaceRoot);
384
+ const arg = (args[0] ?? '').toLowerCase();
385
+ const next = arg ? (arg === 'on' || arg === 'true' || arg === '1') : !prefs.quiet;
386
+ writePreferences(agent.workspaceRoot, { quiet: next });
387
+ // `--quiet` set a one-shot env override at startup; once the user
388
+ // explicitly toggles in-session their choice wins from now on.
389
+ if (next) {
390
+ process.env.BRAINROUTER_QUIET = '1';
391
+ }
392
+ else {
393
+ delete process.env.BRAINROUTER_QUIET;
394
+ }
395
+ const detail = next
396
+ ? 'recall tables, briefing dumps, and tool-completion previews are now hidden.'
397
+ : 'full chrome restored — recall tables, previews, and briefings will print again.';
398
+ console.log(chalk.green(`\n✓ Quiet mode ${next ? 'enabled' : 'disabled'}: ${detail}\n`));
399
+ return true;
400
+ }
376
401
  case '/apps':
377
402
  case '/plugins':
378
403
  {
@@ -468,6 +493,33 @@ export async function tryHandleUiCommand(ctx) {
468
493
  console.log(chalk.gray(' Tip: configure IDE to launch brainrouter with -w <workspace> so paths match.\n'));
469
494
  return true;
470
495
  }
496
+ case '/where':
497
+ {
498
+ const { gatherWhereInputs, renderWhere } = await import('../whereView.js');
499
+ const { resolveTheme } = await import('../theme.js');
500
+ const theme = resolveTheme(agent.workspaceRoot);
501
+ const profileName = config.activeServer;
502
+ const server = config.servers[profileName];
503
+ const briefing = agent.getLastBriefing();
504
+ const inputs = gatherWhereInputs({
505
+ workspaceRoot: agent.workspaceRoot,
506
+ sessionKey: agent.sessionKey,
507
+ model: agent.getModel(),
508
+ mcpProfile: profileName,
509
+ mcpTransport: server?.type ?? 'unknown',
510
+ mcpOnline: mcpClient.isConnected(),
511
+ // 10c: identity flows from the live wrapper; falls back to the
512
+ // config field when present, otherwise 'unknown'.
513
+ mcpIdentity: typeof mcpClient.getIdentity === 'function'
514
+ ? mcpClient.getIdentity()
515
+ : (server?.identity ?? 'unknown'),
516
+ accessMode: agent.getAccessMode(),
517
+ recalledRecords: agent.getRecalledRecords(),
518
+ briefingSources: briefing.sources,
519
+ });
520
+ console.log('\n' + renderWhere(inputs, theme) + '\n');
521
+ return true;
522
+ }
471
523
  case '/help': {
472
524
  renderHelp(args[0]?.toLowerCase());
473
525
  return true;