@kinqs/brainrouter-cli 0.3.6 → 0.3.8

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 (129) 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/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. package/.env.example +0 -116
@@ -0,0 +1,442 @@
1
+ import { McpClientWrapper, resolveIdentityFromConfig } from './mcpClient.js';
2
+ /**
3
+ * Choose which configured MCP profiles should connect for a normal CLI run.
4
+ *
5
+ * BrainRouter profiles are special: users may store several BrainRouter MCPs
6
+ * (local, staging, remote, self-hosted), but only one should be active at a
7
+ * time because the BrainRouter MCP is the memory/brain plane. Third-party MCPs
8
+ * are additive tools, so they all connect concurrently.
9
+ *
10
+ * `requestedProfile` is the explicit escape hatch (`--profile <name>`): it
11
+ * scopes the run to exactly that profile, matching the existing single-server
12
+ * mode.
13
+ */
14
+ export function selectMcpServerIds(servers, activeServer, requestedProfile) {
15
+ const ids = Object.keys(servers);
16
+ if (requestedProfile)
17
+ return servers[requestedProfile] ? [requestedProfile] : [];
18
+ const brainrouterIds = ids.filter((id) => resolveIdentityFromConfig(servers[id], id) === 'brainrouter');
19
+ const activeBrainrouter = activeServer && brainrouterIds.includes(activeServer)
20
+ ? activeServer
21
+ : brainrouterIds[0];
22
+ return ids.filter((id) => {
23
+ const identity = resolveIdentityFromConfig(servers[id], id);
24
+ return identity !== 'brainrouter' || id === activeBrainrouter;
25
+ });
26
+ }
27
+ /**
28
+ * 0.3.8-R5 — Single-underscore `mcp_<server>_<tool>` is the canonical
29
+ * tool-name shape across the CLI. Any legacy double-underscore
30
+ * `mcp__<server>__<tool>` form that arrives at the pool boundary
31
+ * (e.g. through an external surface or older skill) is collapsed here
32
+ * so downstream code can assume one convention.
33
+ */
34
+ export function normalizeMcpToolName(name) {
35
+ if (!name.startsWith('mcp__'))
36
+ return name;
37
+ const rest = name.slice('mcp__'.length);
38
+ const sep = rest.indexOf('__');
39
+ if (sep < 0)
40
+ return name;
41
+ return `mcp_${rest.slice(0, sep)}_${rest.slice(sep + 2)}`;
42
+ }
43
+ function isBrainrouterOwnedTool(name) {
44
+ return name.startsWith('memory_') ||
45
+ [
46
+ 'list_skills',
47
+ 'get_skill',
48
+ 'search_skills',
49
+ 'create_skill',
50
+ 'update_skill',
51
+ 'get_persona',
52
+ 'get_reference',
53
+ 'list_template_docs',
54
+ 'get_template_doc',
55
+ ].includes(name);
56
+ }
57
+ export class McpClientPool {
58
+ /** serverId → connected wrapper. */
59
+ clients = new Map();
60
+ /** serverId → status entry (kept even for failed/offline servers so /mcp can render them). */
61
+ statuses = new Map();
62
+ /**
63
+ * Unprefixed tool name → owning serverId. Sentinel `__COLLISION__`
64
+ * marks tool names exposed by multiple servers (must be addressed
65
+ * via the prefixed form).
66
+ */
67
+ toolToServer = new Map();
68
+ /** Prefixed form (`mcp_<serverId>_<tool>`) → `{serverId, tool}` for fast dispatch. */
69
+ prefixedToServer = new Map();
70
+ /** LLM config from the last connectAll — needed for reconnect calls. */
71
+ currentLlmConfig;
72
+ /** Raw server configs from the last connectAll — needed for /mcp reconnect <id>. */
73
+ serverConfigs = new Map();
74
+ /** serverId → prefix used in tool names. Brainrouter servers get "brainrouter"; others keep their config key. */
75
+ prefixIds = new Map();
76
+ /** Reverse: prefixId → serverId (needed to dispatch `mcp_brainrouter_X` back to the real key). */
77
+ prefixToServerId = new Map();
78
+ getPrefixId(serverId) {
79
+ return this.prefixIds.get(serverId) ?? serverId;
80
+ }
81
+ assignPrefixId(serverId, wrapper) {
82
+ const id = wrapper.getIdentity() === 'brainrouter' ? 'brainrouter' : serverId;
83
+ this.prefixIds.set(serverId, id);
84
+ this.prefixToServerId.set(id, serverId);
85
+ }
86
+ /**
87
+ * Connect to every entry in `servers` concurrently. Each connect
88
+ * gets its own timeout; offline servers don't block the others.
89
+ * Returns the status array after all connects settle.
90
+ */
91
+ async connectAll(servers, llmConfig, options) {
92
+ this.currentLlmConfig = llmConfig;
93
+ const entries = Object.entries(servers);
94
+ // Stash configs first so `/mcp reconnect <id>` can find them later.
95
+ for (const [serverId, cfg] of entries)
96
+ this.serverConfigs.set(serverId, cfg);
97
+ const tasks = entries.map(([serverId, cfg]) => this.connectOne(serverId, cfg, llmConfig, options?.timeoutMs).then(() => {
98
+ const s = this.statuses.get(serverId);
99
+ if (s && options?.onStatusChange)
100
+ options.onStatusChange(s);
101
+ }));
102
+ await Promise.allSettled(tasks);
103
+ await this.refreshToolIndex();
104
+ return this.getStatuses();
105
+ }
106
+ /**
107
+ * Connect a single server. Used both by `connectAll` and by
108
+ * `/mcp connect <id>` for late-joining servers. Idempotent — if
109
+ * the server is already connected, closes the previous wrapper first.
110
+ */
111
+ async connectOne(serverId, config, llmConfig, timeoutMs = 5_000) {
112
+ if (this.clients.has(serverId)) {
113
+ try {
114
+ await this.clients.get(serverId).close();
115
+ }
116
+ catch { /* ignore */ }
117
+ this.clients.delete(serverId);
118
+ }
119
+ this.serverConfigs.set(serverId, config);
120
+ this.statuses.set(serverId, { serverId, identity: 'unknown', status: 'connecting' });
121
+ const wrapper = new McpClientWrapper();
122
+ try {
123
+ await Promise.race([
124
+ wrapper.connect(config, llmConfig ?? this.currentLlmConfig, serverId),
125
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
126
+ ]);
127
+ this.clients.set(serverId, wrapper);
128
+ this.assignPrefixId(serverId, wrapper);
129
+ this.statuses.set(serverId, {
130
+ serverId,
131
+ identity: wrapper.getIdentity(),
132
+ status: 'connected',
133
+ });
134
+ }
135
+ catch (err) {
136
+ this.statuses.set(serverId, {
137
+ serverId,
138
+ identity: 'unknown',
139
+ status: 'failed',
140
+ error: err?.message ?? String(err),
141
+ });
142
+ try {
143
+ await wrapper.close();
144
+ }
145
+ catch { /* ignore */ }
146
+ }
147
+ await this.refreshToolIndex();
148
+ }
149
+ /** Tear down a single server. Removes it from the pool and rebuilds the tool index. */
150
+ async disconnectOne(serverId) {
151
+ const wrapper = this.clients.get(serverId);
152
+ if (wrapper) {
153
+ try {
154
+ await wrapper.close();
155
+ }
156
+ catch { /* ignore */ }
157
+ }
158
+ this.clients.delete(serverId);
159
+ const oldPid = this.prefixIds.get(serverId);
160
+ if (oldPid)
161
+ this.prefixToServerId.delete(oldPid);
162
+ this.prefixIds.delete(serverId);
163
+ const prev = this.statuses.get(serverId);
164
+ this.statuses.set(serverId, {
165
+ serverId,
166
+ identity: prev?.identity ?? 'unknown',
167
+ status: 'offline',
168
+ });
169
+ await this.refreshToolIndex();
170
+ }
171
+ /** Reconnect: close + connect again using the stashed config. */
172
+ async reconnectOne(serverId) {
173
+ const config = this.serverConfigs.get(serverId);
174
+ if (!config) {
175
+ throw new Error(`No stored config for serverId "${serverId}". Add it to ~/.config/brainrouter/config.json first.`);
176
+ }
177
+ await this.disconnectOne(serverId);
178
+ await this.connectOne(serverId, config, this.currentLlmConfig);
179
+ }
180
+ /**
181
+ * Walk every connected client and rebuild the tool→server indices.
182
+ * Called after every connect / disconnect / reconnect so the
183
+ * dispatch path stays correct without re-fetching tools on every
184
+ * `callTool`.
185
+ */
186
+ async refreshToolIndex() {
187
+ this.toolToServer.clear();
188
+ this.prefixedToServer.clear();
189
+ for (const [serverId, wrapper] of this.clients) {
190
+ if (!wrapper.isConnected())
191
+ continue;
192
+ try {
193
+ const res = await wrapper.listTools();
194
+ const tools = res.tools ?? [];
195
+ const status = this.statuses.get(serverId);
196
+ if (status) {
197
+ status.toolCount = tools.length;
198
+ status.identity = wrapper.getIdentity();
199
+ }
200
+ for (const tool of tools) {
201
+ const rawName = tool.name;
202
+ const pid = this.getPrefixId(serverId);
203
+ const prefixed = `mcp_${pid}_${rawName}`;
204
+ this.prefixedToServer.set(prefixed, { serverId, tool: rawName });
205
+ const existing = this.toolToServer.get(rawName);
206
+ if (existing && existing !== serverId) {
207
+ // Two servers expose the same unprefixed tool name. Mark
208
+ // collision so the raw-name resolver knows to require the
209
+ // prefix.
210
+ this.toolToServer.set(rawName, '__COLLISION__');
211
+ }
212
+ else if (!existing) {
213
+ this.toolToServer.set(rawName, serverId);
214
+ }
215
+ }
216
+ }
217
+ catch {
218
+ // Server is connected but listTools failed — its tools won't
219
+ // appear this turn. Will retry on the next refresh.
220
+ }
221
+ }
222
+ }
223
+ /**
224
+ * Concatenated tool list across every connected server, with names
225
+ * prefixed `mcp_<serverId>_<toolName>` (Claude Code style). The
226
+ * agent calls this once per turn and hands it to the LLM.
227
+ */
228
+ async listTools() {
229
+ const all = [];
230
+ for (const [serverId, wrapper] of this.clients) {
231
+ if (!wrapper.isConnected())
232
+ continue;
233
+ try {
234
+ const res = await wrapper.listTools();
235
+ const tools = res.tools ?? [];
236
+ const status = this.statuses.get(serverId);
237
+ if (status) {
238
+ status.identity = wrapper.getIdentity();
239
+ }
240
+ const pid = this.getPrefixId(serverId);
241
+ for (const tool of tools) {
242
+ all.push({
243
+ ...tool,
244
+ name: `mcp_${pid}_${tool.name}`,
245
+ __serverId: serverId,
246
+ __rawName: tool.name,
247
+ });
248
+ }
249
+ }
250
+ catch {
251
+ // listTools failed for this server — drop its tools this turn.
252
+ }
253
+ }
254
+ return { tools: all };
255
+ }
256
+ /**
257
+ * Route a tool call to the right server. Accepts both name forms:
258
+ *
259
+ * - `mcp_<serverId>_<tool>` — the canonical form the LLM sees
260
+ * in the inventory. Stripped + dispatched directly.
261
+ * - `<tool>` raw form — back-compat for prompts/skills that
262
+ * hardcode `memory_recall`-style names. Routed to the unique
263
+ * server providing that tool. Returns a helpful error if two
264
+ * servers expose the same name (caller must use the prefix).
265
+ */
266
+ async callTool(name, args) {
267
+ const resolved = this.resolveToolCall(name);
268
+ if (!resolved) {
269
+ // Distinguish the two failure modes — gives the LLM (and humans
270
+ // tailing logs) actionable feedback.
271
+ if (this.toolToServer.get(name) === '__COLLISION__') {
272
+ const prefixes = [...this.clients.keys()]
273
+ .filter((id) => {
274
+ const w = this.clients.get(id);
275
+ return w && w.isConnected() && this.prefixedToServer.has(`mcp_${this.getPrefixId(id)}_${name}`);
276
+ })
277
+ .map((id) => this.getPrefixId(id));
278
+ return {
279
+ isError: true,
280
+ content: [{
281
+ type: 'text',
282
+ text: `Ambiguous tool name "${name}" — exposed by ${prefixes.length} MCP servers: ${prefixes.join(', ')}. Use the prefixed form, e.g. mcp_${prefixes[0]}_${name}.`,
283
+ }],
284
+ };
285
+ }
286
+ return {
287
+ isError: true,
288
+ content: [{ type: 'text', text: `Tool "${name}" not found on any connected MCP server.` }],
289
+ };
290
+ }
291
+ const wrapper = this.clients.get(resolved.serverId);
292
+ if (!wrapper || !wrapper.isConnected()) {
293
+ return {
294
+ isError: true,
295
+ content: [{
296
+ type: 'text',
297
+ text: `MCP server "${resolved.serverId}" is offline; tool "${resolved.tool}" cannot be reached. Try /mcp reconnect ${resolved.serverId}.`,
298
+ }],
299
+ };
300
+ }
301
+ return wrapper.callTool(resolved.tool, args);
302
+ }
303
+ /** Internal — map a name (prefixed OR raw) to a concrete server + tool. */
304
+ resolveToolCall(name) {
305
+ // Back-compat: any legacy double-underscore form is collapsed first so
306
+ // the rest of the resolver only deals with the canonical shape.
307
+ name = normalizeMcpToolName(name);
308
+ // Fast path: exact prefixed form match in the index.
309
+ if (name.startsWith('mcp_')) {
310
+ const direct = this.prefixedToServer.get(name);
311
+ if (direct)
312
+ return direct;
313
+ // Lenient parse: walk prefix IDs first (covers "brainrouter"
314
+ // alias), then raw server IDs as fallback.
315
+ const rest = name.slice('mcp_'.length);
316
+ for (const [pid, realId] of this.prefixToServerId) {
317
+ const prefix = `${pid}_`;
318
+ if (rest.startsWith(prefix)) {
319
+ return { serverId: realId, tool: rest.slice(prefix.length) };
320
+ }
321
+ }
322
+ for (const serverId of this.clients.keys()) {
323
+ const prefix = `${serverId}_`;
324
+ if (rest.startsWith(prefix)) {
325
+ return { serverId, tool: rest.slice(prefix.length) };
326
+ }
327
+ }
328
+ return undefined;
329
+ }
330
+ if (isBrainrouterOwnedTool(name)) {
331
+ for (const [serverId, wrapper] of this.clients) {
332
+ if (wrapper.isConnected() &&
333
+ wrapper.getIdentity() === 'brainrouter' &&
334
+ this.prefixedToServer.has(`mcp_${serverId}_${name}`)) {
335
+ return { serverId, tool: name };
336
+ }
337
+ }
338
+ }
339
+ // Raw-name fallback.
340
+ const owner = this.toolToServer.get(name);
341
+ if (!owner || owner === '__COLLISION__')
342
+ return undefined;
343
+ return { serverId: owner, tool: name };
344
+ }
345
+ // ----- Facade methods that match McpClientWrapper's public surface -----
346
+ /** True iff at least one server is connected. */
347
+ isConnected() {
348
+ for (const w of this.clients.values()) {
349
+ if (w.isConnected())
350
+ return true;
351
+ }
352
+ return false;
353
+ }
354
+ /**
355
+ * Identity precedence: any connected `brainrouter` > any connected
356
+ * `third-party` > `unknown`. The CLI banner + offline prompt swap
357
+ * branch on this — "BrainRouter is offline" makes sense only when
358
+ * we expected one and didn't get one.
359
+ */
360
+ getIdentity() {
361
+ for (const w of this.clients.values()) {
362
+ if (w.isConnected() && w.getIdentity() === 'brainrouter')
363
+ return 'brainrouter';
364
+ }
365
+ for (const w of this.clients.values()) {
366
+ if (w.isConnected() && w.getIdentity() === 'third-party')
367
+ return 'third-party';
368
+ }
369
+ return 'unknown';
370
+ }
371
+ /**
372
+ * Human-readable summary for the banner/statusline. Single-server
373
+ * pools render just the server name; multi-server pools render
374
+ * a count + the first few names.
375
+ */
376
+ getServerName() {
377
+ const connected = [...this.clients.entries()]
378
+ .filter(([_, w]) => w.isConnected())
379
+ .map(([id]) => id);
380
+ if (connected.length === 0)
381
+ return undefined;
382
+ if (connected.length === 1)
383
+ return connected[0];
384
+ const head = connected.slice(0, 3).join(', ');
385
+ return connected.length > 3 ? `${connected.length} servers (${head}, …)` : `${connected.length} servers (${head})`;
386
+ }
387
+ /**
388
+ * Look up a wrapper by serverId. Used by `/mcp tools <server>` and
389
+ * similar commands that want to talk to one specific server.
390
+ */
391
+ getClient(serverId) {
392
+ return this.clients.get(serverId);
393
+ }
394
+ /**
395
+ * Find the connected wrapper whose identity is 'brainrouter'. Some
396
+ * code paths (memory capture, working-memory offload) specifically
397
+ * need the canonical brain regardless of how many third-party MCPs
398
+ * the user added.
399
+ */
400
+ getBrainrouterClient() {
401
+ for (const w of this.clients.values()) {
402
+ if (w.isConnected() && w.getIdentity() === 'brainrouter')
403
+ return w;
404
+ }
405
+ return undefined;
406
+ }
407
+ /** Server id for the currently connected BrainRouter MCP, if one is active. */
408
+ getActiveBrainrouterServerId() {
409
+ for (const [serverId, wrapper] of this.clients) {
410
+ if (wrapper.isConnected() && wrapper.getIdentity() === 'brainrouter')
411
+ return serverId;
412
+ }
413
+ return undefined;
414
+ }
415
+ /** Status snapshot for every server the pool has tried to connect to. */
416
+ getStatuses() {
417
+ return [...this.statuses.values()];
418
+ }
419
+ /** Status for one server (returns undefined if the pool has never seen it). */
420
+ getStatus(serverId) {
421
+ return this.statuses.get(serverId);
422
+ }
423
+ /** List of serverIds currently held by the pool (connected or not). */
424
+ getServerIds() {
425
+ return [...this.statuses.keys()];
426
+ }
427
+ /** Close every wrapper. Used on CLI exit. */
428
+ async close() {
429
+ for (const wrapper of this.clients.values()) {
430
+ try {
431
+ await wrapper.close();
432
+ }
433
+ catch { /* ignore */ }
434
+ }
435
+ this.clients.clear();
436
+ this.toolToServer.clear();
437
+ this.prefixedToServer.clear();
438
+ this.prefixIds.clear();
439
+ this.prefixToServerId.clear();
440
+ // Keep `statuses` so a `getStatuses()` after close still shows what was there.
441
+ }
442
+ }
@@ -1,4 +1,6 @@
1
1
  import type { McpClientWrapper } from './mcpClient.js';
