@tritard/waterbrother 0.5.0
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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/waterbrother.js +7 -0
- package/package.json +46 -0
- package/src/agent.js +551 -0
- package/src/cli.js +5760 -0
- package/src/config.js +216 -0
- package/src/decider.js +131 -0
- package/src/grok-client.js +268 -0
- package/src/impact.js +161 -0
- package/src/mcp.js +376 -0
- package/src/modes.js +84 -0
- package/src/panel.js +154 -0
- package/src/path-utils.js +78 -0
- package/src/prompt.js +182 -0
- package/src/reviewer.js +140 -0
- package/src/session-store.js +154 -0
- package/src/task-store.js +178 -0
- package/src/tools.js +2206 -0
- package/src/workflow.js +153 -0
package/src/impact.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
function shellEscape(arg) {
|
|
8
|
+
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(arg)) return arg;
|
|
9
|
+
return `'${String(arg).replace(/'/g, `'"'"'`)}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function runCommand(command, { cwd, maxBuffer = 1024 * 1024 } = {}) {
|
|
13
|
+
try {
|
|
14
|
+
const { stdout, stderr } = await execAsync(command, { cwd, maxBuffer });
|
|
15
|
+
return { ok: true, stdout: String(stdout || ''), stderr: String(stderr || '') };
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
code: typeof error?.code === 'number' ? error.code : null,
|
|
20
|
+
stdout: String(error?.stdout || ''),
|
|
21
|
+
stderr: String(error?.stderr || error?.message || error || '')
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function uniqueStrings(values = []) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const value of values) {
|
|
30
|
+
const next = String(value || '').trim();
|
|
31
|
+
if (!next || seen.has(next)) continue;
|
|
32
|
+
seen.add(next);
|
|
33
|
+
out.push(next);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stemFor(filePath) {
|
|
39
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
40
|
+
return base && base !== 'index' ? base : path.basename(path.dirname(filePath));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildTestScore(filePath, changedFiles = []) {
|
|
44
|
+
let score = 0;
|
|
45
|
+
const normalized = String(filePath || '').toLowerCase();
|
|
46
|
+
for (const changed of changedFiles) {
|
|
47
|
+
const changedLower = String(changed || '').toLowerCase();
|
|
48
|
+
const changedDir = path.dirname(changedLower);
|
|
49
|
+
const changedStem = stemFor(changedLower).toLowerCase();
|
|
50
|
+
if (normalized.includes(changedDir) && changedDir !== '.') score += 5;
|
|
51
|
+
if (normalized.includes(changedStem)) score += 8;
|
|
52
|
+
const dirParts = changedDir.split('/').filter(Boolean);
|
|
53
|
+
for (const part of dirParts.slice(-2)) {
|
|
54
|
+
if (part && normalized.includes(part)) score += 2;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return score;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function computeImpactMap({ cwd, changedFiles = [], maxRelated = 10, maxTests = 6 } = {}) {
|
|
61
|
+
const normalizedChanged = uniqueStrings(changedFiles.map((item) => String(item || '').replace(/\\/g, '/')));
|
|
62
|
+
if (normalizedChanged.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
changedFiles: [],
|
|
65
|
+
relatedFiles: [],
|
|
66
|
+
suggestedTests: [],
|
|
67
|
+
notes: ['No changed files recorded for this turn.']
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const relatedScores = new Map();
|
|
72
|
+
const relatedReasons = new Map();
|
|
73
|
+
|
|
74
|
+
for (const changed of normalizedChanged.slice(0, 12)) {
|
|
75
|
+
const queries = uniqueStrings([
|
|
76
|
+
stemFor(changed),
|
|
77
|
+
path.basename(changed),
|
|
78
|
+
path.basename(changed, path.extname(changed))
|
|
79
|
+
]).filter((term) => term.length >= 3);
|
|
80
|
+
|
|
81
|
+
for (const term of queries.slice(0, 2)) {
|
|
82
|
+
const result = await runCommand(
|
|
83
|
+
`rg -n --no-heading --color never -F ${shellEscape(term)} ${shellEscape(cwd)}`,
|
|
84
|
+
{ cwd, maxBuffer: 2 * 1024 * 1024 }
|
|
85
|
+
);
|
|
86
|
+
if (!result.ok && result.code !== 1) continue;
|
|
87
|
+
const lines = String(result.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200);
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const match = line.match(/^(.*?):\d+:/);
|
|
90
|
+
if (!match) continue;
|
|
91
|
+
let candidate = path.relative(cwd, match[1]).replace(/\\/g, '/');
|
|
92
|
+
if (!candidate || normalizedChanged.includes(candidate) || candidate.startsWith('.waterbrother/')) continue;
|
|
93
|
+
const score = (relatedScores.get(candidate) || 0) + (candidate.endsWith('.md') ? 1 : 3);
|
|
94
|
+
relatedScores.set(candidate, score);
|
|
95
|
+
const reasons = relatedReasons.get(candidate) || new Set();
|
|
96
|
+
reasons.add(`references ${term}`);
|
|
97
|
+
relatedReasons.set(candidate, reasons);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const relatedFiles = [...relatedScores.entries()]
|
|
103
|
+
.sort((a, b) => b[1] - a[1])
|
|
104
|
+
.slice(0, maxRelated)
|
|
105
|
+
.map(([filePath, score]) => ({
|
|
106
|
+
path: filePath,
|
|
107
|
+
score,
|
|
108
|
+
reasons: [...(relatedReasons.get(filePath) || [])]
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const testsResult = await runCommand(
|
|
112
|
+
`rg --files -g '*test*' -g '*spec*' -g '**/__tests__/**' ${shellEscape(cwd)}`,
|
|
113
|
+
{ cwd, maxBuffer: 2 * 1024 * 1024 }
|
|
114
|
+
);
|
|
115
|
+
const testCandidates = uniqueStrings(String(testsResult.stdout || '').split(/\r?\n/))
|
|
116
|
+
.filter((filePath) => !normalizedChanged.includes(filePath))
|
|
117
|
+
.map((filePath) => ({
|
|
118
|
+
path: filePath,
|
|
119
|
+
score: buildTestScore(filePath, normalizedChanged)
|
|
120
|
+
}))
|
|
121
|
+
.filter((item) => item.score > 0)
|
|
122
|
+
.sort((a, b) => b.score - a.score)
|
|
123
|
+
.slice(0, maxTests)
|
|
124
|
+
.map((item) => ({
|
|
125
|
+
path: item.path,
|
|
126
|
+
score: item.score,
|
|
127
|
+
reason: 'shares module name or directory with changed files'
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const notes = [];
|
|
131
|
+
if (relatedFiles.length === 0) notes.push('No strong related-file signals found from text references.');
|
|
132
|
+
if (testCandidates.length === 0) notes.push('No nearby test files matched the changed file names.');
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
changedFiles: normalizedChanged,
|
|
136
|
+
relatedFiles,
|
|
137
|
+
suggestedTests: testCandidates,
|
|
138
|
+
notes
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function summarizeImpactMap(impact) {
|
|
143
|
+
if (!impact || typeof impact !== 'object') return null;
|
|
144
|
+
const changed = Array.isArray(impact.changedFiles) ? impact.changedFiles.length : 0;
|
|
145
|
+
const related = Array.isArray(impact.relatedFiles) ? impact.relatedFiles.length : 0;
|
|
146
|
+
const tests = Array.isArray(impact.suggestedTests) ? impact.suggestedTests.length : 0;
|
|
147
|
+
const docsImpact = (impact.relatedFiles || []).filter((f) => String(f.path || '').endsWith('.md'));
|
|
148
|
+
const configImpact = (impact.relatedFiles || []).filter((f) => {
|
|
149
|
+
const p = String(f.path || '').toLowerCase();
|
|
150
|
+
return p.endsWith('.json') || p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.toml') || p.includes('config');
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
changed,
|
|
154
|
+
related,
|
|
155
|
+
tests,
|
|
156
|
+
docsFiles: docsImpact.map((f) => f.path),
|
|
157
|
+
configFiles: configImpact.map((f) => f.path),
|
|
158
|
+
topRelated: (impact.relatedFiles || []).slice(0, 3).map((f) => f.path),
|
|
159
|
+
topTests: (impact.suggestedTests || []).slice(0, 3).map((f) => f.path)
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
5
|
+
const CLIENT_INFO = { name: "waterbrother", version: "0.1.0" };
|
|
6
|
+
|
|
7
|
+
function pretty(data) {
|
|
8
|
+
return typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clipText(text, maxChars = 4000) {
|
|
12
|
+
const raw = String(text || "");
|
|
13
|
+
if (raw.length <= maxChars) return raw;
|
|
14
|
+
return `${raw.slice(0, maxChars)}\n...[truncated]`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeSegment(value) {
|
|
18
|
+
return String(value || "")
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
22
|
+
.replace(/^_+|_+$/g, "") || "tool";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildWireToolName(serverName, toolName) {
|
|
26
|
+
return `mcp__${sanitizeSegment(serverName)}__${sanitizeSegment(toolName)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createAbortError() {
|
|
30
|
+
const error = new Error("Operation aborted");
|
|
31
|
+
error.name = "AbortError";
|
|
32
|
+
error.code = "ABORT_ERR";
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function throwIfAborted(signal) {
|
|
37
|
+
if (signal?.aborted) throw createAbortError();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeInputSchema(schema) {
|
|
41
|
+
if (schema && typeof schema === "object") return schema;
|
|
42
|
+
return { type: "object", properties: {} };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatToolResult(result, serverName, toolName) {
|
|
46
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
47
|
+
const text = content
|
|
48
|
+
.map((item) => {
|
|
49
|
+
if (item?.type === "text") return String(item.text || "");
|
|
50
|
+
if (item?.type === "image") return "[image]";
|
|
51
|
+
return item && typeof item === "object" ? JSON.stringify(item) : String(item || "");
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join("\n")
|
|
55
|
+
.trim();
|
|
56
|
+
|
|
57
|
+
return pretty({
|
|
58
|
+
ok: !result?.isError,
|
|
59
|
+
server: serverName,
|
|
60
|
+
tool: toolName,
|
|
61
|
+
content: text || undefined,
|
|
62
|
+
structuredContent: result?.structuredContent || undefined
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class StdioMessageParser {
|
|
67
|
+
constructor(onMessage) {
|
|
68
|
+
this.onMessage = onMessage;
|
|
69
|
+
this.buffer = Buffer.alloc(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
push(chunk) {
|
|
73
|
+
this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]);
|
|
74
|
+
this.drain();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
drain() {
|
|
78
|
+
while (this.buffer.length > 0) {
|
|
79
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
80
|
+
if (headerEnd === -1) return;
|
|
81
|
+
|
|
82
|
+
const headerText = this.buffer.slice(0, headerEnd).toString("utf8");
|
|
83
|
+
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
|
84
|
+
if (!contentLengthMatch) {
|
|
85
|
+
throw new Error("MCP response missing Content-Length");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const contentLength = Number(contentLengthMatch[1]);
|
|
89
|
+
const totalLength = headerEnd + 4 + contentLength;
|
|
90
|
+
if (this.buffer.length < totalLength) return;
|
|
91
|
+
|
|
92
|
+
const body = this.buffer.slice(headerEnd + 4, totalLength).toString("utf8");
|
|
93
|
+
this.buffer = this.buffer.slice(totalLength);
|
|
94
|
+
this.onMessage(JSON.parse(body));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class StdioMcpClient {
|
|
100
|
+
constructor(serverName, spec, { cwd }) {
|
|
101
|
+
this.serverName = serverName;
|
|
102
|
+
this.spec = spec;
|
|
103
|
+
this.cwd = cwd;
|
|
104
|
+
this.child = null;
|
|
105
|
+
this.initialized = false;
|
|
106
|
+
this.nextId = 1;
|
|
107
|
+
this.pending = new Map();
|
|
108
|
+
this.stderr = "";
|
|
109
|
+
this.serverInfo = null;
|
|
110
|
+
this.startPromise = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async start() {
|
|
114
|
+
if (this.startPromise) return this.startPromise;
|
|
115
|
+
|
|
116
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
117
|
+
const command = String(this.spec.command || "").trim();
|
|
118
|
+
if (!command) {
|
|
119
|
+
reject(new Error(`MCP server ${this.serverName} is missing a command`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const args = Array.isArray(this.spec.args) ? this.spec.args.map((item) => String(item)) : [];
|
|
124
|
+
const env =
|
|
125
|
+
this.spec.env && typeof this.spec.env === "object"
|
|
126
|
+
? { ...process.env, ...this.spec.env }
|
|
127
|
+
: process.env;
|
|
128
|
+
|
|
129
|
+
const child = spawn(command, args, {
|
|
130
|
+
cwd: this.spec.cwd || this.cwd,
|
|
131
|
+
env,
|
|
132
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
this.child = child;
|
|
136
|
+
const parser = new StdioMessageParser((message) => this.handleMessage(message));
|
|
137
|
+
|
|
138
|
+
child.stdout.on("data", (chunk) => {
|
|
139
|
+
try {
|
|
140
|
+
parser.push(chunk);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
this.failAll(error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
child.stderr.on("data", (chunk) => {
|
|
147
|
+
this.stderr = clipText(`${this.stderr}${String(chunk || "")}`, 8000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
child.on("error", (error) => {
|
|
151
|
+
this.failAll(error);
|
|
152
|
+
reject(error);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
child.on("exit", (code, signal) => {
|
|
156
|
+
const error = new Error(`MCP server ${this.serverName} exited (${signal || code || 0})`);
|
|
157
|
+
this.failAll(error);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return this.startPromise;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
handleMessage(message) {
|
|
167
|
+
if (!message || !Object.prototype.hasOwnProperty.call(message, "id")) return;
|
|
168
|
+
const pending = this.pending.get(message.id);
|
|
169
|
+
if (!pending) return;
|
|
170
|
+
this.pending.delete(message.id);
|
|
171
|
+
clearTimeout(pending.timeoutId);
|
|
172
|
+
|
|
173
|
+
if (message.error) {
|
|
174
|
+
const error = new Error(message.error.message || `MCP request failed: ${pending.method}`);
|
|
175
|
+
error.code = message.error.code;
|
|
176
|
+
pending.reject(error);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pending.resolve(message.result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
failAll(error) {
|
|
184
|
+
for (const pending of this.pending.values()) {
|
|
185
|
+
clearTimeout(pending.timeoutId);
|
|
186
|
+
pending.reject(error);
|
|
187
|
+
}
|
|
188
|
+
this.pending.clear();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sendMessage(message) {
|
|
192
|
+
if (!this.child?.stdin) {
|
|
193
|
+
throw new Error(`MCP server ${this.serverName} is not running`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
197
|
+
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8");
|
|
198
|
+
this.child.stdin.write(Buffer.concat([header, body]));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async request(method, params = {}, { signal, timeoutMs = 30000 } = {}) {
|
|
202
|
+
await this.start();
|
|
203
|
+
throwIfAborted(signal);
|
|
204
|
+
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const id = this.nextId++;
|
|
207
|
+
const timeoutId = setTimeout(() => {
|
|
208
|
+
this.pending.delete(id);
|
|
209
|
+
reject(new Error(`MCP request timed out: ${this.serverName} ${method}`));
|
|
210
|
+
}, timeoutMs);
|
|
211
|
+
|
|
212
|
+
this.pending.set(id, { method, resolve, reject, timeoutId });
|
|
213
|
+
try {
|
|
214
|
+
this.sendMessage({
|
|
215
|
+
jsonrpc: "2.0",
|
|
216
|
+
id,
|
|
217
|
+
method,
|
|
218
|
+
params
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
clearTimeout(timeoutId);
|
|
222
|
+
this.pending.delete(id);
|
|
223
|
+
reject(error);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
sendNotification(method, params = {}) {
|
|
229
|
+
this.sendMessage({
|
|
230
|
+
jsonrpc: "2.0",
|
|
231
|
+
method,
|
|
232
|
+
params
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async initialize({ signal } = {}) {
|
|
237
|
+
if (this.initialized) return;
|
|
238
|
+
const result = await this.request(
|
|
239
|
+
"initialize",
|
|
240
|
+
{
|
|
241
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
242
|
+
capabilities: {},
|
|
243
|
+
clientInfo: CLIENT_INFO
|
|
244
|
+
},
|
|
245
|
+
{ signal, timeoutMs: 30000 }
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
this.initialized = true;
|
|
249
|
+
this.serverInfo = result?.serverInfo || null;
|
|
250
|
+
this.sendNotification("notifications/initialized", {});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async listTools({ signal } = {}) {
|
|
254
|
+
await this.initialize({ signal });
|
|
255
|
+
const result = await this.request("tools/list", {}, { signal, timeoutMs: 30000 });
|
|
256
|
+
return Array.isArray(result?.tools) ? result.tools : [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async callTool(toolName, args, { signal } = {}) {
|
|
260
|
+
await this.initialize({ signal });
|
|
261
|
+
return this.request(
|
|
262
|
+
"tools/call",
|
|
263
|
+
{
|
|
264
|
+
name: toolName,
|
|
265
|
+
arguments: args && typeof args === "object" ? args : {}
|
|
266
|
+
},
|
|
267
|
+
{ signal, timeoutMs: 120000 }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export class McpManager {
|
|
273
|
+
constructor({ cwd, servers = {} }) {
|
|
274
|
+
this.cwd = cwd;
|
|
275
|
+
this.servers = servers && typeof servers === "object" ? servers : {};
|
|
276
|
+
this.clients = new Map();
|
|
277
|
+
this.toolMap = new Map();
|
|
278
|
+
this.serverStatuses = [];
|
|
279
|
+
this.initPromise = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async ensureReady({ signal } = {}) {
|
|
283
|
+
if (this.initPromise) return this.initPromise;
|
|
284
|
+
|
|
285
|
+
this.initPromise = (async () => {
|
|
286
|
+
const entries = Object.entries(this.servers).filter(([, spec]) => spec && !spec.disabled);
|
|
287
|
+
const statuses = [];
|
|
288
|
+
|
|
289
|
+
for (const [serverName, spec] of entries) {
|
|
290
|
+
const client = new StdioMcpClient(serverName, spec, { cwd: this.cwd });
|
|
291
|
+
this.clients.set(serverName, client);
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const tools = await client.listTools({ signal });
|
|
295
|
+
const connectedTools = [];
|
|
296
|
+
|
|
297
|
+
for (const tool of tools) {
|
|
298
|
+
const wireName = buildWireToolName(serverName, tool.name);
|
|
299
|
+
this.toolMap.set(wireName, {
|
|
300
|
+
serverName,
|
|
301
|
+
originalName: tool.name,
|
|
302
|
+
client,
|
|
303
|
+
definition: {
|
|
304
|
+
type: "function",
|
|
305
|
+
function: {
|
|
306
|
+
name: wireName,
|
|
307
|
+
description: `MCP ${serverName}: ${tool.description || tool.name}`,
|
|
308
|
+
parameters: normalizeInputSchema(tool.inputSchema)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
connectedTools.push({ name: wireName, originalName: tool.name });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
statuses.push({
|
|
316
|
+
name: serverName,
|
|
317
|
+
ok: true,
|
|
318
|
+
command: spec.command,
|
|
319
|
+
toolCount: connectedTools.length,
|
|
320
|
+
tools: connectedTools,
|
|
321
|
+
serverInfo: client.serverInfo
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
statuses.push({
|
|
325
|
+
name: serverName,
|
|
326
|
+
ok: false,
|
|
327
|
+
command: spec.command,
|
|
328
|
+
toolCount: 0,
|
|
329
|
+
error: error instanceof Error ? error.message : String(error),
|
|
330
|
+
stderr: client.stderr || undefined
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.serverStatuses = statuses;
|
|
336
|
+
})();
|
|
337
|
+
|
|
338
|
+
return this.initPromise;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
getToolDefs() {
|
|
342
|
+
return Array.from(this.toolMap.values(), (entry) => entry.definition);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getStatus() {
|
|
346
|
+
return {
|
|
347
|
+
configured: Object.entries(this.servers).filter(([, spec]) => spec && !spec.disabled).length,
|
|
348
|
+
connected: this.serverStatuses.filter((item) => item.ok).length,
|
|
349
|
+
servers: this.serverStatuses
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
hasTool(toolName) {
|
|
354
|
+
return this.toolMap.has(toolName);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async executeToolCall(toolName, args, { signal } = {}) {
|
|
358
|
+
await this.ensureReady({ signal });
|
|
359
|
+
const entry = this.toolMap.get(toolName);
|
|
360
|
+
if (!entry) {
|
|
361
|
+
return pretty({ ok: false, error: `Unknown MCP tool: ${toolName}` });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const result = await entry.client.callTool(entry.originalName, args, { signal });
|
|
366
|
+
return formatToolResult(result, entry.serverName, entry.originalName);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return pretty({
|
|
369
|
+
ok: false,
|
|
370
|
+
server: entry.serverName,
|
|
371
|
+
tool: entry.originalName,
|
|
372
|
+
error: error instanceof Error ? error.message : String(error)
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
package/src/modes.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const EXPERIENCE_MODES = ["guide", "standard", "expert", "auditor"];
|
|
2
|
+
export const AUTONOMY_MODES = ["ask", "scoped", "auto"];
|
|
3
|
+
|
|
4
|
+
const EXPERIENCE_DESCRIPTIONS = {
|
|
5
|
+
guide: "Explain plans in plain language, surface assumptions, and keep users oriented.",
|
|
6
|
+
standard: "Be concise but clear. Show the plan briefly and focus on execution.",
|
|
7
|
+
expert: "Be terse and high-signal. Skip education unless risk or ambiguity makes it necessary.",
|
|
8
|
+
auditor: "Operate as a read-only reviewer. Inspect, verify, and report risks without mutating the repo."
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const AUTONOMY_DESCRIPTIONS = {
|
|
12
|
+
ask: "Ask before any shell command or repo mutation. Optimize for safety and onboarding.",
|
|
13
|
+
scoped: "Operate freely inside policy and declared contracts, but ask when risk or scope expands.",
|
|
14
|
+
auto: "Move quickly inside policy and declared contracts with minimal prompting. High-risk actions still require confirmation."
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function normalizeExperienceMode(value) {
|
|
18
|
+
const next = String(value || "standard").toLowerCase().trim();
|
|
19
|
+
if (!EXPERIENCE_MODES.includes(next)) {
|
|
20
|
+
throw new Error(`Invalid mode: ${value}. Expected one of ${EXPERIENCE_MODES.join(", ")}`);
|
|
21
|
+
}
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeAutonomyMode(value) {
|
|
26
|
+
const next = String(value || "scoped").toLowerCase().trim();
|
|
27
|
+
if (!AUTONOMY_MODES.includes(next)) {
|
|
28
|
+
throw new Error(`Invalid autonomy: ${value}. Expected one of ${AUTONOMY_MODES.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getExperienceModePrompt(mode) {
|
|
34
|
+
const normalized = normalizeExperienceMode(mode);
|
|
35
|
+
return EXPERIENCE_DESCRIPTIONS[normalized];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getAutonomyModePrompt(mode) {
|
|
39
|
+
const normalized = normalizeAutonomyMode(mode);
|
|
40
|
+
return AUTONOMY_DESCRIPTIONS[normalized];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function modeDefaults(mode) {
|
|
44
|
+
const normalized = normalizeExperienceMode(mode);
|
|
45
|
+
if (normalized === "auditor") {
|
|
46
|
+
return {
|
|
47
|
+
agentProfile: "reviewer",
|
|
48
|
+
toolsEnabled: true,
|
|
49
|
+
traceMode: "on"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (normalized === "guide") {
|
|
53
|
+
return {
|
|
54
|
+
agentProfile: "coder",
|
|
55
|
+
toolsEnabled: true,
|
|
56
|
+
traceMode: "on"
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (normalized === "expert") {
|
|
60
|
+
return {
|
|
61
|
+
agentProfile: "coder",
|
|
62
|
+
toolsEnabled: true,
|
|
63
|
+
traceMode: "on"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
agentProfile: "coder",
|
|
68
|
+
toolsEnabled: true,
|
|
69
|
+
traceMode: "on"
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildOperatorIdentity({ mode = "standard", autonomy = "scoped", profile = "coder" } = {}) {
|
|
74
|
+
const normalizedMode = normalizeExperienceMode(mode);
|
|
75
|
+
const normalizedAutonomy = normalizeAutonomyMode(autonomy);
|
|
76
|
+
return {
|
|
77
|
+
mode: normalizedMode,
|
|
78
|
+
autonomy: normalizedAutonomy,
|
|
79
|
+
profile: String(profile || "coder"),
|
|
80
|
+
summary: `${normalizedMode}/${normalizedAutonomy}/${String(profile || "coder")}`,
|
|
81
|
+
modeDescription: EXPERIENCE_DESCRIPTIONS[normalizedMode],
|
|
82
|
+
autonomyDescription: AUTONOMY_DESCRIPTIONS[normalizedAutonomy]
|
|
83
|
+
};
|
|
84
|
+
}
|