opencara 0.2.0 → 0.2.2
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/dist/index.js +1938 -10
- package/package.json +4 -2
- package/dist/commands/agent.d.ts +0 -46
- package/dist/commands/agent.d.ts.map +0 -1
- package/dist/commands/agent.js +0 -924
- package/dist/commands/agent.js.map +0 -1
- package/dist/commands/login.d.ts +0 -5
- package/dist/commands/login.d.ts.map +0 -1
- package/dist/commands/login.js +0 -102
- package/dist/commands/login.js.map +0 -1
- package/dist/commands/stats.d.ts +0 -9
- package/dist/commands/stats.d.ts.map +0 -1
- package/dist/commands/stats.js +0 -187
- package/dist/commands/stats.js.map +0 -1
- package/dist/config.d.ts +0 -48
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -227
- package/dist/config.js.map +0 -1
- package/dist/consumption.d.ts +0 -21
- package/dist/consumption.d.ts.map +0 -1
- package/dist/consumption.js +0 -18
- package/dist/consumption.js.map +0 -1
- package/dist/http.d.ts +0 -14
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -59
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/reconnect.d.ts +0 -10
- package/dist/reconnect.d.ts.map +0 -1
- package/dist/reconnect.js +0 -17
- package/dist/reconnect.js.map +0 -1
- package/dist/review.d.ts +0 -34
- package/dist/review.d.ts.map +0 -1
- package/dist/review.js +0 -109
- package/dist/review.js.map +0 -1
- package/dist/summary.d.ts +0 -34
- package/dist/summary.d.ts.map +0 -1
- package/dist/summary.js +0 -90
- package/dist/summary.js.map +0 -1
- package/dist/tool-executor.d.ts +0 -50
- package/dist/tool-executor.d.ts.map +0 -1
- package/dist/tool-executor.js +0 -232
- package/dist/tool-executor.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,15 +1,1943 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command4 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/login.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import * as readline from "readline";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import * as os from "os";
|
|
14
|
+
import { parse, stringify } from "yaml";
|
|
15
|
+
var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
|
|
16
|
+
var CONFIG_DIR = path.join(os.homedir(), ".opencara");
|
|
17
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.yml");
|
|
18
|
+
function ensureConfigDir() {
|
|
19
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
var DEFAULT_MAX_DIFF_SIZE_KB = 100;
|
|
22
|
+
function parseLimits(data) {
|
|
23
|
+
const raw = data.limits;
|
|
24
|
+
if (!raw || typeof raw !== "object") return null;
|
|
25
|
+
const obj = raw;
|
|
26
|
+
const limits = {};
|
|
27
|
+
if (typeof obj.tokens_per_day === "number") limits.tokens_per_day = obj.tokens_per_day;
|
|
28
|
+
if (typeof obj.tokens_per_month === "number") limits.tokens_per_month = obj.tokens_per_month;
|
|
29
|
+
if (typeof obj.reviews_per_day === "number") limits.reviews_per_day = obj.reviews_per_day;
|
|
30
|
+
if (Object.keys(limits).length === 0) return null;
|
|
31
|
+
return limits;
|
|
32
|
+
}
|
|
33
|
+
var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
|
|
34
|
+
var REPO_PATTERN = /^[^/]+\/[^/]+$/;
|
|
35
|
+
var RepoConfigError = class extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "RepoConfigError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function parseRepoConfig(obj, index) {
|
|
42
|
+
const raw = obj.repos;
|
|
43
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
44
|
+
if (typeof raw !== "object") {
|
|
45
|
+
throw new RepoConfigError(`agents[${index}].repos must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const reposObj = raw;
|
|
48
|
+
const mode = reposObj.mode;
|
|
49
|
+
if (mode === void 0) {
|
|
50
|
+
throw new RepoConfigError(`agents[${index}].repos.mode is required`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof mode !== "string" || !VALID_REPO_MODES.includes(mode)) {
|
|
53
|
+
throw new RepoConfigError(
|
|
54
|
+
`agents[${index}].repos.mode must be one of: ${VALID_REPO_MODES.join(", ")}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const config = { mode };
|
|
58
|
+
if (mode === "whitelist" || mode === "blacklist") {
|
|
59
|
+
const list = reposObj.list;
|
|
60
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
61
|
+
throw new RepoConfigError(
|
|
62
|
+
`agents[${index}].repos.list is required and must be non-empty for mode '${mode}'`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
for (let j = 0; j < list.length; j++) {
|
|
66
|
+
if (typeof list[j] !== "string" || !REPO_PATTERN.test(list[j])) {
|
|
67
|
+
throw new RepoConfigError(
|
|
68
|
+
`agents[${index}].repos.list[${j}] must match 'owner/repo' format`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
config.list = list;
|
|
73
|
+
}
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
function parseAnonymousAgents(data) {
|
|
77
|
+
const raw = data.anonymous_agents;
|
|
78
|
+
if (!Array.isArray(raw)) return [];
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (let i = 0; i < raw.length; i++) {
|
|
81
|
+
const entry = raw[i];
|
|
82
|
+
if (!entry || typeof entry !== "object") {
|
|
83
|
+
console.warn(`Warning: anonymous_agents[${i}] is not an object, skipping`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const obj = entry;
|
|
87
|
+
if (typeof obj.agent_id !== "string" || typeof obj.api_key !== "string" || typeof obj.model !== "string" || typeof obj.tool !== "string") {
|
|
88
|
+
console.warn(
|
|
89
|
+
`Warning: anonymous_agents[${i}] missing required agent_id/api_key/model/tool fields, skipping`
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const anon = {
|
|
94
|
+
agentId: obj.agent_id,
|
|
95
|
+
apiKey: obj.api_key,
|
|
96
|
+
model: obj.model,
|
|
97
|
+
tool: obj.tool
|
|
98
|
+
};
|
|
99
|
+
if (obj.repo_config && typeof obj.repo_config === "object") {
|
|
100
|
+
const rc = obj.repo_config;
|
|
101
|
+
if (typeof rc.mode === "string" && VALID_REPO_MODES.includes(rc.mode)) {
|
|
102
|
+
const repoConfig = { mode: rc.mode };
|
|
103
|
+
if (Array.isArray(rc.list)) {
|
|
104
|
+
repoConfig.list = rc.list.filter((v) => typeof v === "string");
|
|
105
|
+
}
|
|
106
|
+
anon.repoConfig = repoConfig;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
entries.push(anon);
|
|
110
|
+
}
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
function parseAgents(data) {
|
|
114
|
+
if (!("agents" in data)) return null;
|
|
115
|
+
const raw = data.agents;
|
|
116
|
+
if (!Array.isArray(raw)) return null;
|
|
117
|
+
const agents = [];
|
|
118
|
+
for (let i = 0; i < raw.length; i++) {
|
|
119
|
+
const entry = raw[i];
|
|
120
|
+
if (!entry || typeof entry !== "object") {
|
|
121
|
+
console.warn(`Warning: agents[${i}] is not an object, skipping`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const obj = entry;
|
|
125
|
+
if (typeof obj.model !== "string" || typeof obj.tool !== "string") {
|
|
126
|
+
console.warn(`Warning: agents[${i}] missing required model/tool fields, skipping`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const agent = { model: obj.model, tool: obj.tool };
|
|
130
|
+
if (typeof obj.command === "string") agent.command = obj.command;
|
|
131
|
+
const agentLimits = parseLimits(obj);
|
|
132
|
+
if (agentLimits) agent.limits = agentLimits;
|
|
133
|
+
const repoConfig = parseRepoConfig(obj, i);
|
|
134
|
+
if (repoConfig) agent.repos = repoConfig;
|
|
135
|
+
agents.push(agent);
|
|
136
|
+
}
|
|
137
|
+
return agents;
|
|
138
|
+
}
|
|
139
|
+
function loadConfig() {
|
|
140
|
+
const defaults = {
|
|
141
|
+
apiKey: null,
|
|
142
|
+
platformUrl: DEFAULT_PLATFORM_URL,
|
|
143
|
+
maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
|
|
144
|
+
limits: null,
|
|
145
|
+
agentCommand: null,
|
|
146
|
+
agents: null,
|
|
147
|
+
anonymousAgents: []
|
|
148
|
+
};
|
|
149
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
150
|
+
return defaults;
|
|
151
|
+
}
|
|
152
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
153
|
+
const data = parse(raw);
|
|
154
|
+
if (!data || typeof data !== "object") {
|
|
155
|
+
return defaults;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
apiKey: typeof data.api_key === "string" ? data.api_key : null,
|
|
159
|
+
platformUrl: typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL,
|
|
160
|
+
maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
|
|
161
|
+
limits: parseLimits(data),
|
|
162
|
+
agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
|
|
163
|
+
agents: parseAgents(data),
|
|
164
|
+
anonymousAgents: parseAnonymousAgents(data)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function saveConfig(config) {
|
|
168
|
+
ensureConfigDir();
|
|
169
|
+
const data = {
|
|
170
|
+
platform_url: config.platformUrl
|
|
171
|
+
};
|
|
172
|
+
if (config.apiKey) {
|
|
173
|
+
data.api_key = config.apiKey;
|
|
174
|
+
}
|
|
175
|
+
if (config.maxDiffSizeKb !== DEFAULT_MAX_DIFF_SIZE_KB) {
|
|
176
|
+
data.max_diff_size_kb = config.maxDiffSizeKb;
|
|
177
|
+
}
|
|
178
|
+
if (config.limits) {
|
|
179
|
+
data.limits = config.limits;
|
|
180
|
+
}
|
|
181
|
+
if (config.agentCommand) {
|
|
182
|
+
data.agent_command = config.agentCommand;
|
|
183
|
+
}
|
|
184
|
+
if (config.agents !== null) {
|
|
185
|
+
data.agents = config.agents;
|
|
186
|
+
}
|
|
187
|
+
if (config.anonymousAgents.length > 0) {
|
|
188
|
+
data.anonymous_agents = config.anonymousAgents.map((a) => {
|
|
189
|
+
const entry = {
|
|
190
|
+
agent_id: a.agentId,
|
|
191
|
+
api_key: a.apiKey,
|
|
192
|
+
model: a.model,
|
|
193
|
+
tool: a.tool
|
|
194
|
+
};
|
|
195
|
+
if (a.repoConfig) {
|
|
196
|
+
entry.repo_config = a.repoConfig;
|
|
197
|
+
}
|
|
198
|
+
return entry;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
fs.writeFileSync(CONFIG_FILE, stringify(data), { encoding: "utf-8", mode: 384 });
|
|
202
|
+
}
|
|
203
|
+
function resolveAgentLimits(agentLimits, globalLimits) {
|
|
204
|
+
if (!agentLimits && !globalLimits) return null;
|
|
205
|
+
if (!agentLimits) return globalLimits;
|
|
206
|
+
if (!globalLimits) return agentLimits;
|
|
207
|
+
const merged = { ...globalLimits, ...agentLimits };
|
|
208
|
+
return Object.keys(merged).length === 0 ? null : merged;
|
|
209
|
+
}
|
|
210
|
+
function findAnonymousAgent(config, agentId) {
|
|
211
|
+
return config.anonymousAgents.find((a) => a.agentId === agentId) ?? null;
|
|
212
|
+
}
|
|
213
|
+
function removeAnonymousAgent(config, agentId) {
|
|
214
|
+
config.anonymousAgents = config.anonymousAgents.filter((a) => a.agentId !== agentId);
|
|
215
|
+
}
|
|
216
|
+
function requireApiKey(config) {
|
|
217
|
+
if (!config.apiKey) {
|
|
218
|
+
console.error("Not authenticated. Run `opencara login` first.");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
return config.apiKey;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/http.ts
|
|
225
|
+
var HttpError = class extends Error {
|
|
226
|
+
constructor(status, message) {
|
|
227
|
+
super(message);
|
|
228
|
+
this.status = status;
|
|
229
|
+
this.name = "HttpError";
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
var ApiClient = class {
|
|
233
|
+
constructor(baseUrl, apiKey = null) {
|
|
234
|
+
this.baseUrl = baseUrl;
|
|
235
|
+
this.apiKey = apiKey;
|
|
236
|
+
}
|
|
237
|
+
headers() {
|
|
238
|
+
const h = {
|
|
239
|
+
"Content-Type": "application/json"
|
|
240
|
+
};
|
|
241
|
+
if (this.apiKey) {
|
|
242
|
+
h["Authorization"] = `Bearer ${this.apiKey}`;
|
|
243
|
+
}
|
|
244
|
+
return h;
|
|
245
|
+
}
|
|
246
|
+
async get(path3) {
|
|
247
|
+
const res = await fetch(`${this.baseUrl}${path3}`, {
|
|
248
|
+
method: "GET",
|
|
249
|
+
headers: this.headers()
|
|
250
|
+
});
|
|
251
|
+
return this.handleResponse(res);
|
|
252
|
+
}
|
|
253
|
+
async post(path3, body) {
|
|
254
|
+
const res = await fetch(`${this.baseUrl}${path3}`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: this.headers(),
|
|
257
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
258
|
+
});
|
|
259
|
+
return this.handleResponse(res);
|
|
260
|
+
}
|
|
261
|
+
async handleResponse(res) {
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
if (res.status === 401) {
|
|
264
|
+
throw new HttpError(401, "Not authenticated. Run `opencara login` first.");
|
|
265
|
+
}
|
|
266
|
+
let message = `HTTP ${res.status}`;
|
|
267
|
+
try {
|
|
268
|
+
const body = await res.json();
|
|
269
|
+
if (body.error) message = body.error;
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
throw new HttpError(res.status, message);
|
|
273
|
+
}
|
|
274
|
+
return await res.json();
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// src/reconnect.ts
|
|
279
|
+
var DEFAULT_RECONNECT_OPTIONS = {
|
|
280
|
+
initialDelay: 1e3,
|
|
281
|
+
maxDelay: 3e4,
|
|
282
|
+
multiplier: 2,
|
|
283
|
+
jitter: true
|
|
284
|
+
};
|
|
285
|
+
function calculateDelay(attempt, options = DEFAULT_RECONNECT_OPTIONS) {
|
|
286
|
+
const base = Math.min(
|
|
287
|
+
options.initialDelay * Math.pow(options.multiplier, attempt),
|
|
288
|
+
options.maxDelay
|
|
289
|
+
);
|
|
290
|
+
if (options.jitter) {
|
|
291
|
+
return base + Math.random() * 500;
|
|
292
|
+
}
|
|
293
|
+
return base;
|
|
294
|
+
}
|
|
295
|
+
function sleep(ms) {
|
|
296
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/commands/login.ts
|
|
300
|
+
function promptYesNo(question) {
|
|
301
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
rl.question(question, (answer) => {
|
|
304
|
+
rl.close();
|
|
305
|
+
const normalized = answer.trim().toLowerCase();
|
|
306
|
+
resolve(normalized === "" || normalized === "y" || normalized === "yes");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
var loginCommand = new Command("login").description("Authenticate with GitHub via device flow").action(async () => {
|
|
311
|
+
const config = loadConfig();
|
|
312
|
+
const client = new ApiClient(config.platformUrl);
|
|
313
|
+
let flow;
|
|
314
|
+
try {
|
|
315
|
+
flow = await client.post("/auth/device");
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error("Failed to start device flow:", err instanceof Error ? err.message : err);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
console.log();
|
|
321
|
+
console.log("To sign in, open this URL in your browser:");
|
|
322
|
+
console.log(` ${flow.verificationUri}`);
|
|
323
|
+
console.log();
|
|
324
|
+
console.log(`And enter this code: ${flow.userCode}`);
|
|
325
|
+
console.log();
|
|
326
|
+
console.log("Waiting for authorization...");
|
|
327
|
+
const intervalMs = flow.interval * 1e3;
|
|
328
|
+
const deadline = Date.now() + flow.expiresIn * 1e3;
|
|
329
|
+
while (Date.now() < deadline) {
|
|
330
|
+
await sleep(intervalMs);
|
|
331
|
+
let tokenRes;
|
|
332
|
+
try {
|
|
333
|
+
tokenRes = await client.post("/auth/device/token", {
|
|
334
|
+
deviceCode: flow.deviceCode
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error("Polling error:", err instanceof Error ? err.message : err);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (tokenRes.status === "pending") {
|
|
341
|
+
process.stdout.write(".");
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (tokenRes.status === "expired") {
|
|
345
|
+
console.error("\nDevice code expired. Please run `opencara login` again.");
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
if (tokenRes.status === "complete") {
|
|
349
|
+
config.apiKey = tokenRes.apiKey;
|
|
350
|
+
saveConfig(config);
|
|
351
|
+
console.log("\nLogged in successfully. API key saved to ~/.opencara/config.yml");
|
|
352
|
+
if (config.anonymousAgents.length > 0 && process.stdin.isTTY) {
|
|
353
|
+
console.log();
|
|
354
|
+
console.log(`Found ${config.anonymousAgents.length} anonymous agent(s):`);
|
|
355
|
+
for (const anon of config.anonymousAgents) {
|
|
356
|
+
console.log(` - ${anon.agentId} (${anon.model} / ${anon.tool})`);
|
|
357
|
+
}
|
|
358
|
+
const shouldLink = await promptYesNo("Link to your GitHub account? [Y/n] ");
|
|
359
|
+
if (shouldLink) {
|
|
360
|
+
const authedClient = new ApiClient(config.platformUrl, tokenRes.apiKey);
|
|
361
|
+
let linkedCount = 0;
|
|
362
|
+
const toRemove = [];
|
|
363
|
+
for (const anon of config.anonymousAgents) {
|
|
364
|
+
try {
|
|
365
|
+
await authedClient.post("/api/account/link", {
|
|
366
|
+
anonymousApiKey: anon.apiKey
|
|
367
|
+
});
|
|
368
|
+
toRemove.push(anon.agentId);
|
|
369
|
+
linkedCount++;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
console.error(
|
|
372
|
+
`Failed to link agent ${anon.agentId}:`,
|
|
373
|
+
err instanceof Error ? err.message : err
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const id of toRemove) {
|
|
378
|
+
removeAnonymousAgent(config, id);
|
|
379
|
+
}
|
|
380
|
+
saveConfig(config);
|
|
381
|
+
if (linkedCount > 0) {
|
|
382
|
+
console.log(`Linked ${linkedCount} agent(s) to your account.`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
console.error("\nDevice code expired. Please run `opencara login` again.");
|
|
390
|
+
process.exit(1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// src/commands/agent.ts
|
|
394
|
+
import { Command as Command2 } from "commander";
|
|
395
|
+
import WebSocket from "ws";
|
|
396
|
+
import crypto2 from "crypto";
|
|
397
|
+
|
|
398
|
+
// ../shared/dist/api.js
|
|
399
|
+
var DEFAULT_REGISTRY = {
|
|
400
|
+
tools: [
|
|
401
|
+
{
|
|
402
|
+
name: "claude",
|
|
403
|
+
displayName: "Claude",
|
|
404
|
+
binary: "claude",
|
|
405
|
+
commandTemplate: "claude --model ${MODEL} -p ${PROMPT} --output-format text",
|
|
406
|
+
tokenParser: "claude"
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "codex",
|
|
410
|
+
displayName: "Codex",
|
|
411
|
+
binary: "codex",
|
|
412
|
+
commandTemplate: "codex --model ${MODEL} -p ${PROMPT}",
|
|
413
|
+
tokenParser: "codex"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: "gemini",
|
|
417
|
+
displayName: "Gemini",
|
|
418
|
+
binary: "gemini",
|
|
419
|
+
commandTemplate: "gemini --model ${MODEL} -p ${PROMPT}",
|
|
420
|
+
tokenParser: "gemini"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: "qwen",
|
|
424
|
+
displayName: "Qwen",
|
|
425
|
+
binary: "qwen",
|
|
426
|
+
commandTemplate: "qwen --model ${MODEL} -p ${PROMPT} -y",
|
|
427
|
+
tokenParser: "qwen"
|
|
428
|
+
}
|
|
429
|
+
],
|
|
430
|
+
models: [
|
|
431
|
+
{ name: "claude-opus-4-6", displayName: "Claude Opus 4.6", tools: ["claude"] },
|
|
432
|
+
{
|
|
433
|
+
name: "claude-opus-4-6[1m]",
|
|
434
|
+
displayName: "Claude Opus 4.6 (1M context)",
|
|
435
|
+
tools: ["claude"]
|
|
436
|
+
},
|
|
437
|
+
{ name: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tools: ["claude"] },
|
|
438
|
+
{
|
|
439
|
+
name: "claude-sonnet-4-6[1m]",
|
|
440
|
+
displayName: "Claude Sonnet 4.6 (1M context)",
|
|
441
|
+
tools: ["claude"]
|
|
442
|
+
},
|
|
443
|
+
{ name: "gpt-5-codex", displayName: "GPT-5 Codex", tools: ["codex"] },
|
|
444
|
+
{ name: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", tools: ["gemini"] },
|
|
445
|
+
{ name: "qwen3.5-plus", displayName: "Qwen 3.5 Plus", tools: ["qwen"] },
|
|
446
|
+
{ name: "glm-5", displayName: "GLM-5", tools: ["qwen"] },
|
|
447
|
+
{ name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"] },
|
|
448
|
+
{ name: "minimax-m2.5", displayName: "Minimax M2.5", tools: ["qwen"] }
|
|
449
|
+
]
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// ../shared/dist/review-config.js
|
|
453
|
+
import { parse as parseYaml } from "yaml";
|
|
454
|
+
|
|
455
|
+
// src/tool-executor.ts
|
|
456
|
+
import { spawn, execFileSync } from "child_process";
|
|
457
|
+
import * as fs2 from "fs";
|
|
458
|
+
import * as path2 from "path";
|
|
459
|
+
var ToolTimeoutError = class extends Error {
|
|
460
|
+
constructor(message) {
|
|
461
|
+
super(message);
|
|
462
|
+
this.name = "ToolTimeoutError";
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
var MIN_PARTIAL_RESULT_LENGTH = 50;
|
|
466
|
+
var MAX_STDERR_LENGTH = 1e3;
|
|
467
|
+
function validateCommandBinary(commandTemplate) {
|
|
468
|
+
const { command } = parseCommandTemplate(commandTemplate);
|
|
469
|
+
if (path2.isAbsolute(command)) {
|
|
470
|
+
try {
|
|
471
|
+
fs2.accessSync(command, fs2.constants.X_OK);
|
|
472
|
+
return true;
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const isWindows = process.platform === "win32";
|
|
479
|
+
if (isWindows) {
|
|
480
|
+
execFileSync("where", [command], { stdio: "pipe" });
|
|
481
|
+
} else {
|
|
482
|
+
execFileSync("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function parseCommandTemplate(template, vars = {}) {
|
|
490
|
+
const parts = [];
|
|
491
|
+
let current = "";
|
|
492
|
+
let inSingle = false;
|
|
493
|
+
let inDouble = false;
|
|
494
|
+
for (let i = 0; i < template.length; i++) {
|
|
495
|
+
const ch = template[i];
|
|
496
|
+
if (ch === "'" && !inDouble) {
|
|
497
|
+
inSingle = !inSingle;
|
|
498
|
+
} else if (ch === '"' && !inSingle) {
|
|
499
|
+
inDouble = !inDouble;
|
|
500
|
+
} else if (/\s/.test(ch) && !inSingle && !inDouble) {
|
|
501
|
+
if (current.length > 0) {
|
|
502
|
+
parts.push(current);
|
|
503
|
+
current = "";
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
current += ch;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (current.length > 0) {
|
|
510
|
+
parts.push(current);
|
|
511
|
+
}
|
|
512
|
+
const interpolated = parts.map((part) => {
|
|
513
|
+
let result = part;
|
|
514
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
515
|
+
result = result.replaceAll(`\${${key}}`, value);
|
|
516
|
+
}
|
|
517
|
+
return result;
|
|
518
|
+
});
|
|
519
|
+
if (interpolated.length === 0) {
|
|
520
|
+
throw new Error("Empty command template");
|
|
521
|
+
}
|
|
522
|
+
return { command: interpolated[0], args: interpolated.slice(1) };
|
|
523
|
+
}
|
|
524
|
+
function resolveCommandTemplate(agentCommand2) {
|
|
525
|
+
if (agentCommand2) {
|
|
526
|
+
return agentCommand2;
|
|
527
|
+
}
|
|
528
|
+
throw new Error(
|
|
529
|
+
"No command configured for this agent. Set command in ~/.opencara/config.yml agents section or run `opencara agent create`."
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
var CHARS_PER_TOKEN = 4;
|
|
533
|
+
function estimateTokens(text) {
|
|
534
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
535
|
+
}
|
|
536
|
+
function parseClaudeTokens(text) {
|
|
537
|
+
const inputMatch = text.match(/"input_tokens"\s*:\s*(\d+)/);
|
|
538
|
+
const outputMatch = text.match(/"output_tokens"\s*:\s*(\d+)/);
|
|
539
|
+
if (inputMatch && outputMatch) {
|
|
540
|
+
return parseInt(inputMatch[1], 10) + parseInt(outputMatch[1], 10);
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
function parseTokenUsage(stdout, stderr) {
|
|
545
|
+
const codexMatch = stdout.match(/tokens\s+used[\s:]*([0-9,]+)/i);
|
|
546
|
+
if (codexMatch) return { tokens: parseInt(codexMatch[1].replace(/,/g, ""), 10), parsed: true };
|
|
547
|
+
const claudeTotal = parseClaudeTokens(stdout) ?? parseClaudeTokens(stderr);
|
|
548
|
+
if (claudeTotal !== null) return { tokens: claudeTotal, parsed: true };
|
|
549
|
+
const qwenMatch = stdout.match(/"tokens"\s*:\s*\{[^}]*"total"\s*:\s*(\d+)/);
|
|
550
|
+
if (qwenMatch) return { tokens: parseInt(qwenMatch[1], 10), parsed: true };
|
|
551
|
+
return { tokens: estimateTokens(stdout), parsed: false };
|
|
552
|
+
}
|
|
553
|
+
function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
|
|
554
|
+
const promptViaArg = commandTemplate.includes("${PROMPT}");
|
|
555
|
+
const allVars = { ...vars, PROMPT: prompt };
|
|
556
|
+
const { command, args } = parseCommandTemplate(commandTemplate, allVars);
|
|
557
|
+
return new Promise((resolve, reject) => {
|
|
558
|
+
if (signal?.aborted) {
|
|
559
|
+
reject(new ToolTimeoutError("Tool execution aborted"));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const child = spawn(command, args, {
|
|
563
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
564
|
+
});
|
|
565
|
+
let stdout = "";
|
|
566
|
+
let stderr = "";
|
|
567
|
+
let settled = false;
|
|
568
|
+
const timer = setTimeout(() => {
|
|
569
|
+
child.kill("SIGTERM");
|
|
570
|
+
}, timeoutMs);
|
|
571
|
+
child.stdout?.on("data", (chunk) => {
|
|
572
|
+
stdout += chunk.toString();
|
|
573
|
+
});
|
|
574
|
+
child.stderr?.on("data", (chunk) => {
|
|
575
|
+
stderr += chunk.toString();
|
|
576
|
+
});
|
|
577
|
+
if (!promptViaArg) {
|
|
578
|
+
child.stdin?.write(prompt);
|
|
579
|
+
}
|
|
580
|
+
child.stdin?.end();
|
|
581
|
+
let onAbort;
|
|
582
|
+
if (signal) {
|
|
583
|
+
onAbort = () => {
|
|
584
|
+
child.kill();
|
|
585
|
+
};
|
|
586
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
587
|
+
}
|
|
588
|
+
function cleanup() {
|
|
589
|
+
clearTimeout(timer);
|
|
590
|
+
if (onAbort && signal) {
|
|
591
|
+
signal.removeEventListener("abort", onAbort);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
child.on("error", (err) => {
|
|
595
|
+
cleanup();
|
|
596
|
+
if (settled) return;
|
|
597
|
+
settled = true;
|
|
598
|
+
if (signal?.aborted) {
|
|
599
|
+
reject(new ToolTimeoutError("Tool execution aborted"));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
reject(err);
|
|
603
|
+
});
|
|
604
|
+
child.on("close", (code, sig) => {
|
|
605
|
+
cleanup();
|
|
606
|
+
if (settled) return;
|
|
607
|
+
settled = true;
|
|
608
|
+
if (signal?.aborted) {
|
|
609
|
+
reject(new ToolTimeoutError("Tool execution aborted"));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (sig === "SIGTERM" || sig === "SIGKILL") {
|
|
613
|
+
reject(
|
|
614
|
+
new ToolTimeoutError(
|
|
615
|
+
`Tool "${command}" timed out after ${Math.round(timeoutMs / 1e3)}s`
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (code !== 0) {
|
|
621
|
+
if (stdout.length >= MIN_PARTIAL_RESULT_LENGTH) {
|
|
622
|
+
console.warn(
|
|
623
|
+
`Tool "${command}" exited with code ${code} but produced output. Treating as partial result.`
|
|
624
|
+
);
|
|
625
|
+
if (stderr) {
|
|
626
|
+
console.warn(`Tool stderr: ${stderr.slice(0, MAX_STDERR_LENGTH)}`);
|
|
627
|
+
}
|
|
628
|
+
const usage2 = parseTokenUsage(stdout, stderr);
|
|
629
|
+
resolve({ stdout, stderr, tokensUsed: usage2.tokens, tokensParsed: usage2.parsed });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const errMsg = stderr ? `Tool "${command}" failed (exit code ${code}): ${stderr.slice(0, MAX_STDERR_LENGTH)}` : `Tool "${command}" failed with exit code ${code}`;
|
|
633
|
+
reject(new Error(errMsg));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const usage = parseTokenUsage(stdout, stderr);
|
|
637
|
+
resolve({ stdout, stderr, tokensUsed: usage.tokens, tokensParsed: usage.parsed });
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/review.ts
|
|
643
|
+
var TIMEOUT_SAFETY_MARGIN_MS = 3e4;
|
|
644
|
+
var FULL_SYSTEM_PROMPT_TEMPLATE = `You are a code reviewer for the {owner}/{repo} repository.
|
|
645
|
+
Review the following pull request diff and provide a structured review.
|
|
646
|
+
|
|
647
|
+
Format your response as:
|
|
648
|
+
|
|
649
|
+
## Summary
|
|
650
|
+
[2-3 sentence overall assessment]
|
|
651
|
+
|
|
652
|
+
## Findings
|
|
653
|
+
List each finding on its own line:
|
|
654
|
+
- **[severity]** \`file:line\` \u2014 description
|
|
655
|
+
|
|
656
|
+
Severities: critical, major, minor, suggestion
|
|
657
|
+
Only include findings with specific file:line references from the diff.
|
|
658
|
+
If no issues found, write "No issues found."
|
|
659
|
+
|
|
660
|
+
## Verdict
|
|
661
|
+
APPROVE | REQUEST_CHANGES | COMMENT`;
|
|
662
|
+
var COMPACT_SYSTEM_PROMPT_TEMPLATE = `You are a code reviewer for the {owner}/{repo} repository.
|
|
663
|
+
Review the following pull request diff and return a compact, structured assessment.
|
|
664
|
+
|
|
665
|
+
Format your response as:
|
|
666
|
+
|
|
667
|
+
## Summary
|
|
668
|
+
[1-2 sentence assessment]
|
|
669
|
+
|
|
670
|
+
## Findings
|
|
671
|
+
- **[severity]** \`file:line\` \u2014 description
|
|
672
|
+
|
|
673
|
+
Severities: critical, major, minor, suggestion
|
|
674
|
+
|
|
675
|
+
## Verdict
|
|
676
|
+
APPROVE | REQUEST_CHANGES | COMMENT`;
|
|
677
|
+
function buildSystemPrompt(owner, repo, mode = "full") {
|
|
678
|
+
const template = mode === "compact" ? COMPACT_SYSTEM_PROMPT_TEMPLATE : FULL_SYSTEM_PROMPT_TEMPLATE;
|
|
679
|
+
return template.replace("{owner}", owner).replace("{repo}", repo);
|
|
680
|
+
}
|
|
681
|
+
function buildUserMessage(prompt, diffContent) {
|
|
682
|
+
return `${prompt}
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
${diffContent}`;
|
|
687
|
+
}
|
|
688
|
+
var SECTION_VERDICT_PATTERN = /##\s*Verdict\s*\n+\s*(APPROVE|REQUEST_CHANGES|COMMENT)\b/im;
|
|
689
|
+
var LEGACY_VERDICT_PATTERN = /^VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)\s*$/m;
|
|
690
|
+
function extractVerdict(text) {
|
|
691
|
+
const sectionMatch = SECTION_VERDICT_PATTERN.exec(text);
|
|
692
|
+
if (sectionMatch) {
|
|
693
|
+
const verdictStr = sectionMatch[1].toLowerCase();
|
|
694
|
+
const review = text.slice(0, sectionMatch.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
695
|
+
return { verdict: verdictStr, review };
|
|
696
|
+
}
|
|
697
|
+
const legacyMatch = LEGACY_VERDICT_PATTERN.exec(text);
|
|
698
|
+
if (legacyMatch) {
|
|
699
|
+
const verdictStr = legacyMatch[1].toLowerCase();
|
|
700
|
+
const before = text.slice(0, legacyMatch.index);
|
|
701
|
+
const after = text.slice(legacyMatch.index + legacyMatch[0].length);
|
|
702
|
+
const review = (before + after).replace(/\n{3,}/g, "\n\n").trim();
|
|
703
|
+
return { verdict: verdictStr, review };
|
|
704
|
+
}
|
|
705
|
+
return { verdict: "comment", review: text };
|
|
706
|
+
}
|
|
707
|
+
async function executeReview(req, deps, runTool = executeTool) {
|
|
708
|
+
const diffSizeKb = Buffer.byteLength(req.diffContent, "utf-8") / 1024;
|
|
709
|
+
if (diffSizeKb > deps.maxDiffSizeKb) {
|
|
710
|
+
throw new DiffTooLargeError(
|
|
711
|
+
`Diff too large (${Math.round(diffSizeKb)}KB > ${deps.maxDiffSizeKb}KB limit)`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
const timeoutMs = req.timeout * 1e3;
|
|
715
|
+
if (timeoutMs <= TIMEOUT_SAFETY_MARGIN_MS) {
|
|
716
|
+
throw new Error("Not enough time remaining to start review");
|
|
717
|
+
}
|
|
718
|
+
const effectiveTimeout = timeoutMs - TIMEOUT_SAFETY_MARGIN_MS;
|
|
719
|
+
const abortController = new AbortController();
|
|
720
|
+
const abortTimer = setTimeout(() => {
|
|
721
|
+
abortController.abort();
|
|
722
|
+
}, effectiveTimeout);
|
|
723
|
+
try {
|
|
724
|
+
const systemPrompt = buildSystemPrompt(req.owner, req.repo, req.reviewMode);
|
|
725
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
726
|
+
const fullPrompt = `${systemPrompt}
|
|
727
|
+
|
|
728
|
+
${userMessage}`;
|
|
729
|
+
const result = await runTool(
|
|
730
|
+
deps.commandTemplate,
|
|
731
|
+
fullPrompt,
|
|
732
|
+
effectiveTimeout,
|
|
733
|
+
abortController.signal
|
|
734
|
+
);
|
|
735
|
+
const { verdict, review } = extractVerdict(result.stdout);
|
|
736
|
+
const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
|
|
737
|
+
return {
|
|
738
|
+
review,
|
|
739
|
+
verdict,
|
|
740
|
+
tokensUsed: result.tokensUsed + inputTokens,
|
|
741
|
+
tokensEstimated: !result.tokensParsed
|
|
742
|
+
};
|
|
743
|
+
} finally {
|
|
744
|
+
clearTimeout(abortTimer);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
var DiffTooLargeError = class extends Error {
|
|
748
|
+
constructor(message) {
|
|
749
|
+
super(message);
|
|
750
|
+
this.name = "DiffTooLargeError";
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// src/summary.ts
|
|
755
|
+
var TIMEOUT_SAFETY_MARGIN_MS2 = 3e4;
|
|
756
|
+
var MAX_INPUT_SIZE_BYTES = 200 * 1024;
|
|
757
|
+
var InputTooLargeError = class extends Error {
|
|
758
|
+
constructor(message) {
|
|
759
|
+
super(message);
|
|
760
|
+
this.name = "InputTooLargeError";
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
function buildSummarySystemPrompt(owner, repo, reviewCount) {
|
|
764
|
+
return `You are a senior code reviewer and lead synthesizer for the ${owner}/${repo} repository.
|
|
765
|
+
|
|
766
|
+
You will receive a pull request diff and ${reviewCount} review${reviewCount !== 1 ? "s" : ""} from other agents.
|
|
767
|
+
|
|
768
|
+
Your job:
|
|
769
|
+
1. Perform your own thorough, independent code review of the diff
|
|
770
|
+
2. Incorporate and synthesize ALL findings from the other reviews into yours
|
|
771
|
+
3. Deduplicate overlapping findings but preserve every unique insight
|
|
772
|
+
4. Provide detailed explanations and actionable fix suggestions for each issue
|
|
773
|
+
5. Produce ONE comprehensive, detailed review
|
|
774
|
+
|
|
775
|
+
Format your response as:
|
|
776
|
+
|
|
777
|
+
## Summary
|
|
778
|
+
[Overall assessment of the PR: what it does, its quality, and key concerns \u2014 3-5 sentences]
|
|
779
|
+
|
|
780
|
+
## Findings
|
|
781
|
+
|
|
782
|
+
For each finding, provide a detailed entry:
|
|
783
|
+
|
|
784
|
+
### [severity] \`file:line\` \u2014 Short title
|
|
785
|
+
Detailed explanation of the issue, why it matters, and how to fix it.
|
|
786
|
+
Include code snippets showing the fix when helpful.
|
|
787
|
+
|
|
788
|
+
Severities: critical, major, minor, suggestion
|
|
789
|
+
Include ALL findings from ALL reviewers (deduplicated) plus your own discoveries.
|
|
790
|
+
For each finding, explain clearly what the problem is and how to fix it.
|
|
791
|
+
|
|
792
|
+
## Verdict
|
|
793
|
+
APPROVE | REQUEST_CHANGES | COMMENT`;
|
|
794
|
+
}
|
|
795
|
+
function buildSummaryUserMessage(prompt, reviews, diffContent) {
|
|
796
|
+
const reviewSections = reviews.map((r) => `### Review by ${r.model}/${r.tool} (Verdict: ${r.verdict})
|
|
797
|
+
${r.review}`).join("\n\n");
|
|
798
|
+
return `Project review guidelines:
|
|
799
|
+
${prompt}
|
|
800
|
+
|
|
801
|
+
---
|
|
802
|
+
|
|
803
|
+
Pull request diff:
|
|
804
|
+
|
|
805
|
+
${diffContent}
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
Compact reviews from other agents:
|
|
810
|
+
|
|
811
|
+
${reviewSections}`;
|
|
812
|
+
}
|
|
813
|
+
function calculateInputSize(prompt, reviews, diffContent) {
|
|
814
|
+
let size = Buffer.byteLength(prompt, "utf-8");
|
|
815
|
+
size += Buffer.byteLength(diffContent, "utf-8");
|
|
816
|
+
for (const r of reviews) {
|
|
817
|
+
size += Buffer.byteLength(r.review, "utf-8");
|
|
818
|
+
size += Buffer.byteLength(r.model, "utf-8");
|
|
819
|
+
size += Buffer.byteLength(r.tool, "utf-8");
|
|
820
|
+
size += Buffer.byteLength(r.verdict, "utf-8");
|
|
821
|
+
}
|
|
822
|
+
return size;
|
|
823
|
+
}
|
|
824
|
+
async function executeSummary(req, deps, runTool = executeTool) {
|
|
825
|
+
const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent);
|
|
826
|
+
if (inputSize > MAX_INPUT_SIZE_BYTES) {
|
|
827
|
+
throw new InputTooLargeError(
|
|
828
|
+
`Summary input too large (${Math.round(inputSize / 1024)}KB > ${Math.round(MAX_INPUT_SIZE_BYTES / 1024)}KB limit)`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const timeoutMs = req.timeout * 1e3;
|
|
832
|
+
if (timeoutMs <= TIMEOUT_SAFETY_MARGIN_MS2) {
|
|
833
|
+
throw new Error("Not enough time remaining to start summary");
|
|
834
|
+
}
|
|
835
|
+
const effectiveTimeout = timeoutMs - TIMEOUT_SAFETY_MARGIN_MS2;
|
|
836
|
+
const abortController = new AbortController();
|
|
837
|
+
const abortTimer = setTimeout(() => {
|
|
838
|
+
abortController.abort();
|
|
839
|
+
}, effectiveTimeout);
|
|
840
|
+
try {
|
|
841
|
+
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
842
|
+
const userMessage = buildSummaryUserMessage(req.prompt, req.reviews, req.diffContent);
|
|
843
|
+
const fullPrompt = `${systemPrompt}
|
|
844
|
+
|
|
845
|
+
${userMessage}`;
|
|
846
|
+
const result = await runTool(
|
|
847
|
+
deps.commandTemplate,
|
|
848
|
+
fullPrompt,
|
|
849
|
+
effectiveTimeout,
|
|
850
|
+
abortController.signal
|
|
851
|
+
);
|
|
852
|
+
const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
|
|
853
|
+
return {
|
|
854
|
+
summary: result.stdout,
|
|
855
|
+
tokensUsed: result.tokensUsed + inputTokens,
|
|
856
|
+
tokensEstimated: !result.tokensParsed
|
|
857
|
+
};
|
|
858
|
+
} finally {
|
|
859
|
+
clearTimeout(abortTimer);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/consumption.ts
|
|
864
|
+
async function checkConsumptionLimits(_agentId, _limits) {
|
|
865
|
+
return { allowed: true };
|
|
866
|
+
}
|
|
867
|
+
function createSessionTracker() {
|
|
868
|
+
return { tokens: 0, reviews: 0 };
|
|
869
|
+
}
|
|
870
|
+
function recordSessionUsage(session, tokensUsed) {
|
|
871
|
+
session.tokens += tokensUsed;
|
|
872
|
+
session.reviews += 1;
|
|
873
|
+
}
|
|
874
|
+
function formatPostReviewStats(_tokensUsed, session, _limits, _dailyStats) {
|
|
875
|
+
return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/commands/agent.ts
|
|
879
|
+
var CONNECTION_STABILITY_THRESHOLD_MS = 3e4;
|
|
880
|
+
function formatTable(agents, trustLabels) {
|
|
881
|
+
if (agents.length === 0) {
|
|
882
|
+
console.log("No agents registered. Run `opencara agent create` to register one.");
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const header = [
|
|
886
|
+
"ID".padEnd(38),
|
|
887
|
+
"Model".padEnd(22),
|
|
888
|
+
"Tool".padEnd(16),
|
|
889
|
+
"Status".padEnd(10),
|
|
890
|
+
"Trust"
|
|
891
|
+
].join("");
|
|
892
|
+
console.log(header);
|
|
893
|
+
for (const a of agents) {
|
|
894
|
+
const trust = trustLabels?.get(a.id) ?? "--";
|
|
895
|
+
console.log(
|
|
896
|
+
[a.id.padEnd(38), a.model.padEnd(22), a.tool.padEnd(16), a.status.padEnd(10), trust].join("")
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function buildWsUrl(platformUrl, agentId, apiKey) {
|
|
901
|
+
return platformUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + `/ws/agent/${agentId}?token=${encodeURIComponent(apiKey)}`;
|
|
902
|
+
}
|
|
903
|
+
var HEARTBEAT_TIMEOUT_MS = 9e4;
|
|
904
|
+
var STABILITY_THRESHOLD_MIN_MS = 5e3;
|
|
905
|
+
var STABILITY_THRESHOLD_MAX_MS = 3e5;
|
|
906
|
+
var WS_PING_INTERVAL_MS = 2e4;
|
|
907
|
+
function startAgent(agentId, platformUrl, apiKey, reviewDeps, consumptionDeps, options) {
|
|
908
|
+
const verbose = options?.verbose ?? false;
|
|
909
|
+
const stabilityThreshold = options?.stabilityThresholdMs ?? CONNECTION_STABILITY_THRESHOLD_MS;
|
|
910
|
+
const repoConfig = options?.repoConfig;
|
|
911
|
+
let attempt = 0;
|
|
912
|
+
let intentionalClose = false;
|
|
913
|
+
let heartbeatTimer = null;
|
|
914
|
+
let wsPingTimer = null;
|
|
915
|
+
let currentWs = null;
|
|
916
|
+
let connectionOpenedAt = null;
|
|
917
|
+
let stabilityTimer = null;
|
|
918
|
+
function clearHeartbeatTimer() {
|
|
919
|
+
if (heartbeatTimer) {
|
|
920
|
+
clearTimeout(heartbeatTimer);
|
|
921
|
+
heartbeatTimer = null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function clearStabilityTimer() {
|
|
925
|
+
if (stabilityTimer) {
|
|
926
|
+
clearTimeout(stabilityTimer);
|
|
927
|
+
stabilityTimer = null;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function clearWsPingTimer() {
|
|
931
|
+
if (wsPingTimer) {
|
|
932
|
+
clearInterval(wsPingTimer);
|
|
933
|
+
wsPingTimer = null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function shutdown() {
|
|
937
|
+
intentionalClose = true;
|
|
938
|
+
clearHeartbeatTimer();
|
|
939
|
+
clearStabilityTimer();
|
|
940
|
+
clearWsPingTimer();
|
|
941
|
+
if (currentWs) currentWs.close();
|
|
942
|
+
console.log("Disconnected.");
|
|
943
|
+
process.exit(0);
|
|
944
|
+
}
|
|
945
|
+
process.once("SIGINT", shutdown);
|
|
946
|
+
process.once("SIGTERM", shutdown);
|
|
947
|
+
function connect() {
|
|
948
|
+
const url = buildWsUrl(platformUrl, agentId, apiKey);
|
|
949
|
+
const ws = new WebSocket(url);
|
|
950
|
+
currentWs = ws;
|
|
951
|
+
function resetHeartbeatTimer() {
|
|
952
|
+
clearHeartbeatTimer();
|
|
953
|
+
heartbeatTimer = setTimeout(() => {
|
|
954
|
+
console.log("No heartbeat received in 90s. Reconnecting...");
|
|
955
|
+
ws.terminate();
|
|
956
|
+
}, HEARTBEAT_TIMEOUT_MS);
|
|
957
|
+
}
|
|
958
|
+
ws.on("open", () => {
|
|
959
|
+
connectionOpenedAt = Date.now();
|
|
960
|
+
console.log("Connected to platform.");
|
|
961
|
+
resetHeartbeatTimer();
|
|
962
|
+
clearWsPingTimer();
|
|
963
|
+
wsPingTimer = setInterval(() => {
|
|
964
|
+
try {
|
|
965
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
966
|
+
ws.ping();
|
|
967
|
+
}
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
}, WS_PING_INTERVAL_MS);
|
|
971
|
+
if (verbose) {
|
|
972
|
+
console.log(`[verbose] Connection opened at ${new Date(connectionOpenedAt).toISOString()}`);
|
|
973
|
+
}
|
|
974
|
+
clearStabilityTimer();
|
|
975
|
+
stabilityTimer = setTimeout(() => {
|
|
976
|
+
if (verbose) {
|
|
977
|
+
console.log(
|
|
978
|
+
`[verbose] Connection stable for ${stabilityThreshold / 1e3}s \u2014 resetting reconnect counter`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
attempt = 0;
|
|
982
|
+
}, stabilityThreshold);
|
|
983
|
+
});
|
|
984
|
+
ws.on("message", (data) => {
|
|
985
|
+
let msg;
|
|
986
|
+
try {
|
|
987
|
+
msg = JSON.parse(data.toString());
|
|
988
|
+
} catch {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
handleMessage(ws, msg, resetHeartbeatTimer, reviewDeps, consumptionDeps, verbose, repoConfig);
|
|
992
|
+
});
|
|
993
|
+
ws.on("close", (code, reason) => {
|
|
994
|
+
if (intentionalClose) return;
|
|
995
|
+
if (ws !== currentWs) return;
|
|
996
|
+
clearHeartbeatTimer();
|
|
997
|
+
clearStabilityTimer();
|
|
998
|
+
clearWsPingTimer();
|
|
999
|
+
if (connectionOpenedAt) {
|
|
1000
|
+
const lifetimeMs = Date.now() - connectionOpenedAt;
|
|
1001
|
+
const lifetimeSec = (lifetimeMs / 1e3).toFixed(1);
|
|
1002
|
+
console.log(
|
|
1003
|
+
`Disconnected (code=${code}, reason=${reason.toString()}). Connection was alive for ${lifetimeSec}s.`
|
|
1004
|
+
);
|
|
1005
|
+
} else {
|
|
1006
|
+
console.log(`Disconnected (code=${code}, reason=${reason.toString()}).`);
|
|
1007
|
+
}
|
|
1008
|
+
if (code === 4002) {
|
|
1009
|
+
console.log("Connection replaced by server \u2014 not reconnecting.");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
connectionOpenedAt = null;
|
|
1013
|
+
reconnect();
|
|
1014
|
+
});
|
|
1015
|
+
ws.on("pong", () => {
|
|
1016
|
+
if (verbose) {
|
|
1017
|
+
console.log(`[verbose] WS pong received at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
ws.on("error", (err) => {
|
|
1021
|
+
console.error(`WebSocket error: ${err.message}`);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
async function reconnect() {
|
|
1025
|
+
const delay = calculateDelay(attempt, DEFAULT_RECONNECT_OPTIONS);
|
|
1026
|
+
const delaySec = (delay / 1e3).toFixed(1);
|
|
1027
|
+
attempt++;
|
|
1028
|
+
console.log(`Reconnecting in ${delaySec}s... (attempt ${attempt})`);
|
|
1029
|
+
await sleep(delay);
|
|
1030
|
+
connect();
|
|
1031
|
+
}
|
|
1032
|
+
connect();
|
|
1033
|
+
}
|
|
1034
|
+
function trySend(ws, data) {
|
|
1035
|
+
try {
|
|
1036
|
+
ws.send(JSON.stringify(data));
|
|
1037
|
+
} catch {
|
|
1038
|
+
console.error("Failed to send message \u2014 WebSocket may be closed");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function logPostReviewStats(type, verdict, tokensUsed, tokensEstimated, consumptionDeps) {
|
|
1042
|
+
const estimateTag = tokensEstimated ? " ~" : " ";
|
|
1043
|
+
if (!consumptionDeps) {
|
|
1044
|
+
if (verdict) {
|
|
1045
|
+
console.log(
|
|
1046
|
+
`${type} complete: ${verdict} (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
|
|
1047
|
+
);
|
|
1048
|
+
} else {
|
|
1049
|
+
console.log(
|
|
1050
|
+
`${type} complete (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1056
|
+
if (verdict) {
|
|
1057
|
+
console.log(
|
|
1058
|
+
`${type} complete: ${verdict} (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
|
|
1059
|
+
);
|
|
1060
|
+
} else {
|
|
1061
|
+
console.log(
|
|
1062
|
+
`${type} complete (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
console.log(formatPostReviewStats(tokensUsed, consumptionDeps.session, consumptionDeps.limits));
|
|
1066
|
+
}
|
|
1067
|
+
function handleMessage(ws, msg, resetHeartbeat, reviewDeps, consumptionDeps, verbose, repoConfig) {
|
|
1068
|
+
switch (msg.type) {
|
|
1069
|
+
case "connected":
|
|
1070
|
+
console.log(`Authenticated. Protocol v${msg.version ?? "unknown"}`);
|
|
1071
|
+
trySend(ws, {
|
|
1072
|
+
type: "agent_preferences",
|
|
1073
|
+
id: crypto2.randomUUID(),
|
|
1074
|
+
timestamp: Date.now(),
|
|
1075
|
+
repoConfig: repoConfig ?? { mode: "all" }
|
|
1076
|
+
});
|
|
1077
|
+
break;
|
|
1078
|
+
case "heartbeat_ping":
|
|
1079
|
+
ws.send(JSON.stringify({ type: "heartbeat_pong", timestamp: Date.now() }));
|
|
1080
|
+
if (verbose) {
|
|
1081
|
+
console.log(`[verbose] Heartbeat ping received, pong sent at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (resetHeartbeat) resetHeartbeat();
|
|
1084
|
+
break;
|
|
1085
|
+
case "review_request": {
|
|
1086
|
+
const request = msg;
|
|
1087
|
+
console.log(
|
|
1088
|
+
`Review request: task ${request.taskId} for ${request.project.owner}/${request.project.repo}#${request.pr.number}`
|
|
1089
|
+
);
|
|
1090
|
+
if (!reviewDeps) {
|
|
1091
|
+
ws.send(
|
|
1092
|
+
JSON.stringify({
|
|
1093
|
+
type: "review_rejected",
|
|
1094
|
+
id: crypto2.randomUUID(),
|
|
1095
|
+
timestamp: Date.now(),
|
|
1096
|
+
taskId: request.taskId,
|
|
1097
|
+
reason: "Review execution not configured"
|
|
1098
|
+
})
|
|
1099
|
+
);
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
void (async () => {
|
|
1103
|
+
if (consumptionDeps) {
|
|
1104
|
+
const limitResult = await checkConsumptionLimits(
|
|
1105
|
+
consumptionDeps.agentId,
|
|
1106
|
+
consumptionDeps.limits
|
|
1107
|
+
);
|
|
1108
|
+
if (!limitResult.allowed) {
|
|
1109
|
+
trySend(ws, {
|
|
1110
|
+
type: "review_rejected",
|
|
1111
|
+
id: crypto2.randomUUID(),
|
|
1112
|
+
timestamp: Date.now(),
|
|
1113
|
+
taskId: request.taskId,
|
|
1114
|
+
reason: limitResult.reason ?? "consumption_limit_exceeded"
|
|
1115
|
+
});
|
|
1116
|
+
console.log(`Review rejected: ${limitResult.reason}`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
const result = await executeReview(
|
|
1122
|
+
{
|
|
1123
|
+
taskId: request.taskId,
|
|
1124
|
+
diffContent: request.diffContent,
|
|
1125
|
+
prompt: request.project.prompt,
|
|
1126
|
+
owner: request.project.owner,
|
|
1127
|
+
repo: request.project.repo,
|
|
1128
|
+
prNumber: request.pr.number,
|
|
1129
|
+
timeout: request.timeout,
|
|
1130
|
+
reviewMode: request.reviewMode ?? "full"
|
|
1131
|
+
},
|
|
1132
|
+
reviewDeps
|
|
1133
|
+
);
|
|
1134
|
+
trySend(ws, {
|
|
1135
|
+
type: "review_complete",
|
|
1136
|
+
id: crypto2.randomUUID(),
|
|
1137
|
+
timestamp: Date.now(),
|
|
1138
|
+
taskId: request.taskId,
|
|
1139
|
+
review: result.review,
|
|
1140
|
+
verdict: result.verdict,
|
|
1141
|
+
tokensUsed: result.tokensUsed
|
|
1142
|
+
});
|
|
1143
|
+
await logPostReviewStats(
|
|
1144
|
+
"Review",
|
|
1145
|
+
result.verdict,
|
|
1146
|
+
result.tokensUsed,
|
|
1147
|
+
result.tokensEstimated,
|
|
1148
|
+
consumptionDeps
|
|
1149
|
+
);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
if (err instanceof DiffTooLargeError) {
|
|
1152
|
+
trySend(ws, {
|
|
1153
|
+
type: "review_rejected",
|
|
1154
|
+
id: crypto2.randomUUID(),
|
|
1155
|
+
timestamp: Date.now(),
|
|
1156
|
+
taskId: request.taskId,
|
|
1157
|
+
reason: err.message
|
|
1158
|
+
});
|
|
1159
|
+
} else {
|
|
1160
|
+
trySend(ws, {
|
|
1161
|
+
type: "review_error",
|
|
1162
|
+
id: crypto2.randomUUID(),
|
|
1163
|
+
timestamp: Date.now(),
|
|
1164
|
+
taskId: request.taskId,
|
|
1165
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
console.error("Review failed:", err);
|
|
1169
|
+
}
|
|
1170
|
+
})();
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
case "summary_request": {
|
|
1174
|
+
const summaryRequest = msg;
|
|
1175
|
+
console.log(
|
|
1176
|
+
`Summary request: task ${summaryRequest.taskId} for ${summaryRequest.project.owner}/${summaryRequest.project.repo}#${summaryRequest.pr.number} (${summaryRequest.reviews.length} reviews)`
|
|
1177
|
+
);
|
|
1178
|
+
if (!reviewDeps) {
|
|
1179
|
+
trySend(ws, {
|
|
1180
|
+
type: "review_rejected",
|
|
1181
|
+
id: crypto2.randomUUID(),
|
|
1182
|
+
timestamp: Date.now(),
|
|
1183
|
+
taskId: summaryRequest.taskId,
|
|
1184
|
+
reason: "Review tool not configured"
|
|
1185
|
+
});
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
void (async () => {
|
|
1189
|
+
if (consumptionDeps) {
|
|
1190
|
+
const limitResult = await checkConsumptionLimits(
|
|
1191
|
+
consumptionDeps.agentId,
|
|
1192
|
+
consumptionDeps.limits
|
|
1193
|
+
);
|
|
1194
|
+
if (!limitResult.allowed) {
|
|
1195
|
+
trySend(ws, {
|
|
1196
|
+
type: "review_rejected",
|
|
1197
|
+
id: crypto2.randomUUID(),
|
|
1198
|
+
timestamp: Date.now(),
|
|
1199
|
+
taskId: summaryRequest.taskId,
|
|
1200
|
+
reason: limitResult.reason ?? "consumption_limit_exceeded"
|
|
1201
|
+
});
|
|
1202
|
+
console.log(`Summary rejected: ${limitResult.reason}`);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
const result = await executeSummary(
|
|
1208
|
+
{
|
|
1209
|
+
taskId: summaryRequest.taskId,
|
|
1210
|
+
reviews: summaryRequest.reviews,
|
|
1211
|
+
prompt: summaryRequest.project.prompt,
|
|
1212
|
+
owner: summaryRequest.project.owner,
|
|
1213
|
+
repo: summaryRequest.project.repo,
|
|
1214
|
+
prNumber: summaryRequest.pr.number,
|
|
1215
|
+
timeout: summaryRequest.timeout,
|
|
1216
|
+
diffContent: summaryRequest.diffContent ?? ""
|
|
1217
|
+
},
|
|
1218
|
+
reviewDeps
|
|
1219
|
+
);
|
|
1220
|
+
trySend(ws, {
|
|
1221
|
+
type: "summary_complete",
|
|
1222
|
+
id: crypto2.randomUUID(),
|
|
1223
|
+
timestamp: Date.now(),
|
|
1224
|
+
taskId: summaryRequest.taskId,
|
|
1225
|
+
summary: result.summary,
|
|
1226
|
+
tokensUsed: result.tokensUsed
|
|
1227
|
+
});
|
|
1228
|
+
await logPostReviewStats(
|
|
1229
|
+
"Summary",
|
|
1230
|
+
void 0,
|
|
1231
|
+
result.tokensUsed,
|
|
1232
|
+
result.tokensEstimated,
|
|
1233
|
+
consumptionDeps
|
|
1234
|
+
);
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (err instanceof InputTooLargeError) {
|
|
1237
|
+
trySend(ws, {
|
|
1238
|
+
type: "review_rejected",
|
|
1239
|
+
id: crypto2.randomUUID(),
|
|
1240
|
+
timestamp: Date.now(),
|
|
1241
|
+
taskId: summaryRequest.taskId,
|
|
1242
|
+
reason: err.message
|
|
1243
|
+
});
|
|
1244
|
+
} else {
|
|
1245
|
+
trySend(ws, {
|
|
1246
|
+
type: "review_error",
|
|
1247
|
+
id: crypto2.randomUUID(),
|
|
1248
|
+
timestamp: Date.now(),
|
|
1249
|
+
taskId: summaryRequest.taskId,
|
|
1250
|
+
error: err instanceof Error ? err.message : "Summary failed"
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
console.error("Summary failed:", err);
|
|
1254
|
+
}
|
|
1255
|
+
})();
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
case "error":
|
|
1259
|
+
console.error(`Platform error: ${msg.code ?? "unknown"}`);
|
|
1260
|
+
if (msg.code === "auth_revoked") process.exit(1);
|
|
1261
|
+
break;
|
|
1262
|
+
default:
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
async function syncAgentToServer(client, serverAgents, localAgent) {
|
|
1267
|
+
const existing = serverAgents.find(
|
|
1268
|
+
(a) => a.model === localAgent.model && a.tool === localAgent.tool
|
|
1269
|
+
);
|
|
1270
|
+
if (existing) {
|
|
1271
|
+
return { agentId: existing.id, created: false };
|
|
1272
|
+
}
|
|
1273
|
+
const body = { model: localAgent.model, tool: localAgent.tool };
|
|
1274
|
+
if (localAgent.repos) {
|
|
1275
|
+
body.repoConfig = localAgent.repos;
|
|
1276
|
+
}
|
|
1277
|
+
const created = await client.post("/api/agents", body);
|
|
1278
|
+
return { agentId: created.id, created: true };
|
|
1279
|
+
}
|
|
1280
|
+
function resolveLocalAgentCommand(localAgent, globalAgentCommand) {
|
|
1281
|
+
const effectiveCommand = localAgent.command ?? globalAgentCommand;
|
|
1282
|
+
return resolveCommandTemplate(effectiveCommand);
|
|
1283
|
+
}
|
|
1284
|
+
var agentCommand = new Command2("agent").description("Manage review agents");
|
|
1285
|
+
agentCommand.command("create").description("Add an agent to local config (interactive or via flags)").option("--model <model>", "AI model name (e.g., claude-opus-4-6)").option("--tool <tool>", "Review tool name (e.g., claude-code)").option("--command <cmd>", "Custom command template (bypasses registry lookup)").action(async (opts) => {
|
|
1286
|
+
const config = loadConfig();
|
|
1287
|
+
requireApiKey(config);
|
|
1288
|
+
let model;
|
|
1289
|
+
let tool;
|
|
1290
|
+
let command = opts.command;
|
|
1291
|
+
if (opts.model && opts.tool) {
|
|
1292
|
+
model = opts.model;
|
|
1293
|
+
tool = opts.tool;
|
|
1294
|
+
} else if (opts.model || opts.tool) {
|
|
1295
|
+
console.error("Both --model and --tool are required in non-interactive mode.");
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
} else {
|
|
1298
|
+
const client = new ApiClient(config.platformUrl, config.apiKey);
|
|
1299
|
+
let registry;
|
|
1300
|
+
try {
|
|
1301
|
+
registry = await client.get("/api/registry");
|
|
1302
|
+
} catch {
|
|
1303
|
+
console.warn("Could not fetch registry from server. Using built-in defaults.");
|
|
1304
|
+
registry = DEFAULT_REGISTRY;
|
|
1305
|
+
}
|
|
1306
|
+
const { search, input } = await import("@inquirer/prompts");
|
|
1307
|
+
const searchTheme = {
|
|
1308
|
+
style: {
|
|
1309
|
+
keysHelpTip: (keys) => keys.map(([key, action]) => `${key} ${action}`).join(", ") + ", ^C exit"
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
const existingAgents = config.agents ?? [];
|
|
1313
|
+
const toolChoices = registry.tools.map((t) => ({
|
|
1314
|
+
name: t.displayName,
|
|
1315
|
+
value: t.name
|
|
1316
|
+
}));
|
|
1317
|
+
try {
|
|
1318
|
+
while (true) {
|
|
1319
|
+
tool = await search({
|
|
1320
|
+
message: "Select a tool:",
|
|
1321
|
+
theme: searchTheme,
|
|
1322
|
+
source: (term) => {
|
|
1323
|
+
const q = (term ?? "").toLowerCase();
|
|
1324
|
+
return toolChoices.filter(
|
|
1325
|
+
(c) => c.name.toLowerCase().includes(q) || c.value.toLowerCase().includes(q)
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
const compatible = registry.models.filter((m) => m.tools.includes(tool));
|
|
1330
|
+
const incompatible = registry.models.filter((m) => !m.tools.includes(tool));
|
|
1331
|
+
const modelChoices = [
|
|
1332
|
+
...compatible.map((m) => ({
|
|
1333
|
+
name: m.displayName,
|
|
1334
|
+
value: m.name
|
|
1335
|
+
})),
|
|
1336
|
+
...incompatible.map((m) => ({
|
|
1337
|
+
name: `\x1B[38;5;249m${m.displayName}\x1B[0m`,
|
|
1338
|
+
value: m.name
|
|
1339
|
+
}))
|
|
1340
|
+
];
|
|
1341
|
+
model = await search({
|
|
1342
|
+
message: "Select a model:",
|
|
1343
|
+
theme: searchTheme,
|
|
1344
|
+
source: (term) => {
|
|
1345
|
+
const q = (term ?? "").toLowerCase();
|
|
1346
|
+
return modelChoices.filter(
|
|
1347
|
+
(c) => c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
const isDup = existingAgents.some((a) => a.model === model && a.tool === tool);
|
|
1352
|
+
if (isDup) {
|
|
1353
|
+
console.warn(`"${model}" / "${tool}" already exists in config. Choose again.`);
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
const modelEntry = registry.models.find((m) => m.name === model);
|
|
1357
|
+
if (modelEntry && !modelEntry.tools.includes(tool)) {
|
|
1358
|
+
console.warn(`Warning: "${model}" is not listed as compatible with "${tool}".`);
|
|
1359
|
+
}
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
const toolEntry = registry.tools.find((t) => t.name === tool);
|
|
1363
|
+
const defaultCommand = toolEntry ? toolEntry.commandTemplate.replaceAll("${MODEL}", model) : `${tool} --model ${model} -p \${PROMPT}`;
|
|
1364
|
+
command = await input({
|
|
1365
|
+
message: "Command:",
|
|
1366
|
+
default: defaultCommand,
|
|
1367
|
+
prefill: "editable"
|
|
1368
|
+
});
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
|
|
1371
|
+
console.log("Cancelled.");
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
throw err;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (!command) {
|
|
1378
|
+
const toolEntry = DEFAULT_REGISTRY.tools.find((t) => t.name === tool);
|
|
1379
|
+
if (toolEntry) {
|
|
1380
|
+
command = toolEntry.commandTemplate.replaceAll("${MODEL}", model);
|
|
1381
|
+
} else {
|
|
1382
|
+
console.error(`No command template for tool "${tool}". Use --command to specify one.`);
|
|
1383
|
+
process.exit(1);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (validateCommandBinary(command)) {
|
|
1387
|
+
console.log(`Verifying... binary found.`);
|
|
1388
|
+
} else {
|
|
1389
|
+
console.warn(
|
|
1390
|
+
`Warning: binary for command "${command.split(" ")[0]}" not found on this machine.`
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
const newAgent = { model, tool, command };
|
|
1394
|
+
if (config.agents === null) {
|
|
1395
|
+
config.agents = [];
|
|
1396
|
+
}
|
|
1397
|
+
const isDuplicate = config.agents.some((a) => a.model === model && a.tool === tool);
|
|
1398
|
+
if (isDuplicate) {
|
|
1399
|
+
console.error(`Agent with model "${model}" and tool "${tool}" already exists in config.`);
|
|
1400
|
+
process.exit(1);
|
|
1401
|
+
}
|
|
1402
|
+
config.agents.push(newAgent);
|
|
1403
|
+
saveConfig(config);
|
|
1404
|
+
console.log("Agent added to config:");
|
|
1405
|
+
console.log(` Model: ${model}`);
|
|
1406
|
+
console.log(` Tool: ${tool}`);
|
|
1407
|
+
console.log(` Command: ${command}`);
|
|
1408
|
+
});
|
|
1409
|
+
agentCommand.command("init").description("Import server-side agents into local config").action(async () => {
|
|
1410
|
+
const config = loadConfig();
|
|
1411
|
+
const apiKey = requireApiKey(config);
|
|
1412
|
+
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1413
|
+
let res;
|
|
1414
|
+
try {
|
|
1415
|
+
res = await client.get("/api/agents");
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
if (res.agents.length === 0) {
|
|
1421
|
+
console.log("No server-side agents found. Use `opencara agent create` to add one.");
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
let registry;
|
|
1425
|
+
try {
|
|
1426
|
+
registry = await client.get("/api/registry");
|
|
1427
|
+
} catch {
|
|
1428
|
+
registry = DEFAULT_REGISTRY;
|
|
1429
|
+
}
|
|
1430
|
+
const toolCommands = new Map(registry.tools.map((t) => [t.name, t.commandTemplate]));
|
|
1431
|
+
const existing = config.agents ?? [];
|
|
1432
|
+
let imported = 0;
|
|
1433
|
+
for (const agent of res.agents) {
|
|
1434
|
+
const isDuplicate = existing.some((e) => e.model === agent.model && e.tool === agent.tool);
|
|
1435
|
+
if (isDuplicate) continue;
|
|
1436
|
+
let command = toolCommands.get(agent.tool);
|
|
1437
|
+
if (command) {
|
|
1438
|
+
command = command.replaceAll("${MODEL}", agent.model);
|
|
1439
|
+
} else {
|
|
1440
|
+
console.warn(
|
|
1441
|
+
`Warning: no command template for ${agent.model}/${agent.tool} \u2014 set command manually in config`
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
existing.push({ model: agent.model, tool: agent.tool, command });
|
|
1445
|
+
imported++;
|
|
1446
|
+
}
|
|
1447
|
+
config.agents = existing;
|
|
1448
|
+
saveConfig(config);
|
|
1449
|
+
console.log(`Imported ${imported} agent(s) to local config.`);
|
|
1450
|
+
if (imported > 0) {
|
|
1451
|
+
console.log("Edit ~/.opencara/config.yml to adjust commands for your system.");
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
agentCommand.command("list").description("List registered agents").action(async () => {
|
|
1455
|
+
const config = loadConfig();
|
|
1456
|
+
const apiKey = requireApiKey(config);
|
|
1457
|
+
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1458
|
+
let res;
|
|
1459
|
+
try {
|
|
1460
|
+
res = await client.get("/api/agents");
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1463
|
+
process.exit(1);
|
|
1464
|
+
}
|
|
1465
|
+
const trustLabels = /* @__PURE__ */ new Map();
|
|
1466
|
+
for (const agent of res.agents) {
|
|
1467
|
+
try {
|
|
1468
|
+
const stats = await client.get(`/api/stats/${agent.id}`);
|
|
1469
|
+
trustLabels.set(agent.id, stats.agent.trustTier.label);
|
|
1470
|
+
} catch {
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
formatTable(res.agents, trustLabels);
|
|
1474
|
+
});
|
|
1475
|
+
async function resolveAnonymousAgent(config, model, tool) {
|
|
1476
|
+
const existing = config.anonymousAgents.find((a) => a.model === model && a.tool === tool);
|
|
1477
|
+
if (existing) {
|
|
1478
|
+
console.log(`Reusing stored anonymous agent ${existing.agentId} (${model} / ${tool})`);
|
|
1479
|
+
const command2 = resolveCommandTemplate(
|
|
1480
|
+
DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
|
|
1481
|
+
);
|
|
1482
|
+
return { entry: existing, command: command2 };
|
|
1483
|
+
}
|
|
1484
|
+
console.log("Registering anonymous agent...");
|
|
1485
|
+
const client = new ApiClient(config.platformUrl);
|
|
1486
|
+
const body = { model, tool };
|
|
1487
|
+
const res = await client.post("/api/agents/anonymous", body);
|
|
1488
|
+
const entry = {
|
|
1489
|
+
agentId: res.agentId,
|
|
1490
|
+
apiKey: res.apiKey,
|
|
1491
|
+
model,
|
|
1492
|
+
tool
|
|
1493
|
+
};
|
|
1494
|
+
config.anonymousAgents.push(entry);
|
|
1495
|
+
saveConfig(config);
|
|
1496
|
+
console.log(`Agent registered: ${res.agentId} (${model} / ${tool})`);
|
|
1497
|
+
console.log("Credentials saved to ~/.opencara/config.yml");
|
|
1498
|
+
const command = resolveCommandTemplate(
|
|
1499
|
+
DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
|
|
1500
|
+
);
|
|
1501
|
+
return { entry, command };
|
|
1502
|
+
}
|
|
1503
|
+
agentCommand.command("start [agentIdOrModel]").description("Connect agent to platform via WebSocket").option("--all", "Start all agents from local config concurrently").option("-a, --anonymous", "Start an anonymous agent (no login required)").option("--model <model>", "AI model name (used with --anonymous)").option("--tool <tool>", "Review tool name (used with --anonymous)").option("--verbose", "Enable detailed WebSocket diagnostic logging").option(
|
|
1504
|
+
"--stability-threshold <ms>",
|
|
1505
|
+
`Connection stability threshold in ms (${STABILITY_THRESHOLD_MIN_MS}\u2013${STABILITY_THRESHOLD_MAX_MS}, default: ${CONNECTION_STABILITY_THRESHOLD_MS})`
|
|
1506
|
+
).action(
|
|
1507
|
+
async (agentIdOrModel, opts) => {
|
|
1508
|
+
let stabilityThresholdMs;
|
|
1509
|
+
if (opts.stabilityThreshold !== void 0) {
|
|
1510
|
+
const val = Number(opts.stabilityThreshold);
|
|
1511
|
+
if (!Number.isInteger(val) || val < STABILITY_THRESHOLD_MIN_MS || val > STABILITY_THRESHOLD_MAX_MS) {
|
|
1512
|
+
console.error(
|
|
1513
|
+
`Invalid --stability-threshold: must be an integer between ${STABILITY_THRESHOLD_MIN_MS} and ${STABILITY_THRESHOLD_MAX_MS}`
|
|
1514
|
+
);
|
|
1515
|
+
process.exit(1);
|
|
1516
|
+
}
|
|
1517
|
+
stabilityThresholdMs = val;
|
|
1518
|
+
}
|
|
1519
|
+
const config = loadConfig();
|
|
1520
|
+
if (opts.anonymous) {
|
|
1521
|
+
if (!opts.model || !opts.tool) {
|
|
1522
|
+
console.error("Both --model and --tool are required with --anonymous.");
|
|
1523
|
+
process.exit(1);
|
|
1524
|
+
}
|
|
1525
|
+
let resolved;
|
|
1526
|
+
try {
|
|
1527
|
+
resolved = await resolveAnonymousAgent(config, opts.model, opts.tool);
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
console.error(
|
|
1530
|
+
"Failed to register anonymous agent:",
|
|
1531
|
+
err instanceof Error ? err.message : err
|
|
1532
|
+
);
|
|
1533
|
+
process.exit(1);
|
|
1534
|
+
}
|
|
1535
|
+
const { entry, command } = resolved;
|
|
1536
|
+
let reviewDeps2;
|
|
1537
|
+
if (validateCommandBinary(command)) {
|
|
1538
|
+
reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
|
|
1539
|
+
} else {
|
|
1540
|
+
console.warn(
|
|
1541
|
+
`Warning: binary "${command.split(" ")[0]}" not found. Reviews will be rejected.`
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const consumptionDeps2 = {
|
|
1545
|
+
agentId: entry.agentId,
|
|
1546
|
+
limits: config.limits,
|
|
1547
|
+
session: createSessionTracker()
|
|
1548
|
+
};
|
|
1549
|
+
console.log(`Starting anonymous agent ${entry.agentId}...`);
|
|
1550
|
+
startAgent(entry.agentId, config.platformUrl, entry.apiKey, reviewDeps2, consumptionDeps2, {
|
|
1551
|
+
verbose: opts.verbose,
|
|
1552
|
+
stabilityThresholdMs,
|
|
1553
|
+
repoConfig: entry.repoConfig
|
|
1554
|
+
});
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (config.agents !== null) {
|
|
1558
|
+
const validAgents = [];
|
|
1559
|
+
for (const local of config.agents) {
|
|
1560
|
+
let cmd;
|
|
1561
|
+
try {
|
|
1562
|
+
cmd = resolveLocalAgentCommand(local, config.agentCommand);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
console.warn(
|
|
1565
|
+
`Skipping ${local.model}/${local.tool}: ${err instanceof Error ? err.message : "no command template available"}`
|
|
1566
|
+
);
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (!validateCommandBinary(cmd)) {
|
|
1570
|
+
console.warn(
|
|
1571
|
+
`Skipping ${local.model}/${local.tool}: binary "${cmd.split(" ")[0]}" not found`
|
|
1572
|
+
);
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
validAgents.push({ local, command: cmd });
|
|
1576
|
+
}
|
|
1577
|
+
if (validAgents.length === 0 && config.anonymousAgents.length === 0) {
|
|
1578
|
+
console.error("No valid agents in config. Check that tool binaries are installed.");
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
}
|
|
1581
|
+
let agentsToStart;
|
|
1582
|
+
const anonAgentsToStart = [];
|
|
1583
|
+
if (opts.all) {
|
|
1584
|
+
agentsToStart = validAgents;
|
|
1585
|
+
anonAgentsToStart.push(...config.anonymousAgents);
|
|
1586
|
+
} else if (agentIdOrModel) {
|
|
1587
|
+
const match = validAgents.find((a) => a.local.model === agentIdOrModel);
|
|
1588
|
+
if (!match) {
|
|
1589
|
+
console.error(`No agent with model "${agentIdOrModel}" found in local config.`);
|
|
1590
|
+
console.error("Available agents:");
|
|
1591
|
+
for (const a of validAgents) {
|
|
1592
|
+
console.error(` ${a.local.model} (${a.local.tool})`);
|
|
1593
|
+
}
|
|
1594
|
+
process.exit(1);
|
|
1595
|
+
}
|
|
1596
|
+
agentsToStart = [match];
|
|
1597
|
+
} else if (validAgents.length === 1) {
|
|
1598
|
+
agentsToStart = [validAgents[0]];
|
|
1599
|
+
console.log(`Using agent ${validAgents[0].local.model} (${validAgents[0].local.tool})`);
|
|
1600
|
+
} else if (validAgents.length === 0) {
|
|
1601
|
+
console.error("No valid authenticated agents in config. Use --anonymous or --all.");
|
|
1602
|
+
process.exit(1);
|
|
1603
|
+
} else {
|
|
1604
|
+
console.error("Multiple agents in config. Specify a model name or use --all:");
|
|
1605
|
+
for (const a of validAgents) {
|
|
1606
|
+
console.error(` ${a.local.model} (${a.local.tool})`);
|
|
1607
|
+
}
|
|
1608
|
+
process.exit(1);
|
|
1609
|
+
}
|
|
1610
|
+
const totalAgents = agentsToStart.length + anonAgentsToStart.length;
|
|
1611
|
+
if (totalAgents > 1) {
|
|
1612
|
+
process.setMaxListeners(process.getMaxListeners() + totalAgents * 2);
|
|
1613
|
+
}
|
|
1614
|
+
let startedCount = 0;
|
|
1615
|
+
let apiKey2;
|
|
1616
|
+
let client2;
|
|
1617
|
+
let serverAgents;
|
|
1618
|
+
if (agentsToStart.length > 0) {
|
|
1619
|
+
apiKey2 = requireApiKey(config);
|
|
1620
|
+
client2 = new ApiClient(config.platformUrl, apiKey2);
|
|
1621
|
+
try {
|
|
1622
|
+
const res = await client2.get("/api/agents");
|
|
1623
|
+
serverAgents = res.agents;
|
|
1624
|
+
} catch (err) {
|
|
1625
|
+
console.error("Failed to fetch agents:", err instanceof Error ? err.message : err);
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
for (const selected of agentsToStart) {
|
|
1630
|
+
let agentId2;
|
|
1631
|
+
try {
|
|
1632
|
+
const sync = await syncAgentToServer(client2, serverAgents, selected.local);
|
|
1633
|
+
agentId2 = sync.agentId;
|
|
1634
|
+
if (sync.created) {
|
|
1635
|
+
console.log(`Registered new agent ${agentId2} on platform`);
|
|
1636
|
+
serverAgents.push({
|
|
1637
|
+
id: agentId2,
|
|
1638
|
+
model: selected.local.model,
|
|
1639
|
+
tool: selected.local.tool,
|
|
1640
|
+
isAnonymous: false,
|
|
1641
|
+
status: "offline",
|
|
1642
|
+
repoConfig: null,
|
|
1643
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
console.error(
|
|
1648
|
+
`Failed to sync agent ${selected.local.model} to server:`,
|
|
1649
|
+
err instanceof Error ? err.message : err
|
|
1650
|
+
);
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
const reviewDeps2 = {
|
|
1654
|
+
commandTemplate: selected.command,
|
|
1655
|
+
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1656
|
+
};
|
|
1657
|
+
const consumptionDeps2 = {
|
|
1658
|
+
agentId: agentId2,
|
|
1659
|
+
limits: resolveAgentLimits(selected.local.limits, config.limits),
|
|
1660
|
+
session: createSessionTracker()
|
|
1661
|
+
};
|
|
1662
|
+
console.log(`Starting agent ${selected.local.model} (${agentId2})...`);
|
|
1663
|
+
startAgent(agentId2, config.platformUrl, apiKey2, reviewDeps2, consumptionDeps2, {
|
|
1664
|
+
verbose: opts.verbose,
|
|
1665
|
+
stabilityThresholdMs,
|
|
1666
|
+
repoConfig: selected.local.repos
|
|
1667
|
+
});
|
|
1668
|
+
startedCount++;
|
|
1669
|
+
}
|
|
1670
|
+
for (const anon of anonAgentsToStart) {
|
|
1671
|
+
let command;
|
|
1672
|
+
try {
|
|
1673
|
+
command = resolveCommandTemplate(
|
|
1674
|
+
DEFAULT_REGISTRY.tools.find((t) => t.name === anon.tool)?.commandTemplate.replaceAll("${MODEL}", anon.model) ?? null
|
|
1675
|
+
);
|
|
1676
|
+
} catch {
|
|
1677
|
+
console.warn(
|
|
1678
|
+
`Skipping anonymous agent ${anon.agentId}: no command template for tool "${anon.tool}"`
|
|
1679
|
+
);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
let reviewDeps2;
|
|
1683
|
+
if (validateCommandBinary(command)) {
|
|
1684
|
+
reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
|
|
1685
|
+
} else {
|
|
1686
|
+
console.warn(
|
|
1687
|
+
`Warning: binary "${command.split(" ")[0]}" not found for anonymous agent ${anon.agentId}. Reviews will be rejected.`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
const consumptionDeps2 = {
|
|
1691
|
+
agentId: anon.agentId,
|
|
1692
|
+
limits: config.limits,
|
|
1693
|
+
session: createSessionTracker()
|
|
1694
|
+
};
|
|
1695
|
+
console.log(`Starting anonymous agent ${anon.model} (${anon.agentId})...`);
|
|
1696
|
+
startAgent(anon.agentId, config.platformUrl, anon.apiKey, reviewDeps2, consumptionDeps2, {
|
|
1697
|
+
verbose: opts.verbose,
|
|
1698
|
+
stabilityThresholdMs,
|
|
1699
|
+
repoConfig: anon.repoConfig
|
|
1700
|
+
});
|
|
1701
|
+
startedCount++;
|
|
1702
|
+
}
|
|
1703
|
+
if (startedCount === 0) {
|
|
1704
|
+
console.error("No agents could be started.");
|
|
1705
|
+
process.exit(1);
|
|
1706
|
+
}
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const apiKey = requireApiKey(config);
|
|
1710
|
+
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1711
|
+
console.log(
|
|
1712
|
+
"Hint: No agents in local config. Run `opencara agent init` to import, or `opencara agent create` to add agents."
|
|
1713
|
+
);
|
|
1714
|
+
let agentId = agentIdOrModel;
|
|
1715
|
+
if (!agentId) {
|
|
1716
|
+
let res;
|
|
1717
|
+
try {
|
|
1718
|
+
res = await client.get("/api/agents");
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1721
|
+
process.exit(1);
|
|
1722
|
+
}
|
|
1723
|
+
if (res.agents.length === 0) {
|
|
1724
|
+
console.error("No agents registered. Run `opencara agent create` first.");
|
|
1725
|
+
process.exit(1);
|
|
1726
|
+
}
|
|
1727
|
+
if (res.agents.length === 1) {
|
|
1728
|
+
agentId = res.agents[0].id;
|
|
1729
|
+
console.log(`Using agent ${agentId}`);
|
|
1730
|
+
} else {
|
|
1731
|
+
console.error("Multiple agents found. Please specify an agent ID:");
|
|
1732
|
+
for (const a of res.agents) {
|
|
1733
|
+
console.error(` ${a.id} ${a.model} / ${a.tool}`);
|
|
1734
|
+
}
|
|
1735
|
+
process.exit(1);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
let reviewDeps;
|
|
1739
|
+
try {
|
|
1740
|
+
const commandTemplate = resolveCommandTemplate(config.agentCommand);
|
|
1741
|
+
reviewDeps = {
|
|
1742
|
+
commandTemplate,
|
|
1743
|
+
maxDiffSizeKb: config.maxDiffSizeKb
|
|
1744
|
+
};
|
|
1745
|
+
} catch (err) {
|
|
1746
|
+
console.warn(
|
|
1747
|
+
`Warning: ${err instanceof Error ? err.message : "Could not determine agent command."} Reviews will be rejected.`
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
const consumptionDeps = {
|
|
1751
|
+
agentId,
|
|
1752
|
+
limits: config.limits,
|
|
1753
|
+
session: createSessionTracker()
|
|
1754
|
+
};
|
|
1755
|
+
console.log(`Starting agent ${agentId}...`);
|
|
1756
|
+
startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
|
|
1757
|
+
verbose: opts.verbose,
|
|
1758
|
+
stabilityThresholdMs
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
);
|
|
1762
|
+
|
|
1763
|
+
// src/commands/stats.ts
|
|
1764
|
+
import { Command as Command3 } from "commander";
|
|
1765
|
+
function formatTrustTier(tier) {
|
|
1766
|
+
const lines = [];
|
|
1767
|
+
const pctPositive = Math.round(tier.positiveRate * 100);
|
|
1768
|
+
lines.push(` Trust: ${tier.label} (${tier.reviewCount} reviews, ${pctPositive}% positive)`);
|
|
1769
|
+
if (tier.nextTier) {
|
|
1770
|
+
const pctProgress = Math.round(tier.progressToNext * 100);
|
|
1771
|
+
const nextLabel = tier.nextTier.charAt(0).toUpperCase() + tier.nextTier.slice(1);
|
|
1772
|
+
lines.push(` Progress to ${nextLabel}: ${pctProgress}%`);
|
|
1773
|
+
}
|
|
1774
|
+
return lines.join("\n");
|
|
1775
|
+
}
|
|
1776
|
+
function formatReviewQuality(stats) {
|
|
1777
|
+
const lines = [];
|
|
1778
|
+
lines.push(` Reviews: ${stats.totalReviews} completed, ${stats.totalSummaries} summaries`);
|
|
1779
|
+
const totalRatings = stats.thumbsUp + stats.thumbsDown;
|
|
1780
|
+
if (totalRatings > 0) {
|
|
1781
|
+
const pctPositive = Math.round(stats.thumbsUp / totalRatings * 100);
|
|
1782
|
+
lines.push(` Quality: ${stats.thumbsUp}/${totalRatings} positive ratings (${pctPositive}%)`);
|
|
1783
|
+
} else {
|
|
1784
|
+
lines.push(` Quality: No ratings yet`);
|
|
1785
|
+
}
|
|
1786
|
+
return lines.join("\n");
|
|
1787
|
+
}
|
|
1788
|
+
function formatRepoConfig(repoConfig) {
|
|
1789
|
+
if (!repoConfig) return " Repos: all (default)";
|
|
1790
|
+
switch (repoConfig.mode) {
|
|
1791
|
+
case "all":
|
|
1792
|
+
return " Repos: all";
|
|
1793
|
+
case "own":
|
|
1794
|
+
return " Repos: own repos only";
|
|
1795
|
+
case "whitelist":
|
|
1796
|
+
return ` Repos: whitelist (${repoConfig.list?.join(", ") ?? "none"})`;
|
|
1797
|
+
case "blacklist":
|
|
1798
|
+
return ` Repos: blacklist (${repoConfig.list?.join(", ") ?? "none"})`;
|
|
1799
|
+
default:
|
|
1800
|
+
return ` Repos: ${repoConfig.mode}`;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
function formatAgentStats(agent, agentStats) {
|
|
1804
|
+
const lines = [];
|
|
1805
|
+
lines.push(`Agent: ${agent.id} (${agent.model} / ${agent.tool})`);
|
|
1806
|
+
lines.push(formatRepoConfig(agent.repoConfig));
|
|
1807
|
+
if (agentStats) {
|
|
1808
|
+
lines.push(formatTrustTier(agentStats.agent.trustTier));
|
|
1809
|
+
lines.push(formatReviewQuality(agentStats.stats));
|
|
1810
|
+
}
|
|
1811
|
+
return lines.join("\n");
|
|
1812
|
+
}
|
|
1813
|
+
async function fetchAgentStats(client, agentId) {
|
|
1814
|
+
try {
|
|
1815
|
+
return await client.get(`/api/stats/${agentId}`);
|
|
1816
|
+
} catch {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
var statsCommand = new Command3("stats").description("Display agent dashboard: trust tier and review quality").option("--agent <agentId>", "Show stats for a specific agent").action(async (opts) => {
|
|
1821
|
+
const config = loadConfig();
|
|
1822
|
+
if (opts.agent) {
|
|
1823
|
+
const anonEntry = findAnonymousAgent(config, opts.agent);
|
|
1824
|
+
if (anonEntry) {
|
|
1825
|
+
const anonClient = new ApiClient(config.platformUrl, anonEntry.apiKey);
|
|
1826
|
+
const agent = {
|
|
1827
|
+
id: anonEntry.agentId,
|
|
1828
|
+
model: anonEntry.model,
|
|
1829
|
+
tool: anonEntry.tool,
|
|
1830
|
+
isAnonymous: true,
|
|
1831
|
+
status: "offline",
|
|
1832
|
+
repoConfig: anonEntry.repoConfig ?? null,
|
|
1833
|
+
createdAt: ""
|
|
1834
|
+
};
|
|
1835
|
+
const agentStats = await fetchAgentStats(anonClient, anonEntry.agentId);
|
|
1836
|
+
console.log(formatAgentStats(agent, agentStats));
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
if (!config.apiKey && config.anonymousAgents.length > 0 && !opts.agent) {
|
|
1841
|
+
const outputs2 = [];
|
|
1842
|
+
for (const anon of config.anonymousAgents) {
|
|
1843
|
+
const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
|
|
1844
|
+
const agent = {
|
|
1845
|
+
id: anon.agentId,
|
|
1846
|
+
model: anon.model,
|
|
1847
|
+
tool: anon.tool,
|
|
1848
|
+
isAnonymous: true,
|
|
1849
|
+
status: "offline",
|
|
1850
|
+
repoConfig: anon.repoConfig ?? null,
|
|
1851
|
+
createdAt: ""
|
|
1852
|
+
};
|
|
1853
|
+
try {
|
|
1854
|
+
const agentStats = await fetchAgentStats(anonClient, anon.agentId);
|
|
1855
|
+
outputs2.push(formatAgentStats(agent, agentStats));
|
|
1856
|
+
} catch (err) {
|
|
1857
|
+
outputs2.push(
|
|
1858
|
+
`Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
|
|
1859
|
+
Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
console.log(outputs2.join("\n\n"));
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
const apiKey = requireApiKey(config);
|
|
1867
|
+
const client = new ApiClient(config.platformUrl, apiKey);
|
|
1868
|
+
if (opts.agent) {
|
|
1869
|
+
const agent = {
|
|
1870
|
+
id: opts.agent,
|
|
1871
|
+
model: "unknown",
|
|
1872
|
+
tool: "unknown",
|
|
1873
|
+
isAnonymous: false,
|
|
1874
|
+
status: "offline",
|
|
1875
|
+
repoConfig: null,
|
|
1876
|
+
createdAt: ""
|
|
1877
|
+
};
|
|
1878
|
+
try {
|
|
1879
|
+
const agentsRes2 = await client.get("/api/agents");
|
|
1880
|
+
const found = agentsRes2.agents.find((a) => a.id === opts.agent);
|
|
1881
|
+
if (found) {
|
|
1882
|
+
agent.model = found.model;
|
|
1883
|
+
agent.tool = found.tool;
|
|
1884
|
+
}
|
|
1885
|
+
} catch {
|
|
1886
|
+
}
|
|
1887
|
+
const agentStats = await fetchAgentStats(client, opts.agent);
|
|
1888
|
+
console.log(formatAgentStats(agent, agentStats));
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
let agentsRes;
|
|
1892
|
+
try {
|
|
1893
|
+
agentsRes = await client.get("/api/agents");
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
console.error("Failed to list agents:", err instanceof Error ? err.message : err);
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
if (agentsRes.agents.length === 0 && config.anonymousAgents.length === 0) {
|
|
1899
|
+
console.log("No agents registered. Run `opencara agent create` to register one.");
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
const outputs = [];
|
|
1903
|
+
for (const agent of agentsRes.agents) {
|
|
1904
|
+
try {
|
|
1905
|
+
const agentStats = await fetchAgentStats(client, agent.id);
|
|
1906
|
+
outputs.push(formatAgentStats(agent, agentStats));
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
outputs.push(
|
|
1909
|
+
`Agent: ${agent.id} (${agent.model} / ${agent.tool})
|
|
1910
|
+
Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
for (const anon of config.anonymousAgents) {
|
|
1915
|
+
const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
|
|
1916
|
+
const agent = {
|
|
1917
|
+
id: anon.agentId,
|
|
1918
|
+
model: anon.model,
|
|
1919
|
+
tool: anon.tool,
|
|
1920
|
+
isAnonymous: true,
|
|
1921
|
+
status: "offline",
|
|
1922
|
+
repoConfig: anon.repoConfig ?? null,
|
|
1923
|
+
createdAt: ""
|
|
1924
|
+
};
|
|
1925
|
+
try {
|
|
1926
|
+
const agentStats = await fetchAgentStats(anonClient, anon.agentId);
|
|
1927
|
+
outputs.push(formatAgentStats(agent, agentStats));
|
|
1928
|
+
} catch (err) {
|
|
1929
|
+
outputs.push(
|
|
1930
|
+
`Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
|
|
1931
|
+
Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
console.log(outputs.join("\n\n"));
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
// src/index.ts
|
|
1939
|
+
var program = new Command4().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.2.1");
|
|
11
1940
|
program.addCommand(loginCommand);
|
|
12
1941
|
program.addCommand(agentCommand);
|
|
13
1942
|
program.addCommand(statsCommand);
|
|
14
1943
|
program.parse();
|
|
15
|
-
//# sourceMappingURL=index.js.map
|