@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.
- package/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/changelog/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +34 -1
- package/dist/agent/agent.js +372 -79
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +117 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +261 -74
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +51 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +119 -60
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +71 -0
- package/dist/cli/ink/Picker.js +168 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +682 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +75 -0
- package/dist/cli/ink/toolFormat.js +206 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +52 -714
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +13 -1
- package/dist/config/config.js +45 -3
- package/dist/index.js +157 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +105 -3
- package/dist/orchestration/tools.js +167 -8
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +7 -2
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +170 -0
- package/dist/runtime/mcpPool.js +442 -0
- package/dist/runtime/mcpUtils.d.ts +17 -1
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +14 -5
- 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:
|
|
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;
|
package/dist/runtime/mcpUtils.js
CHANGED
|
@@ -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;
|