@kinqs/brainrouter-cli 0.3.6 → 0.3.7

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 (96) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/dist/agent/agent.d.ts +12 -1
  8. package/dist/agent/agent.js +134 -18
  9. package/dist/cli/banner.d.ts +20 -0
  10. package/dist/cli/banner.js +47 -14
  11. package/dist/cli/cliPrompt.d.ts +40 -3
  12. package/dist/cli/cliPrompt.js +52 -25
  13. package/dist/cli/commands/_context.d.ts +3 -1
  14. package/dist/cli/commands/_helpers.d.ts +1 -1
  15. package/dist/cli/commands/config.d.ts +46 -0
  16. package/dist/cli/commands/config.js +1042 -0
  17. package/dist/cli/commands/init.d.ts +20 -0
  18. package/dist/cli/commands/init.js +64 -0
  19. package/dist/cli/commands/login.d.ts +13 -0
  20. package/dist/cli/commands/login.js +179 -0
  21. package/dist/cli/commands/mcp.d.ts +13 -11
  22. package/dist/cli/commands/mcp.js +239 -74
  23. package/dist/cli/commands/orchestration.js +18 -0
  24. package/dist/cli/commands/ui.js +117 -58
  25. package/dist/cli/commands/workflow.d.ts +2 -0
  26. package/dist/cli/commands/workflow.js +54 -8
  27. package/dist/cli/ink/ChatApp.d.ts +206 -0
  28. package/dist/cli/ink/ChatApp.js +493 -0
  29. package/dist/cli/ink/Frame.d.ts +26 -0
  30. package/dist/cli/ink/Frame.js +5 -0
  31. package/dist/cli/ink/Picker.d.ts +65 -0
  32. package/dist/cli/ink/Picker.js +133 -0
  33. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  34. package/dist/cli/ink/SlashPalette.js +136 -0
  35. package/dist/cli/ink/TextField.d.ts +34 -0
  36. package/dist/cli/ink/TextField.js +47 -0
  37. package/dist/cli/ink/WizardApp.d.ts +7 -0
  38. package/dist/cli/ink/WizardApp.js +422 -0
  39. package/dist/cli/ink/ambientChat.d.ts +34 -0
  40. package/dist/cli/ink/ambientChat.js +7 -0
  41. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  42. package/dist/cli/ink/consoleCapture.js +33 -0
  43. package/dist/cli/ink/markdownRender.d.ts +41 -0
  44. package/dist/cli/ink/markdownRender.js +278 -0
  45. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  46. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  47. package/dist/cli/ink/runChat.d.ts +34 -0
  48. package/dist/cli/ink/runChat.js +571 -0
  49. package/dist/cli/ink/runPicker.d.ts +31 -0
  50. package/dist/cli/ink/runPicker.js +139 -0
  51. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  52. package/dist/cli/ink/runSlashPalette.js +33 -0
  53. package/dist/cli/ink/runWizard.d.ts +22 -0
  54. package/dist/cli/ink/runWizard.js +133 -0
  55. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  56. package/dist/cli/ink/stdinHandoff.js +78 -0
  57. package/dist/cli/ink/toolFormat.d.ts +73 -0
  58. package/dist/cli/ink/toolFormat.js +180 -0
  59. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  60. package/dist/cli/ink/useTerminalSize.js +26 -0
  61. package/dist/cli/repl.d.ts +25 -3
  62. package/dist/cli/repl.js +43 -712
  63. package/dist/cli/slashSuggest.d.ts +32 -0
  64. package/dist/cli/slashSuggest.js +146 -0
  65. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  66. package/dist/cli/wizard/modelsApi.js +166 -0
  67. package/dist/cli/wizard/picker.d.ts +202 -0
  68. package/dist/cli/wizard/picker.js +547 -0
  69. package/dist/cli/wizard/providers.d.ts +86 -0
  70. package/dist/cli/wizard/providers.js +190 -0
  71. package/dist/cli/wizard/runner.d.ts +13 -0
  72. package/dist/cli/wizard/runner.js +488 -0
  73. package/dist/cli/wizard/types.d.ts +122 -0
  74. package/dist/cli/wizard/types.js +109 -0
  75. package/dist/config/config.d.ts +12 -0
  76. package/dist/config/config.js +45 -3
  77. package/dist/index.js +148 -206
  78. package/dist/memory/briefing.d.ts +1 -1
  79. package/dist/memory/consolidation.d.ts +1 -1
  80. package/dist/orchestration/agentRegistry.d.ts +36 -0
  81. package/dist/orchestration/agentRegistry.js +64 -0
  82. package/dist/orchestration/orchestrator.d.ts +7 -0
  83. package/dist/orchestration/orchestrator.js +2 -0
  84. package/dist/orchestration/tools.d.ts +10 -1
  85. package/dist/orchestration/tools.js +48 -4
  86. package/dist/prompt/skillCatalog.d.ts +11 -0
  87. package/dist/prompt/skillCatalog.js +134 -0
  88. package/dist/prompt/skillRunner.d.ts +2 -2
  89. package/dist/prompt/skillRunner.js +2 -31
  90. package/dist/prompt/systemPrompt.js +5 -1
  91. package/dist/runtime/mcpClient.js +14 -11
  92. package/dist/runtime/mcpPool.d.ts +162 -0
  93. package/dist/runtime/mcpPool.js +423 -0
  94. package/dist/runtime/mcpUtils.d.ts +3 -1
  95. package/package.json +8 -2
  96. package/.env.example +0 -116
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ // Resolved at import time from dist/orchestration/agentRegistry.js → ../../agents
6
+ const BUILTIN_AGENTS_DIR = fileURLToPath(new URL('../../agents', import.meta.url));
7
+ function getUserAgentsDir() {
8
+ const home = process.env.BRAINROUTER_HOME ?? path.join(os.homedir(), '.config', 'brainrouter');
9
+ return path.join(home, 'agents');
10
+ }
11
+ function getWorkspaceAgentsDir(workspaceRoot) {
12
+ return path.join(workspaceRoot, '.brainrouter', 'agents');
13
+ }
14
+ function loadFromDir(dir, source) {
15
+ if (!fs.existsSync(dir))
16
+ return [];
17
+ let entries;
18
+ try {
19
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ const results = [];
25
+ for (const entry of entries) {
26
+ const filePath = path.join(dir, entry);
27
+ try {
28
+ const raw = fs.readFileSync(filePath, 'utf-8');
29
+ const def = JSON.parse(raw);
30
+ if (!def.id || typeof def.id !== 'string') {
31
+ console.error(`[agentRegistry] Skipping ${filePath}: missing or invalid "id" field.`);
32
+ continue;
33
+ }
34
+ results.push({ def, source, filePath });
35
+ }
36
+ catch (err) {
37
+ console.error(`[agentRegistry] Skipping ${filePath}: ${err.message}`);
38
+ }
39
+ }
40
+ return results;
41
+ }
42
+ /**
43
+ * Load all agent definitions from three tiers (builtin → user-global → workspace).
44
+ * Same `id` from a higher-priority source wins; distinct ids coexist.
45
+ */
46
+ export function loadRegistry(workspaceRoot) {
47
+ const builtin = loadFromDir(BUILTIN_AGENTS_DIR, 'builtin');
48
+ const user = loadFromDir(getUserAgentsDir(), 'user');
49
+ const workspace = workspaceRoot
50
+ ? loadFromDir(getWorkspaceAgentsDir(workspaceRoot), 'workspace')
51
+ : [];
52
+ // Precedence: builtin first (lowest), workspace last (highest).
53
+ const merged = new Map();
54
+ for (const loaded of [...builtin, ...user, ...workspace]) {
55
+ merged.set(loaded.def.id, loaded);
56
+ }
57
+ return Array.from(merged.values());
58
+ }
59
+ export function findById(id, workspaceRoot) {
60
+ return loadRegistry(workspaceRoot).find((l) => l.def.id === id);
61
+ }
62
+ export function listAll(workspaceRoot) {
63
+ return loadRegistry(workspaceRoot);
64
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AccessMode } from './roles.js';
2
+ import type { Tier } from './agentRegistry.js';
2
3
  export type ChildStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stale' | 'closed';
3
4
  export interface ChildSessionRecord {
4
5
  id: string;
@@ -14,6 +15,10 @@ export interface ChildSessionRecord {
14
15
  pid: number;
15
16
  finalOutput?: string;
16
17
  error?: string;
18
+ /** Agent tier from the definition (reasoning | worker). Undefined for legacy records. */
19
+ tier?: Tier;
20
+ /** Nesting depth in the spawn chain; 0 = direct child of the chat root. */
21
+ depth?: number;
17
22
  /** LLM usage attributable to this child (filled when the child completes). */
18
23
  usage?: {
19
24
  promptTokens: number;
@@ -30,6 +35,8 @@ export declare function createSession(workspaceRoot: string, input: {
30
35
  parentSessionKey: string;
31
36
  access?: AccessMode;
32
37
  label?: string;
38
+ tier?: Tier;
39
+ depth?: number;
33
40
  }): ChildSessionRecord;
34
41
  export declare function updateSession(workspaceRoot: string, id: string, patch: Partial<ChildSessionRecord>): ChildSessionRecord;
35
42
  export declare function reconcileStale(workspaceRoot: string): number;
@@ -28,6 +28,8 @@ export function createSession(workspaceRoot, input) {
28
28
  startedAt: now,
29
29
  updatedAt: now,
30
30
  pid: process.pid,
31
+ tier: input.tier,
32
+ depth: input.depth,
31
33
  };
32
34
  const data = readFile(workspaceRoot);
33
35
  data.sessions.push(record);
@@ -1,6 +1,7 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  import type { LLMConfig } from '../config/config.js';
3
3
  import { type AccessMode } from './roles.js';
4
+ import { type Tier } from './agentRegistry.js';
4
5
  export interface OrchestrationContext {
5
6
  workspaceRoot: string;
6
7
  parentSessionKey: string;
@@ -21,6 +22,10 @@ export interface OrchestrationContext {
21
22
  parentSpanId?: string;
22
23
  /** Parent agent_id so children can be grouped via attribute even without trace links. */
23
24
  parentAgentId?: string;
25
+ /** Parent agent tier — used for hierarchy checks (worker cannot spawn; reasoning can only spawn workers). */
26
+ parentTier?: Tier;
27
+ /** Current spawn-chain depth (0 = direct child of chat root). */
28
+ depth?: number;
24
29
  mcpClient: McpClientWrapper;
25
30
  llmConfig: LLMConfig;
26
31
  launchCwd: string;
@@ -76,6 +81,10 @@ export declare function createSpawnAgentTool(): {
76
81
  type: string;
77
82
  description: string;
78
83
  };
84
+ agentId: {
85
+ type: string;
86
+ description: string;
87
+ };
79
88
  prompt: {
80
89
  type: string;
81
90
  description: string;
@@ -1,6 +1,7 @@
1
1
  import { Agent } from '../agent/agent.js';
2
2
  import { createSession, formatSessionSummary, getSession, listSessions, updateSession, } from './orchestrator.js';
3
3
  import { buildRolePrompt, resolveRole } from './roles.js';
4
+ import { findById, listAll } from './agentRegistry.js';
4
5
  import { buildSystemPrompt, loadWorkspaceInstructionSummary } from '../prompt/systemPrompt.js';
5
6
  import { readTranscriptEntries } from '../state/sessionStore.js';
6
7
  import { callMcpTool, childSessionKey } from '../runtime/mcpUtils.js';
@@ -100,11 +101,12 @@ export function trackedPromiseFor(id) {
100
101
  export function createSpawnAgentTool() {
101
102
  return {
102
103
  name: 'spawn_agent',
103
- description: 'Spawn a child agent with a specific role (explorer, architect, reviewer, worker, verifier) and a bounded prompt. Returns the child agent id immediately; the child runs in the background.',
104
+ description: 'Spawn a child agent and a bounded prompt. Returns the child agent id immediately; the child runs in the background. Specify the agent via `role` (legacy: explorer/architect/reviewer/worker/verifier) or `agentId` (registry id, e.g. a custom workspace definition).',
104
105
  inputSchema: {
105
106
  type: 'object',
106
107
  properties: {
107
- role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier.' },
108
+ role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier. Prefer agentId for custom definitions.' },
109
+ agentId: { type: 'string', description: 'Registry id of the agent definition. Takes precedence over role when both are provided.' },
108
110
  prompt: { type: 'string', description: 'The bounded task prompt for the child agent.' },
109
111
  label: { type: 'string', description: 'Optional short label for the child run.' },
110
112
  access: { type: 'string', enum: ['read', 'write', 'shell'], description: 'Override the role default access mode. Default: role default.' },
@@ -116,7 +118,7 @@ export function createSpawnAgentTool() {
116
118
  description: 'Optional BrainRouter memory record IDs that the parent already recalled. The child agent is told to build on these instead of re-discovering them.',
117
119
  },
118
120
  },
119
- required: ['role', 'prompt'],
121
+ required: ['prompt'],
120
122
  },
121
123
  };
122
124
  }
@@ -297,10 +299,47 @@ function explainRoute(task, role) {
297
299
  }
298
300
  }
299
301
  async function handleSpawn(args, ctx) {
300
- const role = resolveRole(String(args.role));
302
+ // Resolve agent definition via agentId (registry) or role (legacy).
303
+ let role;
304
+ let childTier;
305
+ if (typeof args.agentId === 'string' && args.agentId.trim()) {
306
+ const loaded = findById(args.agentId.trim(), ctx.workspaceRoot);
307
+ if (!loaded) {
308
+ const known = listAll(ctx.workspaceRoot).map((l) => l.def.id).join(', ');
309
+ throw new Error(`Unknown agentId "${args.agentId}". Known agents: ${known}.`);
310
+ }
311
+ role = {
312
+ name: loaded.def.id,
313
+ description: loaded.def.whenToUse,
314
+ defaultAccess: loaded.def.defaultAccess,
315
+ promptOverlay: loaded.def.prompt,
316
+ };
317
+ childTier = loaded.def.tier;
318
+ }
319
+ else {
320
+ const roleName = String(args.role ?? '');
321
+ if (!roleName.trim())
322
+ throw new Error('spawn_agent requires either "agentId" or "role".');
323
+ role = resolveRole(roleName);
324
+ childTier = findById(role.name, ctx.workspaceRoot)?.def.tier;
325
+ }
301
326
  const prompt = String(args.prompt ?? '');
302
327
  if (!prompt.trim())
303
328
  throw new Error('spawn_agent requires a non-empty prompt.');
329
+ // P1.2 — spawn hierarchy checks.
330
+ const rawMaxDepth = parseInt(process.env.BRAINROUTER_MAX_SPAWN_DEPTH ?? '3', 10);
331
+ const maxDepth = Number.isFinite(rawMaxDepth) && rawMaxDepth > 0 ? rawMaxDepth : 3;
332
+ const currentDepth = ctx.depth ?? 0;
333
+ const parentTier = ctx.parentTier;
334
+ if (parentTier === 'worker') {
335
+ throw new Error('Tier "worker" cannot delegate — ask the parent agent to spawn instead.');
336
+ }
337
+ if (parentTier === 'reasoning' && childTier && (childTier === 'chat' || childTier === 'reasoning')) {
338
+ throw new Error(`Tier "reasoning" cannot spawn a "${childTier}" agent — only "worker" children are allowed.`);
339
+ }
340
+ if (currentDepth >= maxDepth) {
341
+ throw new Error(`Spawn depth cap reached (${currentDepth}/${maxDepth}). Reduce agent nesting or raise BRAINROUTER_MAX_SPAWN_DEPTH.`);
342
+ }
304
343
  const requested = args.access ?? role.defaultAccess;
305
344
  const access = clampAccess(ctx.parentAccessMode ?? 'shell', requested);
306
345
  const record = createSession(ctx.workspaceRoot, {
@@ -309,6 +348,8 @@ async function handleSpawn(args, ctx) {
309
348
  parentSessionKey: ctx.parentSessionKey,
310
349
  access,
311
350
  label: typeof args.label === 'string' ? args.label : undefined,
351
+ tier: childTier,
352
+ depth: currentDepth + 1,
312
353
  });
313
354
  const childKey = childSessionKey(ctx.parentSessionKey, record.id);
314
355
  const seededIds = Array.isArray(args.seedRecordIds)
@@ -346,6 +387,9 @@ async function handleSpawn(args, ctx) {
346
387
  // dispatching spawn_agent tool span instead of starting a fresh tree.
347
388
  parentTraceId: ctx.parentTraceId,
348
389
  parentSpanId: ctx.parentSpanId,
390
+ // Propagate tier and depth so grandchildren can enforce hierarchy caps.
391
+ tier: childTier,
392
+ agentDepth: currentDepth + 1,
349
393
  });
350
394
  if (ctx.parentAgentId)
351
395
  childAgent.setParentAgentId(ctx.parentAgentId);
@@ -0,0 +1,11 @@
1
+ export interface SkillListItem {
2
+ name: string;
3
+ scope?: string;
4
+ category?: string;
5
+ description?: string;
6
+ source?: 'mcp' | 'filesystem';
7
+ }
8
+ export declare function listFilesystemSkills(workspaceRoot: string): SkillListItem[];
9
+ export declare function mergeSkillLists(primary: SkillListItem[], fallback: SkillListItem[]): SkillListItem[];
10
+ export declare function sortSkills(a: SkillListItem, b: SkillListItem): number;
11
+ export declare function skillSearchRoots(workspaceRoot: string): string[];
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ const requireFromHere = createRequire(import.meta.url);
5
+ const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
6
+ export function listFilesystemSkills(workspaceRoot) {
7
+ const seen = new Map();
8
+ for (const root of skillSearchRoots(workspaceRoot)) {
9
+ if (!fs.existsSync(root))
10
+ continue;
11
+ const scope = inferRootScope(root, workspaceRoot);
12
+ for (const filePath of findSkillFiles(root)) {
13
+ const parsed = parseSkillFile(filePath);
14
+ if (!parsed)
15
+ continue;
16
+ const rel = path.relative(root, filePath);
17
+ const category = rel.split(path.sep)[0] || 'uncategorized';
18
+ if (!seen.has(parsed.name)) {
19
+ seen.set(parsed.name, {
20
+ name: parsed.name,
21
+ category,
22
+ description: parsed.description,
23
+ scope,
24
+ source: 'filesystem',
25
+ });
26
+ }
27
+ }
28
+ }
29
+ return Array.from(seen.values()).sort(sortSkills);
30
+ }
31
+ export function mergeSkillLists(primary, fallback) {
32
+ const merged = new Map();
33
+ for (const skill of primary) {
34
+ merged.set(skill.name, { ...skill, source: skill.source ?? 'mcp' });
35
+ }
36
+ for (const skill of fallback) {
37
+ if (!merged.has(skill.name))
38
+ merged.set(skill.name, skill);
39
+ }
40
+ return Array.from(merged.values()).sort(sortSkills);
41
+ }
42
+ export function sortSkills(a, b) {
43
+ return (a.category ?? '').localeCompare(b.category ?? '') || a.name.localeCompare(b.name);
44
+ }
45
+ export function skillSearchRoots(workspaceRoot) {
46
+ const roots = [];
47
+ for (const sub of WORKSPACE_SKILL_ROOTS)
48
+ roots.push(path.join(workspaceRoot, sub));
49
+ const mcpPkgDir = resolveInstalledMcpPackageDir();
50
+ if (mcpPkgDir) {
51
+ roots.push(path.join(mcpPkgDir, 'skills'));
52
+ const monorepoRoot = path.dirname(mcpPkgDir);
53
+ roots.push(path.join(monorepoRoot, 'skills'));
54
+ }
55
+ return [...new Set(roots.map((root) => path.resolve(root)))];
56
+ }
57
+ function resolveInstalledMcpPackageDir() {
58
+ try {
59
+ const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
60
+ return path.dirname(pkgJsonPath);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ function inferRootScope(root, workspaceRoot) {
67
+ const resolvedWorkspace = path.resolve(workspaceRoot);
68
+ const resolvedRoot = path.resolve(root);
69
+ if (resolvedRoot.startsWith(path.join(resolvedWorkspace, '.brainrouter')))
70
+ return 'local';
71
+ if (isBrainRouterRepoRoot(path.dirname(resolvedRoot)))
72
+ return 'global';
73
+ return resolvedRoot.startsWith(resolvedWorkspace) ? 'local' : 'global';
74
+ }
75
+ function isBrainRouterRepoRoot(root) {
76
+ return (fs.existsSync(path.join(root, 'brainrouter', 'package.json')) &&
77
+ fs.existsSync(path.join(root, 'brainrouter-cli', 'package.json')) &&
78
+ fs.existsSync(path.join(root, 'skills')));
79
+ }
80
+ function findSkillFiles(root) {
81
+ const results = [];
82
+ function walk(current, depth) {
83
+ if (depth < 0)
84
+ return;
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(current, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ for (const entry of entries) {
93
+ if (entry.name === 'node_modules' || entry.name === '.git')
94
+ continue;
95
+ const full = path.join(current, entry.name);
96
+ if (entry.isDirectory())
97
+ walk(full, depth - 1);
98
+ else if (entry.isFile() && entry.name === 'SKILL.md')
99
+ results.push(full);
100
+ }
101
+ }
102
+ walk(root, 5);
103
+ return results;
104
+ }
105
+ function parseSkillFile(filePath) {
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(filePath, 'utf8');
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
114
+ const block = frontmatter?.[1] ?? '';
115
+ const name = readYamlScalar(block, 'name') ?? path.basename(path.dirname(filePath));
116
+ const description = readYamlScalar(block, 'description') ?? firstParagraph(raw);
117
+ if (!name)
118
+ return undefined;
119
+ return { name, description };
120
+ }
121
+ function readYamlScalar(block, key) {
122
+ const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
123
+ if (!match?.[1])
124
+ return undefined;
125
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
126
+ }
127
+ function firstParagraph(raw) {
128
+ const withoutFrontmatter = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, '').trim();
129
+ const line = withoutFrontmatter
130
+ .split(/\r?\n/)
131
+ .map((part) => part.trim())
132
+ .find((part) => part && !part.startsWith('#'));
133
+ return line;
134
+ }
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClient } from '../runtime/mcpUtils.js';
2
2
  export interface SkillResolution {
3
3
  name: string;
4
4
  body: string;
@@ -24,7 +24,7 @@ export declare const SLASH_TO_SKILL: Record<string, string>;
24
24
  * server has loaded, including their own private skills), falls back to a
25
25
  * local filesystem scan of `skills/` for when the MCP tool is unavailable.
26
26
  */
27
- export declare function resolveSkill(mcpClient: McpClientWrapper, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
27
+ export declare function resolveSkill(mcpClient: McpClient, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
28
28
  /**
29
29
  * Build the user prompt that asks the agent to execute a skill. The skill
30
30
  * body is embedded so the agent does not need to round-trip through
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- const requireFromHere = createRequire(import.meta.url);
3
+ import { skillSearchRoots } from './skillCatalog.js';
5
4
  /**
6
5
  * Slash-command → skill mapping. Each entry names the skill catalogued in
7
6
  * the BrainRouter skills/ folder (and exposed by the MCP server) that the
@@ -21,7 +20,6 @@ export const SLASH_TO_SKILL = {
21
20
  '/refactor': 'code-simplification',
22
21
  '/test': 'testing-skill',
23
22
  };
24
- const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
25
23
  /**
26
24
  * Resolve a skill by name. Prefers the MCP server (so users get whatever the
27
25
  * server has loaded, including their own private skills), falls back to a
@@ -61,34 +59,7 @@ function readSkillFromFilesystem(workspaceRoot, name) {
61
59
  }
62
60
  return undefined;
63
61
  }
64
- /**
65
- * Roots to search for SKILL.md when the MCP server is unavailable. Includes:
66
- * - the user's workspace (so per-project skills win locally)
67
- * - the installed @kinqs/brainrouter-mcp-server package directory (so the canonical
68
- * BrainRouter catalogue is found even when MCP is down, because prepack
69
- * bundles `skills/` into the published package)
70
- * - the monorepo root (when running from source during development)
71
- */
72
- function skillSearchRoots(workspaceRoot) {
73
- const roots = [];
74
- for (const sub of WORKSPACE_SKILL_ROOTS) {
75
- roots.push(path.join(workspaceRoot, sub));
76
- }
77
- const mcpPkgDir = resolveInstalledMcpPackageDir();
78
- if (mcpPkgDir)
79
- roots.push(path.join(mcpPkgDir, 'skills'));
80
- return roots;
81
- }
82
- function resolveInstalledMcpPackageDir() {
83
- try {
84
- const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
85
- return path.dirname(pkgJsonPath);
86
- }
87
- catch {
88
- return undefined;
89
- }
90
- }
91
- function findSkillDir(rootDir, skillName, depth = 3) {
62
+ function findSkillDir(rootDir, skillName, depth = 5) {
92
63
  if (depth < 0)
93
64
  return undefined;
94
65
  let entries;
@@ -77,7 +77,11 @@ function clarifyOverlay(activeSkill) {
77
77
  function isBrainOnline(connectedTools) {
78
78
  if (!connectedTools)
79
79
  return true;
80
- return connectedTools.includes('memory_recall');
80
+ // Match bare `memory_recall`, double-underscore `mcp__<server>__memory_recall`,
81
+ // and single-underscore `mcp_<server>_memory_recall` (both prefix conventions
82
+ // are in use across the multi-MCP codepaths until naming is unified).
83
+ return connectedTools.some((tool) => tool === 'memory_recall' ||
84
+ (tool.startsWith('mcp_') && tool.endsWith('memory_recall')));
81
85
  }
82
86
  function brainOfflineNotice() {
83
87
  return [
@@ -23,7 +23,7 @@ export class McpClientWrapper {
23
23
  identity = 'unknown';
24
24
  serverName;
25
25
  constructor() {
26
- this.client = new Client({ name: 'brainrouter-cli', version: '0.3.5' }, { capabilities: {} });
26
+ this.client = new Client({ name: 'brainrouter-cli', version: '0.3.7' }, { capabilities: {} });
27
27
  }
28
28
  /** Whether this wrapper has an active MCP transport. */
29
29
  isConnected() {
@@ -139,16 +139,11 @@ export class McpClientWrapper {
139
139
  if (llmConfig?.model && !mergedEnv.BRAINROUTER_LLM_MODEL) {
140
140
  mergedEnv.BRAINROUTER_LLM_MODEL = llmConfig.model;
141
141
  }
142
- // Loud diagnostic: if NO LLM key reached the child, server-side
143
- // memory extraction is dead every sensory capture will pile up
144
- // un-extracted. Print a yellow banner so the user knows BEFORE they
145
- // see "0 records" in every briefing.
146
- if (!mergedEnv.BRAINROUTER_LLM_API_KEY) {
147
- console.warn('\n⚠️ No LLM API key reached the MCP child. Sensory turns will be ' +
148
- 'captured but cognitive extraction (the thing that makes them ' +
149
- 'searchable) will fail silently. Set OPENAI_API_KEY or ' +
150
- 'BRAINROUTER_LLM_API_KEY before starting brainrouter.\n');
151
- }
142
+ // (Previously: a loud console.warn here if no LLM API key reached the
143
+ // MCP child. That message landed above the Ink banner and looked like a
144
+ // CLI error even though it was a server-side concern. Server-side
145
+ // extraction failures should surface through MCP's own status channel —
146
+ // not by the CLI second-guessing what the server needs.)
152
147
  // Spawn the MCP child with cwd set to the MCP package directory if we
153
148
  // can find it from the first arg (typically
154
149
  // `node /path/to/BrainRouter/brainrouter/dist/index.js`). The child
@@ -179,6 +174,14 @@ export class McpClientWrapper {
179
174
  args: serverConfig.args ?? [],
180
175
  env: mergedEnv,
181
176
  cwd: childCwd,
177
+ // The MCP child is a separate process with its own concerns (its own
178
+ // dotenv, its own auth failures, its own platform warnings). Inheriting
179
+ // its stderr meant every `[BrainRouter] FATAL …`, every dotenv banner,
180
+ // every SQLite ExperimentalWarning leaked above our Ink chat banner
181
+ // and looked like the CLI was crashing. Pipe it so the SDK owns the
182
+ // stream; the CLI can surface a single graceful "MCP unreachable" line
183
+ // through its own offline-mode flow instead.
184
+ stderr: 'pipe',
182
185
  });
183
186
  await this.client.connect(this.transport);
184
187
  this.connected = true;
@@ -0,0 +1,162 @@
1
+ import { McpClientWrapper } from './mcpClient.js';
2
+ import type { LLMConfig, ServerConfig } from '../config/config.js';
3
+ /**
4
+ * 0.3.7 — Multi-MCP support. Wraps a `Map<serverId, McpClientWrapper>`
5
+ * and exposes the same public API as a single wrapper (`isConnected`,
6
+ * `getIdentity`, `getServerName`, `listTools`, `callTool`, `close`),
7
+ * so existing call-sites that hold an `mcpClient` reference keep
8
+ * working unchanged.
9
+ *
10
+ * Pattern lifted from Claude Code's `.mcp.json` model (see
11
+ * `openSrc/claude-code/CHANGELOG.md` — concurrent startup at line 688,
12
+ * tool prefixing at line 1515, graceful degradation at line 189). Our
13
+ * shape:
14
+ *
15
+ * - All configured servers attempt connection concurrently on boot,
16
+ * each with a 5s timeout. Offline ones do NOT block others.
17
+ * - Tools surface to the agent with `mcp_<serverId>_<toolName>`
18
+ * prefix (Claude Code style).
19
+ * - `callTool` accepts BOTH the prefixed form (the canonical name
20
+ * the LLM sees in the tool inventory) AND the raw form (back-compat
21
+ * for the existing system prompt and skills that hardcode
22
+ * `memory_recall` etc.). Raw form routes to the unique server
23
+ * providing that tool name; collision (two servers expose the same
24
+ * unprefixed name) returns a helpful error pointing at the
25
+ * prefixed form.
26
+ *
27
+ * Future versions may drop the raw-name fallback once skills and
28
+ * prompts are migrated to prefixed names; the pool then becomes the
29
+ * pure Claude Code shape. Until then the dual-name resolution is a
30
+ * transition aid documented in CHANGELOG `[0.3.7]`.
31
+ */
32
+ export type McpServerStatus = {
33
+ serverId: string;
34
+ identity: 'brainrouter' | 'third-party' | 'unknown';
35
+ /** 'connected' once the underlying wrapper reports isConnected. */
36
+ status: 'connected' | 'connecting' | 'offline' | 'failed';
37
+ /** Filled after the first successful listTools (used by /mcp list). */
38
+ toolCount?: number;
39
+ /** Per-server error message when status === 'failed'. */
40
+ error?: string;
41
+ };
42
+ /**
43
+ * Choose which configured MCP profiles should connect for a normal CLI run.
44
+ *
45
+ * BrainRouter profiles are special: users may store several BrainRouter MCPs
46
+ * (local, staging, remote, self-hosted), but only one should be active at a
47
+ * time because the BrainRouter MCP is the memory/brain plane. Third-party MCPs
48
+ * are additive tools, so they all connect concurrently.
49
+ *
50
+ * `requestedProfile` is the explicit escape hatch (`--profile <name>`): it
51
+ * scopes the run to exactly that profile, matching the existing single-server
52
+ * mode.
53
+ */
54
+ export declare function selectMcpServerIds(servers: Record<string, ServerConfig>, activeServer?: string, requestedProfile?: string): string[];
55
+ export declare class McpClientPool {
56
+ /** serverId → connected wrapper. */
57
+ private clients;
58
+ /** serverId → status entry (kept even for failed/offline servers so /mcp can render them). */
59
+ private statuses;
60
+ /**
61
+ * Unprefixed tool name → owning serverId. Sentinel `__COLLISION__`
62
+ * marks tool names exposed by multiple servers (must be addressed
63
+ * via the prefixed form).
64
+ */
65
+ private toolToServer;
66
+ /** Prefixed form (`mcp_<serverId>_<tool>`) → `{serverId, tool}` for fast dispatch. */
67
+ private prefixedToServer;
68
+ /** LLM config from the last connectAll — needed for reconnect calls. */
69
+ private currentLlmConfig?;
70
+ /** Raw server configs from the last connectAll — needed for /mcp reconnect <id>. */
71
+ private serverConfigs;
72
+ /** serverId → prefix used in tool names. Brainrouter servers get "brainrouter"; others keep their config key. */
73
+ private prefixIds;
74
+ /** Reverse: prefixId → serverId (needed to dispatch `mcp_brainrouter_X` back to the real key). */
75
+ private prefixToServerId;
76
+ private getPrefixId;
77
+ private assignPrefixId;
78
+ /**
79
+ * Connect to every entry in `servers` concurrently. Each connect
80
+ * gets its own timeout; offline servers don't block the others.
81
+ * Returns the status array after all connects settle.
82
+ */
83
+ connectAll(servers: Record<string, ServerConfig>, llmConfig?: LLMConfig, options?: {
84
+ timeoutMs?: number;
85
+ onStatusChange?: (s: McpServerStatus) => void;
86
+ }): Promise<McpServerStatus[]>;
87
+ /**
88
+ * Connect a single server. Used both by `connectAll` and by
89
+ * `/mcp connect <id>` for late-joining servers. Idempotent — if
90
+ * the server is already connected, closes the previous wrapper first.
91
+ */
92
+ connectOne(serverId: string, config: ServerConfig, llmConfig?: LLMConfig, timeoutMs?: number): Promise<void>;
93
+ /** Tear down a single server. Removes it from the pool and rebuilds the tool index. */
94
+ disconnectOne(serverId: string): Promise<void>;
95
+ /** Reconnect: close + connect again using the stashed config. */
96
+ reconnectOne(serverId: string): Promise<void>;
97
+ /**
98
+ * Walk every connected client and rebuild the tool→server indices.
99
+ * Called after every connect / disconnect / reconnect so the
100
+ * dispatch path stays correct without re-fetching tools on every
101
+ * `callTool`.
102
+ */
103
+ private refreshToolIndex;
104
+ /**
105
+ * Concatenated tool list across every connected server, with names
106
+ * prefixed `mcp_<serverId>_<toolName>` (Claude Code style). The
107
+ * agent calls this once per turn and hands it to the LLM.
108
+ */
109
+ listTools(): Promise<{
110
+ tools: any[];
111
+ }>;
112
+ /**
113
+ * Route a tool call to the right server. Accepts both name forms:
114
+ *
115
+ * - `mcp_<serverId>_<tool>` — the canonical form the LLM sees
116
+ * in the inventory. Stripped + dispatched directly.
117
+ * - `<tool>` raw form — back-compat for prompts/skills that
118
+ * hardcode `memory_recall`-style names. Routed to the unique
119
+ * server providing that tool. Returns a helpful error if two
120
+ * servers expose the same name (caller must use the prefix).
121
+ */
122
+ callTool(name: string, args: Record<string, any>): Promise<any>;
123
+ /** Internal — map a name (prefixed OR raw) to a concrete server + tool. */
124
+ private resolveToolCall;
125
+ /** True iff at least one server is connected. */
126
+ isConnected(): boolean;
127
+ /**
128
+ * Identity precedence: any connected `brainrouter` > any connected
129
+ * `third-party` > `unknown`. The CLI banner + offline prompt swap
130
+ * branch on this — "BrainRouter is offline" makes sense only when
131
+ * we expected one and didn't get one.
132
+ */
133
+ getIdentity(): 'brainrouter' | 'third-party' | 'unknown';
134
+ /**
135
+ * Human-readable summary for the banner/statusline. Single-server
136
+ * pools render just the server name; multi-server pools render
137
+ * a count + the first few names.
138
+ */
139
+ getServerName(): string | undefined;
140
+ /**
141
+ * Look up a wrapper by serverId. Used by `/mcp tools <server>` and
142
+ * similar commands that want to talk to one specific server.
143
+ */
144
+ getClient(serverId: string): McpClientWrapper | undefined;
145
+ /**
146
+ * Find the connected wrapper whose identity is 'brainrouter'. Some
147
+ * code paths (memory capture, working-memory offload) specifically
148
+ * need the canonical brain regardless of how many third-party MCPs
149
+ * the user added.
150
+ */
151
+ getBrainrouterClient(): McpClientWrapper | undefined;
152
+ /** Server id for the currently connected BrainRouter MCP, if one is active. */
153
+ getActiveBrainrouterServerId(): string | undefined;
154
+ /** Status snapshot for every server the pool has tried to connect to. */
155
+ getStatuses(): McpServerStatus[];
156
+ /** Status for one server (returns undefined if the pool has never seen it). */
157
+ getStatus(serverId: string): McpServerStatus | undefined;
158
+ /** List of serverIds currently held by the pool (connected or not). */
159
+ getServerIds(): string[];
160
+ /** Close every wrapper. Used on CLI exit. */
161
+ close(): Promise<void>;
162
+ }