2
+ import type { McpClientPool } from './mcpPool.js';
3
+ export type McpClient = McpClientWrapper | McpClientPool;
2
4
  /**
3
5
  * Centralized helpers for talking to the BrainRouter MCP server.
4
6
  *
@@ -27,10 +29,24 @@ export interface McpCallResult<T = any> {
27
29
  * Network and protocol errors are converted to `{ isError: true, text: errorMessage }`
28
30
  * so callers can branch on a single shape instead of mixing try/catch with isError checks.
29
31
  */
30
- export declare function callMcpTool<T = any>(client: McpClientWrapper, name: string, args: Record<string, unknown>): Promise<McpCallResult<T>>;
32
+ export declare function callMcpTool<T = any>(client: McpClient, name: string, args: Record<string, unknown>): Promise<McpCallResult<T>>;
31
33
  /**
32
34
  * Canonical convention for naming a child agent's session key relative to its
33
35
  * parent: `<parent>:child:<id>`. Centralized so a future change (e.g. switching
34
36
  * to UUIDs or namespacing per-role) is a one-file edit, not a sweep.
35
37
  */
36
38
  export declare function childSessionKey(parentSessionKey: string, childId: string): string;
39
+ /**
40
+ * Discovery helper for `Set<string>` tool-name lookups across MCP prefix
41
+ * conventions. The pool normalises tool names to single-underscore
42
+ * `mcp_<server>_<tool>` at the boundary (0.3.8-R5), so a discovery check
43
+ * for "is `memory_recall` exposed?" must match either the bare name (raw
44
+ * single-server flow) or any `mcp_<server>_memory_recall` (post-pool
45
+ * normalisation).
46
+ *
47
+ * Use this anywhere a caller previously did `toolNames.has('memory_recall')` —
48
+ * those bare-name checks silently miss prefixed names and were the root
49
+ * cause of `🧠 Briefing: 0 records from (none)` even with the brain MCP
50
+ * connected.
51
+ */
52
+ export declare function hasMcpTool(toolNames: Set<string>, bareName: string): boolean;
@@ -62,3 +62,26 @@ export async function callMcpTool(client, name, args) {
62
62
  export function childSessionKey(parentSessionKey, childId) {
63
63
  return `${parentSessionKey}:child:${childId}`;
64
64
  }
65
+ /**
66
+ * Discovery helper for `Set<string>` tool-name lookups across MCP prefix
67
+ * conventions. The pool normalises tool names to single-underscore
68
+ * `mcp_<server>_<tool>` at the boundary (0.3.8-R5), so a discovery check
69
+ * for "is `memory_recall` exposed?" must match either the bare name (raw
70
+ * single-server flow) or any `mcp_<server>_memory_recall` (post-pool
71
+ * normalisation).
72
+ *
73
+ * Use this anywhere a caller previously did `toolNames.has('memory_recall')` —
74
+ * those bare-name checks silently miss prefixed names and were the root
75
+ * cause of `🧠 Briefing: 0 records from (none)` even with the brain MCP
76
+ * connected.
77
+ */
78
+ export function hasMcpTool(toolNames, bareName) {
79
+ if (toolNames.has(bareName))
80
+ return true;
81
+ const suffix = `_${bareName}`;
82
+ for (const name of toolNames) {
83
+ if (name.startsWith('mcp_') && name.endsWith(suffix))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * In-process ticker that fires due `/schedule` jobs.
3
+ *
4
+ * Singleton per CLI process. Wakes every 30s (override via
5
+ * `BRAINROUTER_SCHEDULE_TICK_MS`), reloads the persisted store, fires
6
+ * any jobs whose `nextRun` is now in the past, then advances `nextRun`
7
+ * past `now()` so a missed window only fires ONCE (catch-up
8
+ * idempotency).
9
+ *
10
+ * No daemon — the ticker dies with the REPL. Schedules that miss
11
+ * because the CLI was closed are caught on the next REPL boot via the
12
+ * same "advance past now" rule.
13
+ */
14
+ import { type ScheduleRecord } from '../state/scheduleStore.js';
15
+ export interface ScheduleTickerOptions {
16
+ workspaceRoot: string;
17
+ sessionKey: string;
18
+ /** Called when a schedule is due. Fire-and-forget. */
19
+ fire: (command: string, schedule: ScheduleRecord) => void;
20
+ /** Override the wake interval. Defaults to env or 30 000 ms. */
21
+ intervalMs?: number;
22
+ /** Injected clock for tests. */
23
+ now?: () => number;
24
+ /** Called when a fire is skipped because the expression is unparseable. */
25
+ onError?: (msg: string) => void;
26
+ }
27
+ export interface ScheduleTickerHandle {
28
+ stop: () => void;
29
+ /** Force one immediate scan (tests + boot catch-up). */
30
+ tickNow: () => void;
31
+ }
32
+ export declare function isScheduleTickerRunning(): boolean;
33
+ export declare function startScheduleTicker(opts: ScheduleTickerOptions): ScheduleTickerHandle;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * In-process ticker that fires due `/schedule` jobs.
3
+ *
4
+ * Singleton per CLI process. Wakes every 30s (override via
5
+ * `BRAINROUTER_SCHEDULE_TICK_MS`), reloads the persisted store, fires
6
+ * any jobs whose `nextRun` is now in the past, then advances `nextRun`
7
+ * past `now()` so a missed window only fires ONCE (catch-up
8
+ * idempotency).
9
+ *
10
+ * No daemon — the ticker dies with the REPL. Schedules that miss
11
+ * because the CLI was closed are caught on the next REPL boot via the
12
+ * same "advance past now" rule.
13
+ */
14
+ import { loadSchedules, recordFire, removeSchedule, setScheduleEnabled } from '../state/scheduleStore.js';
15
+ import { parseCron, nextCronFire } from './cronParser.js';
16
+ const DEFAULT_INTERVAL_MS = 30_000;
17
+ const MIN_INTERVAL_MS = 100;
18
+ let active = null;
19
+ export function isScheduleTickerRunning() {
20
+ return active !== null;
21
+ }
22
+ export function startScheduleTicker(opts) {
23
+ if (active)
24
+ return active;
25
+ const envOverride = Number(process.env.BRAINROUTER_SCHEDULE_TICK_MS);
26
+ const rawInterval = opts.intervalMs ?? (Number.isFinite(envOverride) && envOverride > 0 ? envOverride : DEFAULT_INTERVAL_MS);
27
+ const interval = Math.max(MIN_INTERVAL_MS, rawInterval);
28
+ const now = opts.now ?? (() => Date.now());
29
+ let stopped = false;
30
+ let timer = null;
31
+ const scan = () => {
32
+ if (stopped)
33
+ return;
34
+ const t = now();
35
+ let schedules;
36
+ try {
37
+ schedules = loadSchedules(opts.workspaceRoot);
38
+ }
39
+ catch (err) {
40
+ opts.onError?.(`load failed: ${err.message}`);
41
+ return;
42
+ }
43
+ for (const s of schedules) {
44
+ if (!s.enabled)
45
+ continue;
46
+ if (s.owner !== opts.sessionKey)
47
+ continue;
48
+ const nextMs = Date.parse(s.nextRun);
49
+ if (!Number.isFinite(nextMs) || nextMs > t)
50
+ continue;
51
+ try {
52
+ opts.fire(s.command, s);
53
+ }
54
+ catch (err) {
55
+ opts.onError?.(`fire failed for ${s.id}: ${err.message}`);
56
+ }
57
+ if (s.kind === 'once') {
58
+ removeSchedule(opts.workspaceRoot, s.id);
59
+ continue;
60
+ }
61
+ // Cron: advance nextRun strictly past `t`. parseCron should always
62
+ // succeed (we validated on add), but tolerate corruption by
63
+ // disabling rather than spinning forever on a past nextRun.
64
+ const cron = parseCron(s.expr);
65
+ if (!cron) {
66
+ opts.onError?.(`cron expression invalid; disabling ${s.id}: ${s.expr}`);
67
+ try {
68
+ setScheduleEnabled(opts.workspaceRoot, s.id, false);
69
+ }
70
+ catch { /* swallow */ }
71
+ continue;
72
+ }
73
+ const next = nextCronFire(cron, new Date(t));
74
+ recordFire(opts.workspaceRoot, s.id, new Date(t), next.toISOString());
75
+ }
76
+ };
77
+ const tick = () => {
78
+ if (stopped)
79
+ return;
80
+ scan();
81
+ if (stopped)
82
+ return;
83
+ timer = setTimeout(tick, interval);
84
+ };
85
+ // Defer the first scan to the next macrotask so the caller finishes
86
+ // wiring (e.g. Ink's `onReady`) before any fire callback runs.
87
+ timer = setTimeout(tick, 0);
88
+ active = {
89
+ stop: () => {
90
+ stopped = true;
91
+ if (timer)
92
+ clearTimeout(timer);
93
+ timer = null;
94
+ active = null;
95
+ },
96
+ tickNow: scan,
97
+ };
98
+ return active;
99
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Per-vendor MCP install snippets (0.3.8-I5 / roadmap §9).
3
+ *
4
+ * Pattern adapted from semble's per-agent install docs
5
+ * (openSrc/semble/src/semble/agents/*.md) — one focused entry per vendor
6
+ * with the exact JSON shape and config file path. Where semble ships
7
+ * markdown, we ship structured templates so the CLI can substitute the
8
+ * user's live profile URL + API key on the fly.
9
+ *
10
+ * Notes
11
+ * - Tool name examples use the single-underscore convention
12
+ * `mcp_<server>_<tool>` (0.3.8-R5 decision).
13
+ * - Each template is pinned to a "verified against vendor docs as of …"
14
+ * comment. Vendor MCP schemas drift; bump the date when you re-verify.
15
+ * - We never auto-write the vendor config file. Print only — direct-write
16
+ * is a future enhancement (roadmap: future item; do not file a follow-up).
17
+ */
18
+ export type VendorSchema = 'stdio' | 'http' | 'sse';
19
+ export interface VendorVars {
20
+ /** Active BrainRouter profile URL (http transport). */
21
+ url: string;
22
+ /** Active BrainRouter profile API key — rendered verbatim into the snippet. */
23
+ apiKey: string;
24
+ /** Server id the user picks in their vendor config (defaults to "brainrouter"). */
25
+ serverId?: string;
26
+ }
27
+ export interface VendorEntry {
28
+ id: string;
29
+ label: string;
30
+ schema: VendorSchema;
31
+ /** Restart note shown after the snippet. */
32
+ restart: string;
33
+ /** Per-OS config file path. POSIX path with `~` for the user's home. */
34
+ configPath: (platform: NodeJS.Platform) => string;
35
+ /** Pure template — returns the JSON object the user should merge into their vendor config. */
36
+ template: (vars: VendorVars) => unknown;
37
+ /** Free-form note (e.g. nested-key location, alternative shape). */
38
+ note?: string;
39
+ }
40
+ /** Render a config path for human display — backslashes on Windows. */
41
+ export declare function displayPath(p: string, platform?: NodeJS.Platform): string;
42
+ export declare const VENDORS: Record<string, VendorEntry>;
43
+ export declare function listVendors(): VendorEntry[];
44
+ export declare function getVendor(id: string): VendorEntry | undefined;
45
+ export declare function renderSnippet(entry: VendorEntry, vars: VendorVars): string;