@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.
- 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/dist/agent/agent.d.ts +12 -1
- package/dist/agent/agent.js +134 -18
- 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 +52 -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 +239 -74
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/ui.js +117 -58
- 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 +65 -0
- package/dist/cli/ink/Picker.js +133 -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 +571 -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 +73 -0
- package/dist/cli/ink/toolFormat.js +180 -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 +43 -712
- 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 +12 -0
- package/dist/config/config.js +45 -3
- package/dist/index.js +148 -206
- package/dist/memory/briefing.d.ts +1 -1
- 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 +10 -1
- package/dist/orchestration/tools.js +48 -4
- 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 +5 -1
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/package.json +8 -2
- package/.env.example +0 -116
|
@@ -0,0 +1,423 @@
|
|
|
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
|
+
function isBrainrouterOwnedTool(name) {
|
|
28
|
+
return name.startsWith('memory_') ||
|
|
29
|
+
[
|
|
30
|
+
'list_skills',
|
|
31
|
+
'get_skill',
|
|
32
|
+
'search_skills',
|
|
33
|
+
'create_skill',
|
|
34
|
+
'update_skill',
|
|
35
|
+
'get_persona',
|
|
36
|
+
'get_reference',
|
|
37
|
+
'list_template_docs',
|
|
38
|
+
'get_template_doc',
|
|
39
|
+
].includes(name);
|
|
40
|
+
}
|
|
41
|
+
export class McpClientPool {
|
|
42
|
+
/** serverId → connected wrapper. */
|
|
43
|
+
clients = new Map();
|
|
44
|
+
/** serverId → status entry (kept even for failed/offline servers so /mcp can render them). */
|
|
45
|
+
statuses = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Unprefixed tool name → owning serverId. Sentinel `__COLLISION__`
|
|
48
|
+
* marks tool names exposed by multiple servers (must be addressed
|
|
49
|
+
* via the prefixed form).
|
|
50
|
+
*/
|
|
51
|
+
toolToServer = new Map();
|
|
52
|
+
/** Prefixed form (`mcp_<serverId>_<tool>`) → `{serverId, tool}` for fast dispatch. */
|
|
53
|
+
prefixedToServer = new Map();
|
|
54
|
+
/** LLM config from the last connectAll — needed for reconnect calls. */
|
|
55
|
+
currentLlmConfig;
|
|
56
|
+
/** Raw server configs from the last connectAll — needed for /mcp reconnect <id>. */
|
|
57
|
+
serverConfigs = new Map();
|
|
58
|
+
/** serverId → prefix used in tool names. Brainrouter servers get "brainrouter"; others keep their config key. */
|
|
59
|
+
prefixIds = new Map();
|
|
60
|
+
/** Reverse: prefixId → serverId (needed to dispatch `mcp_brainrouter_X` back to the real key). */
|
|
61
|
+
prefixToServerId = new Map();
|
|
62
|
+
getPrefixId(serverId) {
|
|
63
|
+
return this.prefixIds.get(serverId) ?? serverId;
|
|
64
|
+
}
|
|
65
|
+
assignPrefixId(serverId, wrapper) {
|
|
66
|
+
const id = wrapper.getIdentity() === 'brainrouter' ? 'brainrouter' : serverId;
|
|
67
|
+
this.prefixIds.set(serverId, id);
|
|
68
|
+
this.prefixToServerId.set(id, serverId);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Connect to every entry in `servers` concurrently. Each connect
|
|
72
|
+
* gets its own timeout; offline servers don't block the others.
|
|
73
|
+
* Returns the status array after all connects settle.
|
|
74
|
+
*/
|
|
75
|
+
async connectAll(servers, llmConfig, options) {
|
|
76
|
+
this.currentLlmConfig = llmConfig;
|
|
77
|
+
const entries = Object.entries(servers);
|
|
78
|
+
// Stash configs first so `/mcp reconnect <id>` can find them later.
|
|
79
|
+
for (const [serverId, cfg] of entries)
|
|
80
|
+
this.serverConfigs.set(serverId, cfg);
|
|
81
|
+
const tasks = entries.map(([serverId, cfg]) => this.connectOne(serverId, cfg, llmConfig, options?.timeoutMs).then(() => {
|
|
82
|
+
const s = this.statuses.get(serverId);
|
|
83
|
+
if (s && options?.onStatusChange)
|
|
84
|
+
options.onStatusChange(s);
|
|
85
|
+
}));
|
|
86
|
+
await Promise.allSettled(tasks);
|
|
87
|
+
await this.refreshToolIndex();
|
|
88
|
+
return this.getStatuses();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Connect a single server. Used both by `connectAll` and by
|
|
92
|
+
* `/mcp connect <id>` for late-joining servers. Idempotent — if
|
|
93
|
+
* the server is already connected, closes the previous wrapper first.
|
|
94
|
+
*/
|
|
95
|
+
async connectOne(serverId, config, llmConfig, timeoutMs = 5_000) {
|
|
96
|
+
if (this.clients.has(serverId)) {
|
|
97
|
+
try {
|
|
98
|
+
await this.clients.get(serverId).close();
|
|
99
|
+
}
|
|
100
|
+
catch { /* ignore */ }
|
|
101
|
+
this.clients.delete(serverId);
|
|
102
|
+
}
|
|
103
|
+
this.serverConfigs.set(serverId, config);
|
|
104
|
+
this.statuses.set(serverId, { serverId, identity: 'unknown', status: 'connecting' });
|
|
105
|
+
const wrapper = new McpClientWrapper();
|
|
106
|
+
try {
|
|
107
|
+
await Promise.race([
|
|
108
|
+
wrapper.connect(config, llmConfig ?? this.currentLlmConfig, serverId),
|
|
109
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
110
|
+
]);
|
|
111
|
+
this.clients.set(serverId, wrapper);
|
|
112
|
+
this.assignPrefixId(serverId, wrapper);
|
|
113
|
+
this.statuses.set(serverId, {
|
|
114
|
+
serverId,
|
|
115
|
+
identity: wrapper.getIdentity(),
|
|
116
|
+
status: 'connected',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.statuses.set(serverId, {
|
|
121
|
+
serverId,
|
|
122
|
+
identity: 'unknown',
|
|
123
|
+
status: 'failed',
|
|
124
|
+
error: err?.message ?? String(err),
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
await wrapper.close();
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
await this.refreshToolIndex();
|
|
132
|
+
}
|
|
133
|
+
/** Tear down a single server. Removes it from the pool and rebuilds the tool index. */
|
|
134
|
+
async disconnectOne(serverId) {
|
|
135
|
+
const wrapper = this.clients.get(serverId);
|
|
136
|
+
if (wrapper) {
|
|
137
|
+
try {
|
|
138
|
+
await wrapper.close();
|
|
139
|
+
}
|
|
140
|
+
catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
this.clients.delete(serverId);
|
|
143
|
+
const oldPid = this.prefixIds.get(serverId);
|
|
144
|
+
if (oldPid)
|
|
145
|
+
this.prefixToServerId.delete(oldPid);
|
|
146
|
+
this.prefixIds.delete(serverId);
|
|
147
|
+
const prev = this.statuses.get(serverId);
|
|
148
|
+
this.statuses.set(serverId, {
|
|
149
|
+
serverId,
|
|
150
|
+
identity: prev?.identity ?? 'unknown',
|
|
151
|
+
status: 'offline',
|
|
152
|
+
});
|
|
153
|
+
await this.refreshToolIndex();
|
|
154
|
+
}
|
|
155
|
+
/** Reconnect: close + connect again using the stashed config. */
|
|
156
|
+
async reconnectOne(serverId) {
|
|
157
|
+
const config = this.serverConfigs.get(serverId);
|
|
158
|
+
if (!config) {
|
|
159
|
+
throw new Error(`No stored config for serverId "${serverId}". Add it to ~/.config/brainrouter/config.json first.`);
|
|
160
|
+
}
|
|
161
|
+
await this.disconnectOne(serverId);
|
|
162
|
+
await this.connectOne(serverId, config, this.currentLlmConfig);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Walk every connected client and rebuild the tool→server indices.
|
|
166
|
+
* Called after every connect / disconnect / reconnect so the
|
|
167
|
+
* dispatch path stays correct without re-fetching tools on every
|
|
168
|
+
* `callTool`.
|
|
169
|
+
*/
|
|
170
|
+
async refreshToolIndex() {
|
|
171
|
+
this.toolToServer.clear();
|
|
172
|
+
this.prefixedToServer.clear();
|
|
173
|
+
for (const [serverId, wrapper] of this.clients) {
|
|
174
|
+
if (!wrapper.isConnected())
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
const res = await wrapper.listTools();
|
|
178
|
+
const tools = res.tools ?? [];
|
|
179
|
+
const status = this.statuses.get(serverId);
|
|
180
|
+
if (status) {
|
|
181
|
+
status.toolCount = tools.length;
|
|
182
|
+
status.identity = wrapper.getIdentity();
|
|
183
|
+
}
|
|
184
|
+
for (const tool of tools) {
|
|
185
|
+
const rawName = tool.name;
|
|
186
|
+
const pid = this.getPrefixId(serverId);
|
|
187
|
+
const prefixed = `mcp_${pid}_${rawName}`;
|
|
188
|
+
this.prefixedToServer.set(prefixed, { serverId, tool: rawName });
|
|
189
|
+
const existing = this.toolToServer.get(rawName);
|
|
190
|
+
if (existing && existing !== serverId) {
|
|
191
|
+
// Two servers expose the same unprefixed tool name. Mark
|
|
192
|
+
// collision so the raw-name resolver knows to require the
|
|
193
|
+
// prefix.
|
|
194
|
+
this.toolToServer.set(rawName, '__COLLISION__');
|
|
195
|
+
}
|
|
196
|
+
else if (!existing) {
|
|
197
|
+
this.toolToServer.set(rawName, serverId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Server is connected but listTools failed — its tools won't
|
|
203
|
+
// appear this turn. Will retry on the next refresh.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Concatenated tool list across every connected server, with names
|
|
209
|
+
* prefixed `mcp_<serverId>_<toolName>` (Claude Code style). The
|
|
210
|
+
* agent calls this once per turn and hands it to the LLM.
|
|
211
|
+
*/
|
|
212
|
+
async listTools() {
|
|
213
|
+
const all = [];
|
|
214
|
+
for (const [serverId, wrapper] of this.clients) {
|
|
215
|
+
if (!wrapper.isConnected())
|
|
216
|
+
continue;
|
|
217
|
+
try {
|
|
218
|
+
const res = await wrapper.listTools();
|
|
219
|
+
const tools = res.tools ?? [];
|
|
220
|
+
const status = this.statuses.get(serverId);
|
|
221
|
+
if (status) {
|
|
222
|
+
status.identity = wrapper.getIdentity();
|
|
223
|
+
}
|
|
224
|
+
const pid = this.getPrefixId(serverId);
|
|
225
|
+
for (const tool of tools) {
|
|
226
|
+
all.push({
|
|
227
|
+
...tool,
|
|
228
|
+
name: `mcp_${pid}_${tool.name}`,
|
|
229
|
+
__serverId: serverId,
|
|
230
|
+
__rawName: tool.name,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// listTools failed for this server — drop its tools this turn.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { tools: all };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Route a tool call to the right server. Accepts both name forms:
|
|
242
|
+
*
|
|
243
|
+
* - `mcp_<serverId>_<tool>` — the canonical form the LLM sees
|
|
244
|
+
* in the inventory. Stripped + dispatched directly.
|
|
245
|
+
* - `<tool>` raw form — back-compat for prompts/skills that
|
|
246
|
+
* hardcode `memory_recall`-style names. Routed to the unique
|
|
247
|
+
* server providing that tool. Returns a helpful error if two
|
|
248
|
+
* servers expose the same name (caller must use the prefix).
|
|
249
|
+
*/
|
|
250
|
+
async callTool(name, args) {
|
|
251
|
+
const resolved = this.resolveToolCall(name);
|
|
252
|
+
if (!resolved) {
|
|
253
|
+
// Distinguish the two failure modes — gives the LLM (and humans
|
|
254
|
+
// tailing logs) actionable feedback.
|
|
255
|
+
if (this.toolToServer.get(name) === '__COLLISION__') {
|
|
256
|
+
const prefixes = [...this.clients.keys()]
|
|
257
|
+
.filter((id) => {
|
|
258
|
+
const w = this.clients.get(id);
|
|
259
|
+
return w && w.isConnected() && this.prefixedToServer.has(`mcp_${this.getPrefixId(id)}_${name}`);
|
|
260
|
+
})
|
|
261
|
+
.map((id) => this.getPrefixId(id));
|
|
262
|
+
return {
|
|
263
|
+
isError: true,
|
|
264
|
+
content: [{
|
|
265
|
+
type: 'text',
|
|
266
|
+
text: `Ambiguous tool name "${name}" — exposed by ${prefixes.length} MCP servers: ${prefixes.join(', ')}. Use the prefixed form, e.g. mcp_${prefixes[0]}_${name}.`,
|
|
267
|
+
}],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
isError: true,
|
|
272
|
+
content: [{ type: 'text', text: `Tool "${name}" not found on any connected MCP server.` }],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const wrapper = this.clients.get(resolved.serverId);
|
|
276
|
+
if (!wrapper || !wrapper.isConnected()) {
|
|
277
|
+
return {
|
|
278
|
+
isError: true,
|
|
279
|
+
content: [{
|
|
280
|
+
type: 'text',
|
|
281
|
+
text: `MCP server "${resolved.serverId}" is offline; tool "${resolved.tool}" cannot be reached. Try /mcp reconnect ${resolved.serverId}.`,
|
|
282
|
+
}],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return wrapper.callTool(resolved.tool, args);
|
|
286
|
+
}
|
|
287
|
+
/** Internal — map a name (prefixed OR raw) to a concrete server + tool. */
|
|
288
|
+
resolveToolCall(name) {
|
|
289
|
+
// Fast path: exact prefixed form match in the index.
|
|
290
|
+
if (name.startsWith('mcp_')) {
|
|
291
|
+
const direct = this.prefixedToServer.get(name);
|
|
292
|
+
if (direct)
|
|
293
|
+
return direct;
|
|
294
|
+
// Lenient parse: walk prefix IDs first (covers "brainrouter"
|
|
295
|
+
// alias), then raw server IDs as fallback.
|
|
296
|
+
const rest = name.slice('mcp_'.length);
|
|
297
|
+
for (const [pid, realId] of this.prefixToServerId) {
|
|
298
|
+
const prefix = `${pid}_`;
|
|
299
|
+
if (rest.startsWith(prefix)) {
|
|
300
|
+
return { serverId: realId, tool: rest.slice(prefix.length) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const serverId of this.clients.keys()) {
|
|
304
|
+
const prefix = `${serverId}_`;
|
|
305
|
+
if (rest.startsWith(prefix)) {
|
|
306
|
+
return { serverId, tool: rest.slice(prefix.length) };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
if (isBrainrouterOwnedTool(name)) {
|
|
312
|
+
for (const [serverId, wrapper] of this.clients) {
|
|
313
|
+
if (wrapper.isConnected() &&
|
|
314
|
+
wrapper.getIdentity() === 'brainrouter' &&
|
|
315
|
+
this.prefixedToServer.has(`mcp_${serverId}_${name}`)) {
|
|
316
|
+
return { serverId, tool: name };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Raw-name fallback.
|
|
321
|
+
const owner = this.toolToServer.get(name);
|
|
322
|
+
if (!owner || owner === '__COLLISION__')
|
|
323
|
+
return undefined;
|
|
324
|
+
return { serverId: owner, tool: name };
|
|
325
|
+
}
|
|
326
|
+
// ----- Facade methods that match McpClientWrapper's public surface -----
|
|
327
|
+
/** True iff at least one server is connected. */
|
|
328
|
+
isConnected() {
|
|
329
|
+
for (const w of this.clients.values()) {
|
|
330
|
+
if (w.isConnected())
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Identity precedence: any connected `brainrouter` > any connected
|
|
337
|
+
* `third-party` > `unknown`. The CLI banner + offline prompt swap
|
|
338
|
+
* branch on this — "BrainRouter is offline" makes sense only when
|
|
339
|
+
* we expected one and didn't get one.
|
|
340
|
+
*/
|
|
341
|
+
getIdentity() {
|
|
342
|
+
for (const w of this.clients.values()) {
|
|
343
|
+
if (w.isConnected() && w.getIdentity() === 'brainrouter')
|
|
344
|
+
return 'brainrouter';
|
|
345
|
+
}
|
|
346
|
+
for (const w of this.clients.values()) {
|
|
347
|
+
if (w.isConnected() && w.getIdentity() === 'third-party')
|
|
348
|
+
return 'third-party';
|
|
349
|
+
}
|
|
350
|
+
return 'unknown';
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Human-readable summary for the banner/statusline. Single-server
|
|
354
|
+
* pools render just the server name; multi-server pools render
|
|
355
|
+
* a count + the first few names.
|
|
356
|
+
*/
|
|
357
|
+
getServerName() {
|
|
358
|
+
const connected = [...this.clients.entries()]
|
|
359
|
+
.filter(([_, w]) => w.isConnected())
|
|
360
|
+
.map(([id]) => id);
|
|
361
|
+
if (connected.length === 0)
|
|
362
|
+
return undefined;
|
|
363
|
+
if (connected.length === 1)
|
|
364
|
+
return connected[0];
|
|
365
|
+
const head = connected.slice(0, 3).join(', ');
|
|
366
|
+
return connected.length > 3 ? `${connected.length} servers (${head}, …)` : `${connected.length} servers (${head})`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Look up a wrapper by serverId. Used by `/mcp tools <server>` and
|
|
370
|
+
* similar commands that want to talk to one specific server.
|
|
371
|
+
*/
|
|
372
|
+
getClient(serverId) {
|
|
373
|
+
return this.clients.get(serverId);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Find the connected wrapper whose identity is 'brainrouter'. Some
|
|
377
|
+
* code paths (memory capture, working-memory offload) specifically
|
|
378
|
+
* need the canonical brain regardless of how many third-party MCPs
|
|
379
|
+
* the user added.
|
|
380
|
+
*/
|
|
381
|
+
getBrainrouterClient() {
|
|
382
|
+
for (const w of this.clients.values()) {
|
|
383
|
+
if (w.isConnected() && w.getIdentity() === 'brainrouter')
|
|
384
|
+
return w;
|
|
385
|
+
}
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
/** Server id for the currently connected BrainRouter MCP, if one is active. */
|
|
389
|
+
getActiveBrainrouterServerId() {
|
|
390
|
+
for (const [serverId, wrapper] of this.clients) {
|
|
391
|
+
if (wrapper.isConnected() && wrapper.getIdentity() === 'brainrouter')
|
|
392
|
+
return serverId;
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
/** Status snapshot for every server the pool has tried to connect to. */
|
|
397
|
+
getStatuses() {
|
|
398
|
+
return [...this.statuses.values()];
|
|
399
|
+
}
|
|
400
|
+
/** Status for one server (returns undefined if the pool has never seen it). */
|
|
401
|
+
getStatus(serverId) {
|
|
402
|
+
return this.statuses.get(serverId);
|
|
403
|
+
}
|
|
404
|
+
/** List of serverIds currently held by the pool (connected or not). */
|
|
405
|
+
getServerIds() {
|
|
406
|
+
return [...this.statuses.keys()];
|
|
407
|
+
}
|
|
408
|
+
/** Close every wrapper. Used on CLI exit. */
|
|
409
|
+
async close() {
|
|
410
|
+
for (const wrapper of this.clients.values()) {
|
|
411
|
+
try {
|
|
412
|
+
await wrapper.close();
|
|
413
|
+
}
|
|
414
|
+
catch { /* ignore */ }
|
|
415
|
+
}
|
|
416
|
+
this.clients.clear();
|
|
417
|
+
this.toolToServer.clear();
|
|
418
|
+
this.prefixedToServer.clear();
|
|
419
|
+
this.prefixIds.clear();
|
|
420
|
+
this.prefixToServerId.clear();
|
|
421
|
+
// Keep `statuses` so a `getStatuses()` after close still shows what was there.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -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,7 +29,7 @@ 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinqs/brainrouter-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Memory-native terminal coding agent. Talks to the BrainRouter MCP cognitive engine for recall, skills, capture, persona, focus scenes, and contradiction tracking.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"brainrouter": "bin/cli.cjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"agents",
|
|
11
12
|
"bin",
|
|
12
13
|
"dist",
|
|
13
14
|
"README.md",
|
|
@@ -28,15 +29,20 @@
|
|
|
28
29
|
"chalk": "^5.3.0",
|
|
29
30
|
"commander": "^12.1.0",
|
|
30
31
|
"dotenv": "^16.4.5",
|
|
32
|
+
"ink": "^7.0.3",
|
|
33
|
+
"ink-spinner": "^5.0.0",
|
|
34
|
+
"ink-text-input": "^6.0.0",
|
|
31
35
|
"inquirer": "^9.3.2",
|
|
32
36
|
"marked": "^12.0.1",
|
|
33
37
|
"marked-terminal": "^7.0.0",
|
|
34
|
-
"ora": "^8.0.1"
|
|
38
|
+
"ora": "^8.0.1",
|
|
39
|
+
"react": "^19.2.6"
|
|
35
40
|
},
|
|
36
41
|
"devDependencies": {
|
|
37
42
|
"@types/inquirer": "^9.0.7",
|
|
38
43
|
"@types/marked": "^4.0.8",
|
|
39
44
|
"@types/node": "^22.0.0",
|
|
45
|
+
"@types/react": "^19.2.15",
|
|
40
46
|
"tsx": "^4.7.0",
|
|
41
47
|
"typescript": "^5.5.4"
|
|
42
48
|
},
|
package/.env.example
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# BrainRouter CLI agent — environment template
|
|
2
|
-
#
|
|
3
|
-
# Copy to `brainrouter-cli/.env`. Loaded by the CLI at startup.
|
|
4
|
-
#
|
|
5
|
-
# This file is for CLI-AGENT concerns only:
|
|
6
|
-
# 1. Chat LLM (the model the terminal agent talks to)
|
|
7
|
-
# 2. Tool runtime (loop limit, result clamp, MCP timeout, auto-compact)
|
|
8
|
-
# 3. Sandbox (run_command wrapping)
|
|
9
|
-
# 4. Workspace (root override + shared state root)
|
|
10
|
-
# 5. Web search (custom backend for the web_search tool)
|
|
11
|
-
# 6. Observability (trace log path)
|
|
12
|
-
#
|
|
13
|
-
# MCP-server concerns (cognitive extraction, embeddings, reranker, judge,
|
|
14
|
-
# memory engine knobs, server auth) live in `brainrouter/.env.example`.
|
|
15
|
-
#
|
|
16
|
-
# Why split: the MCP and the CLI are separate processes with different
|
|
17
|
-
# concerns. The CLI's chat LLM can be a smart cloud model while the MCP's
|
|
18
|
-
# cognitive extractor is a cheap local one. Their concurrency caps differ
|
|
19
|
-
# too (CLI default 4, MCP default 2). Keep them independent.
|
|
20
|
-
#
|
|
21
|
-
# All values in this template are blank placeholders. Fill in only what
|
|
22
|
-
# you actually need — most settings have sensible defaults.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# =============================================================================
|
|
26
|
-
# 1. Chat LLM (for the agent's own conversation)
|
|
27
|
-
# =============================================================================
|
|
28
|
-
# Same var names as the MCP, but a separate process — set them here for the
|
|
29
|
-
# CLI's chat model. Falls back to OPENAI_API_KEY.
|
|
30
|
-
#
|
|
31
|
-
# If you don't set BRAINROUTER_LLM_API_KEY here, the CLI also reads it from
|
|
32
|
-
# `brainrouter/.env` as a transitional fallback. Setting it here makes the
|
|
33
|
-
# CLI's choice explicit and lets you use a different chat model than the
|
|
34
|
-
# MCP's extractor (e.g. gpt-4o for chat, gpt-4o-mini for extraction).
|
|
35
|
-
BRAINROUTER_LLM_API_KEY=
|
|
36
|
-
|
|
37
|
-
# OpenAI-compatible chat-completions endpoint.
|
|
38
|
-
# Examples:
|
|
39
|
-
# OpenAI: https://api.openai.com/v1/chat/completions
|
|
40
|
-
# OpenRouter: https://openrouter.ai/api/v1/chat/completions
|
|
41
|
-
# Anthropic via OpenRouter: model id "anthropic/claude-sonnet-4"
|
|
42
|
-
# LM Studio: http://localhost:1234/v1/chat/completions
|
|
43
|
-
BRAINROUTER_LLM_ENDPOINT=https://api.openai.com/v1/chat/completions
|
|
44
|
-
BRAINROUTER_LLM_MODEL=gpt-4o-mini
|
|
45
|
-
|
|
46
|
-
# Per-call timeout for the CLI's chat LLM. Default 120000 (2 min).
|
|
47
|
-
# BRAINROUTER_LLM_TIMEOUT_MS=120000
|
|
48
|
-
|
|
49
|
-
# Cap on concurrent in-flight chat LLM calls FROM THE CLI PROCESS.
|
|
50
|
-
# Default 4 (separate from the MCP's own cap). Set to 1 for consumer-grade
|
|
51
|
-
# local backends; raise to 16+ for cloud APIs.
|
|
52
|
-
# BRAINROUTER_LLM_MAX_CONCURRENT=4
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# =============================================================================
|
|
56
|
-
# 2. Tool runtime
|
|
57
|
-
# =============================================================================
|
|
58
|
-
# Per-tool timeout for CLI → MCP requests. Default 60000.
|
|
59
|
-
# BRAINROUTER_MCP_TIMEOUT_MS=60000
|
|
60
|
-
|
|
61
|
-
# LLM-visible clamp on a single tool-result body (full text still recorded
|
|
62
|
-
# in the transcript on disk). Default 8000.
|
|
63
|
-
# BRAINROUTER_MAX_TOOL_RESULT_CHARS=8000
|
|
64
|
-
|
|
65
|
-
# Hard ceiling on tool-call iterations per turn. Default 60.
|
|
66
|
-
# BRAINROUTER_MAX_TOOL_LOOPS=60
|
|
67
|
-
|
|
68
|
-
# Estimated history-size trigger for auto-`/compact`. Default 80000 tokens.
|
|
69
|
-
# BRAINROUTER_AUTO_COMPACT_TOKENS=80000
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# =============================================================================
|
|
73
|
-
# 3. Sandbox (run_command)
|
|
74
|
-
# =============================================================================
|
|
75
|
-
# Wrap shell commands in the platform sandbox:
|
|
76
|
-
# macOS: sandbox-exec
|
|
77
|
-
# Linux: bwrap (preferred) or firejail
|
|
78
|
-
# Set `on` to enable. Off by default.
|
|
79
|
-
# BRAINROUTER_SANDBOX=on
|
|
80
|
-
|
|
81
|
-
# Allow outbound network from sandboxed commands. Off by default.
|
|
82
|
-
# BRAINROUTER_SANDBOX_NETWORK=off
|
|
83
|
-
|
|
84
|
-
# Colon-separated read/write path allowlists.
|
|
85
|
-
# BRAINROUTER_SANDBOX_READ_PATHS=/usr/local:/opt
|
|
86
|
-
# BRAINROUTER_SANDBOX_WRITE_PATHS=/tmp
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# =============================================================================
|
|
90
|
-
# 4. Workspace
|
|
91
|
-
# =============================================================================
|
|
92
|
-
# Override the workspace root the CLI uses for file tools + session key.
|
|
93
|
-
# Most users let the CLI auto-detect via git / closest package.json.
|
|
94
|
-
# BRAINROUTER_WORKSPACE=/path/to/project
|
|
95
|
-
|
|
96
|
-
# Override per-user state root. Default: ~/.brainrouter.
|
|
97
|
-
# Both the CLI and MCP honor this — set it once and both processes use it.
|
|
98
|
-
# BRAINROUTER_HOME=/path/to/state
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# =============================================================================
|
|
102
|
-
# 5. Web search
|
|
103
|
-
# =============================================================================
|
|
104
|
-
# Custom search backend for the web_search tool. Must accept
|
|
105
|
-
# POST { query, maxResults } → { results: [{ title, url, snippet }] }
|
|
106
|
-
# Falls back to DuckDuckGo's Instant Answer API when unset.
|
|
107
|
-
# Compatible with Brave Search API wrappers, Tavily, SerpAPI proxies, etc.
|
|
108
|
-
# BRAINROUTER_WEB_SEARCH_ENDPOINT=https://your-search-proxy.example.com/search
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# =============================================================================
|
|
112
|
-
# 6. Observability
|
|
113
|
-
# =============================================================================
|
|
114
|
-
# Path for OTEL-style JSONL turn traces. One line per turn/tool span.
|
|
115
|
-
# Toggle at runtime with /trace on|off.
|
|
116
|
-
# BRAINROUTER_TRACE_LOG=/path/to/trace.jsonl
|