@kinqs/brainrouter-cli 0.3.5 → 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/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +224 -3
- package/dist/agent/agent.js +561 -55
- package/dist/cli/banner.d.ts +80 -0
- package/dist/cli/banner.js +232 -0
- package/dist/cli/cliPrompt.d.ts +106 -0
- package/dist/cli/cliPrompt.js +314 -0
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/guard.js +75 -10
- 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 +19 -0
- package/dist/cli/commands/mcp.js +286 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +202 -91
- package/dist/cli/commands/workflow.d.ts +20 -0
- package/dist/cli/commands/workflow.js +368 -51
- 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 +64 -646
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -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 +52 -0
- package/dist/config/config.js +89 -75
- package/dist/index.js +215 -206
- package/dist/memory/briefing.d.ts +11 -1
- package/dist/memory/briefing.js +69 -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/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- 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.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +128 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +104 -13
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +12 -5
- package/.env.example +0 -109
|
@@ -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
|
|
@@ -24,12 +24,20 @@
|
|
|
24
24
|
* "you ran out of room" from "user paused" from
|
|
25
25
|
* "agent gave up."
|
|
26
26
|
*
|
|
27
|
-
* Storage (
|
|
28
|
-
*
|
|
27
|
+
* Storage (priority chain — see `resolveGoalScope`):
|
|
28
|
+
* 1. Workflow bound: `<workspace>/.brainrouter/workflows/<slug>/goal.json`
|
|
29
|
+
* (lives in the committable workflow folder so the goal travels with
|
|
30
|
+
* the spec / tasks / walkthrough).
|
|
31
|
+
* 2. No workflow, session-scoped:
|
|
32
|
+
* `~/.brainrouter/workspaces/<encoded>/cli/sessions/<encodedKey>/goal.json`
|
|
33
|
+
* 3. Back-compat (no workflow, no sessionKey):
|
|
34
|
+
* `~/.brainrouter/workspaces/<encoded>/cli/goal.json`
|
|
29
35
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* Session-scoped reads stay isolated (Item 1 invariant — never fall back to
|
|
37
|
+
* a prior session's goal). Workflow-bound reads stay isolated by workflow
|
|
38
|
+
* (Item 3 invariant — switching workflows swaps which goal you see).
|
|
39
|
+
* normalize() fills missing fields with defaults so resumed sessions don't
|
|
40
|
+
* crash on first read.
|
|
33
41
|
*/
|
|
34
42
|
export type GoalStatus = 'active' | 'paused' | 'complete' | 'blocked' | 'usage_limited';
|
|
35
43
|
/** A pausing status is one where continuation is halted but resumable. */
|
|
@@ -57,7 +65,25 @@ export interface Goal {
|
|
|
57
65
|
completedAt?: string;
|
|
58
66
|
blockedReason?: string;
|
|
59
67
|
}
|
|
60
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Default iteration cap when the user doesn't pass one.
|
|
70
|
+
*
|
|
71
|
+
* Set to a very high number (effectively "unlimited" for any real task)
|
|
72
|
+
* rather than a tight 10. Rationale: the goal lifecycle has three
|
|
73
|
+
* independent safety nets that already prevent runaway loops —
|
|
74
|
+
* 1. Anti-spin — a turn that made zero tool calls doesn't continue
|
|
75
|
+
* 2. Repeat-loop — calling the same tool with identical args 3× errors
|
|
76
|
+
* 3. Manual stop — Ctrl-C, /goal pause, /goal clear
|
|
77
|
+
*
|
|
78
|
+
* A hard iteration cap on top of those is overly paternalistic for users
|
|
79
|
+
* running local models (no $ cost) and is easily lifted with /goal budget
|
|
80
|
+
* <n> when wanted. Display layers should treat any value >= UNLIMITED_THRESHOLD
|
|
81
|
+
* as "unlimited" for friendlier UX.
|
|
82
|
+
*/
|
|
83
|
+
export declare const DEFAULT_GOAL_BUDGET = 1000000;
|
|
84
|
+
export declare const UNLIMITED_BUDGET_THRESHOLD = 100000;
|
|
85
|
+
/** Format helper — used by REPL display + status output. */
|
|
86
|
+
export declare function formatBudget(maxIterations: number): string;
|
|
61
87
|
/**
|
|
62
88
|
* Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
|
|
63
89
|
* outcome statement; multi-thousand-character pastes (e.g. full chat logs)
|
|
@@ -82,6 +108,55 @@ export declare class GoalConflictError extends Error {
|
|
|
82
108
|
readonly existing: Goal;
|
|
83
109
|
constructor(existing: Goal);
|
|
84
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Where the agent's goal lives RIGHT NOW. The priority chain — adapted from
|
|
113
|
+
* openSrc/agentmemory's fallback-provider walk (guard clauses that early-
|
|
114
|
+
* return per layer rather than a single flat loop) — is:
|
|
115
|
+
*
|
|
116
|
+
* 1. workflow scope — a workflow is bound via `current-workflow.json`
|
|
117
|
+
* (the per-user CLI pointer). Goal lives at `<workflow>/goal.json`
|
|
118
|
+
* next to spec.md / tasks.md / meta.json. Switching workflows carries
|
|
119
|
+
* the goal with the folder.
|
|
120
|
+
* 2. session scope — no workflow bound but a sessionKey is supplied
|
|
121
|
+
* (the post-Item-1 default). Goal lives at
|
|
122
|
+
* `<cliStateDir>/sessions/<encodedKey>/goal.json` — strictly per
|
|
123
|
+
* session, never falls back to a different session's file.
|
|
124
|
+
* 3. legacy scope — no workflow, no sessionKey. Used by the very-old
|
|
125
|
+
* single-process call sites that haven't been migrated yet (and by
|
|
126
|
+
* back-compat reads of pre-0.3.5 workspace-level goal.json files).
|
|
127
|
+
*
|
|
128
|
+
* Every read/write entrypoint routes through this single resolver so the
|
|
129
|
+
* priority chain has exactly one decision point. Callers don't decide where
|
|
130
|
+
* to look; they get a path + scope tag and act on it.
|
|
131
|
+
*/
|
|
132
|
+
export type GoalScope = {
|
|
133
|
+
scope: 'session';
|
|
134
|
+
sessionKey: string;
|
|
135
|
+
path: string;
|
|
136
|
+
} | {
|
|
137
|
+
scope: 'legacy';
|
|
138
|
+
path: string;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the on-disk location where the active goal for this CLI process
|
|
142
|
+
* lives.
|
|
143
|
+
*
|
|
144
|
+
* **Design (0.3.6 decouple-goal-from-workflow, supersedes Item 3):** goal
|
|
145
|
+
* is **always per-session**. Workflows are durable artifact folders
|
|
146
|
+
* (spec.md, tasks.md, walkthrough.md, meta.json) that have nothing to do
|
|
147
|
+
* with the agent's autonomy primitive. The earlier Item 3 design coupled
|
|
148
|
+
* the two by storing goal state inside `<workflow>/goal.json`, which
|
|
149
|
+
* meant any two CLI sessions in the same workspace that happened to land
|
|
150
|
+
* on the same workflow shared a goal — silently reintroducing the
|
|
151
|
+
* cross-session leak PR #26 had fixed. We removed that coupling
|
|
152
|
+
* entirely: workflows are storage, goals are runtime, no overlap.
|
|
153
|
+
*
|
|
154
|
+
* Priority chain:
|
|
155
|
+
* 1. Session-scoped — `<session>/goal.json`. The normal case.
|
|
156
|
+
* 2. Legacy — `<cli-state>/goal.json`. Only hit by callers without a
|
|
157
|
+
* sessionKey (rare; mostly tooling paths that pre-date Item 1).
|
|
158
|
+
*/
|
|
159
|
+
export declare function resolveGoalScope(workspaceRoot: string, sessionKey?: string): GoalScope;
|
|
85
160
|
export declare function readGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
|
|
86
161
|
/**
|
|
87
162
|
* Set a new active goal. Refuses to overwrite an in-progress goal (active,
|
|
@@ -158,17 +233,23 @@ export declare function goalHasBudgetLeft(goal: Goal): boolean;
|
|
|
158
233
|
* decision. We detect that ahead of time by checking before the tick.
|
|
159
234
|
*/
|
|
160
235
|
export declare function goalIsOnFinalBudgetTurn(goal: Goal): boolean;
|
|
236
|
+
export interface FormatGoalBlockOptions {
|
|
237
|
+
/**
|
|
238
|
+
* Override the auto-detected final-budget-turn state. Useful for tests
|
|
239
|
+
* and for callers that want to force-render the wrap-up directive. When
|
|
240
|
+
* omitted, `formatGoalBlock` calls `goalIsOnFinalBudgetTurn(goal)` itself.
|
|
241
|
+
*/
|
|
242
|
+
finalBudgetTurn?: boolean;
|
|
243
|
+
}
|
|
244
|
+
export declare function formatGoalBlock(goal: Goal, options?: FormatGoalBlockOptions): string;
|
|
161
245
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
246
|
+
* Drift / ready check used by the goal-continuation prompt. Compressed
|
|
247
|
+
* from the prose-heavy 4-paragraph form into a 2-line checklist as part
|
|
248
|
+
* of 9d's prompt deduplication — the goal text, status, and budget are
|
|
249
|
+
* now owned by the goal-anchor system message; the continuation prompt
|
|
250
|
+
* carries only the per-turn drift check + a pointer to the anchor.
|
|
166
251
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* has many iterations remaining but is near the token cap, or vice versa.
|
|
170
|
-
* Earlier versions hardcoded the iteration framing even when only the
|
|
171
|
-
* token heuristic tripped, which misled the model on token-budgeted runs.
|
|
252
|
+
* Distinct from `buildGoalKickoffPrompt` (in `commands/_helpers.ts`),
|
|
253
|
+
* which is the FIRST-turn prompt fired by `/goal <text>` and `/goal resume`.
|
|
172
254
|
*/
|
|
173
|
-
export declare function
|
|
174
|
-
export declare function formatGoalBlock(goal: Goal): string;
|
|
255
|
+
export declare function buildGoalContinuationPrompt(goal: Goal, lastPrompt: string, lastAnswer: string): string;
|