opencara 0.12.1 → 0.14.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.
Files changed (2) hide show
  1. package/dist/index.js +140 -49
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -137,20 +137,20 @@ var KNOWN_TOOL_NAMES = new Set(DEFAULT_REGISTRY.tools.map((t) => t.name));
137
137
  var TOOL_ALIASES = {
138
138
  "claude-code": "claude"
139
139
  };
140
- function parseRepoConfig(obj, index) {
141
- const raw = obj.repos;
140
+ function parseRepoConfig(obj, index, field = "repos") {
141
+ const raw = obj[field];
142
142
  if (raw === void 0 || raw === null) return void 0;
143
143
  if (typeof raw !== "object") {
144
- throw new RepoConfigError(`agents[${index}].repos must be an object`);
144
+ throw new RepoConfigError(`agents[${index}].${field} must be an object`);
145
145
  }
146
146
  const reposObj = raw;
147
147
  const mode = reposObj.mode;
148
148
  if (mode === void 0) {
149
- throw new RepoConfigError(`agents[${index}].repos.mode is required`);
149
+ throw new RepoConfigError(`agents[${index}].${field}.mode is required`);
150
150
  }
151
151
  if (typeof mode !== "string" || !VALID_REPO_MODES.includes(mode)) {
152
152
  throw new RepoConfigError(
153
- `agents[${index}].repos.mode must be one of: ${VALID_REPO_MODES.join(", ")}`
153
+ `agents[${index}].${field}.mode must be one of: ${VALID_REPO_MODES.join(", ")}`
154
154
  );
155
155
  }
156
156
  const config = { mode };
@@ -158,7 +158,7 @@ function parseRepoConfig(obj, index) {
158
158
  if (mode === "whitelist" || mode === "blacklist") {
159
159
  if (!Array.isArray(list) || list.length === 0) {
160
160
  throw new RepoConfigError(
161
- `agents[${index}].repos.list is required and must be non-empty for mode '${mode}'`
161
+ `agents[${index}].${field}.list is required and must be non-empty for mode '${mode}'`
162
162
  );
163
163
  }
164
164
  }
@@ -166,7 +166,7 @@ function parseRepoConfig(obj, index) {
166
166
  for (let j = 0; j < list.length; j++) {
167
167
  if (typeof list[j] !== "string" || !REPO_PATTERN.test(list[j])) {
168
168
  throw new RepoConfigError(
169
- `agents[${index}].repos.list[${j}] must match 'owner/repo' format`
169
+ `agents[${index}].${field}.list[${j}] must match 'owner/repo' format`
170
170
  );
171
171
  }
172
172
  }
@@ -213,10 +213,18 @@ function parseAgents(data) {
213
213
  if (typeof obj.command === "string") agent.command = obj.command;
214
214
  if (obj.router === true) agent.router = true;
215
215
  if (obj.review_only === true) agent.review_only = true;
216
+ if (obj.synthesizer_only === true) agent.synthesizer_only = true;
217
+ if (agent.review_only && agent.synthesizer_only) {
218
+ throw new ConfigValidationError(
219
+ `agents[${i}]: review_only and synthesizer_only cannot both be true`
220
+ );
221
+ }
216
222
  if (typeof obj.github_token === "string") agent.github_token = obj.github_token;
217
223
  if (typeof obj.codebase_dir === "string") agent.codebase_dir = obj.codebase_dir;
218
224
  const repoConfig = parseRepoConfig(obj, i);
219
225
  if (repoConfig) agent.repos = repoConfig;
226
+ const synthesizeRepoConfig = parseRepoConfig(obj, i, "synthesize_repos");
227
+ if (synthesizeRepoConfig) agent.synthesize_repos = synthesizeRepoConfig;
220
228
  agents.push(agent);
221
229
  }
222
230
  return agents;
@@ -254,9 +262,11 @@ function loadConfig() {
254
262
  const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
255
263
  const defaults = {
256
264
  platformUrl: envPlatformUrl || DEFAULT_PLATFORM_URL,
265
+ apiKey: null,
257
266
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
258
267
  maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
259
268
  githubToken: null,
269
+ githubUsername: null,
260
270
  codebaseDir: null,
261
271
  agentCommand: null,
262
272
  agents: null
@@ -272,9 +282,11 @@ function loadConfig() {
272
282
  const overrides = validateConfigData(data, envPlatformUrl);
273
283
  return {
274
284
  platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
285
+ apiKey: typeof data.api_key === "string" ? data.api_key.trim() || null : null,
275
286
  maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
276
287
  maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
277
288
  githubToken: typeof data.github_token === "string" ? data.github_token : null,
289
+ githubUsername: typeof data.github_username === "string" ? data.github_username : null,
278
290
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
279
291
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
280
292
  agents: parseAgents(data)
@@ -291,6 +303,22 @@ function resolveCodebaseDir(agentDir, globalDir) {
291
303
  }
292
304
  return path.resolve(raw);
293
305
  }
306
+ async function resolveGithubUsername(githubToken, fetchFn = fetch) {
307
+ if (!githubToken) return null;
308
+ try {
309
+ const response = await fetchFn("https://api.github.com/user", {
310
+ headers: {
311
+ Authorization: `Bearer ${githubToken}`,
312
+ Accept: "application/vnd.github+json"
313
+ }
314
+ });
315
+ if (!response.ok) return null;
316
+ const data = await response.json();
317
+ return typeof data.login === "string" ? data.login : null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
294
322
 
295
323
  // src/codebase.ts
296
324
  import { execFileSync } from "child_process";
@@ -413,18 +441,29 @@ var HttpError = class extends Error {
413
441
  }
414
442
  };
415
443
  var ApiClient = class {
416
- constructor(baseUrl, debug) {
444
+ constructor(baseUrl, debugOrOptions) {
417
445
  this.baseUrl = baseUrl;
418
- this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
446
+ if (typeof debugOrOptions === "object" && debugOrOptions !== null) {
447
+ this.debug = debugOrOptions.debug ?? process.env.OPENCARA_DEBUG === "1";
448
+ this.apiKey = debugOrOptions.apiKey ?? null;
449
+ } else {
450
+ this.debug = debugOrOptions ?? process.env.OPENCARA_DEBUG === "1";
451
+ this.apiKey = null;
452
+ }
419
453
  }
420
454
  debug;
455
+ apiKey;
421
456
  log(msg) {
422
457
  if (this.debug) console.debug(`[ApiClient] ${msg}`);
423
458
  }
424
459
  headers() {
425
- return {
460
+ const h = {
426
461
  "Content-Type": "application/json"
427
462
  };
463
+ if (this.apiKey) {
464
+ h["Authorization"] = `Bearer ${this.apiKey}`;
465
+ }
466
+ return h;
428
467
  }
429
468
  async get(path5) {
430
469
  this.log(`GET ${path5}`);
@@ -500,15 +539,15 @@ function sleep(ms, signal) {
500
539
  resolve2();
501
540
  return;
502
541
  }
503
- const timer = setTimeout(resolve2, ms);
504
- signal?.addEventListener(
505
- "abort",
506
- () => {
507
- clearTimeout(timer);
508
- resolve2();
509
- },
510
- { once: true }
511
- );
542
+ const onAbort = () => {
543
+ clearTimeout(timer);
544
+ resolve2();
545
+ };
546
+ const timer = setTimeout(() => {
547
+ signal?.removeEventListener("abort", onAbort);
548
+ resolve2();
549
+ }, ms);
550
+ signal?.addEventListener("abort", onAbort, { once: true });
512
551
  });
513
552
  }
514
553
 
@@ -522,6 +561,7 @@ var ToolTimeoutError = class extends Error {
522
561
  this.name = "ToolTimeoutError";
523
562
  }
524
563
  };
564
+ var SIGKILL_GRACE_MS = 5e3;
525
565
  var MIN_PARTIAL_RESULT_LENGTH = 50;
526
566
  var MAX_STDERR_LENGTH = 1e3;
527
567
  function validateCommandBinary(commandTemplate) {
@@ -621,9 +661,17 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
621
661
  let stdout = "";
622
662
  let stderr = "";
623
663
  let settled = false;
624
- const timer = setTimeout(() => {
664
+ let sigkillTimer;
665
+ function scheduleKillEscalation() {
625
666
  child.kill("SIGTERM");
626
- }, timeoutMs);
667
+ if (sigkillTimer) clearTimeout(sigkillTimer);
668
+ sigkillTimer = setTimeout(() => {
669
+ if (!settled) {
670
+ child.kill("SIGKILL");
671
+ }
672
+ }, SIGKILL_GRACE_MS);
673
+ }
674
+ const timer = setTimeout(scheduleKillEscalation, timeoutMs);
627
675
  child.stdout?.on("data", (chunk) => {
628
676
  stdout += chunk.toString();
629
677
  });
@@ -636,13 +684,12 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
636
684
  child.stdin?.end();
637
685
  let onAbort;
638
686
  if (signal) {
639
- onAbort = () => {
640
- child.kill();
641
- };
687
+ onAbort = scheduleKillEscalation;
642
688
  signal.addEventListener("abort", onAbort, { once: true });
643
689
  }
644
690
  function cleanup() {
645
691
  clearTimeout(timer);
692
+ if (sigkillTimer) clearTimeout(sigkillTimer);
646
693
  if (onAbort && signal) {
647
694
  signal.removeEventListener("abort", onAbort);
648
695
  }
@@ -1313,6 +1360,11 @@ function toApiDiffUrl(webUrl) {
1313
1360
  const [, owner, repo, prNumber] = match;
1314
1361
  return `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`;
1315
1362
  }
1363
+ function computeRoles(agent) {
1364
+ if (agent.review_only) return ["review"];
1365
+ if (agent.synthesizer_only) return ["summary"];
1366
+ return ["review", "summary"];
1367
+ }
1316
1368
  async function fetchDiff(diffUrl, githubToken, signal) {
1317
1369
  return withRetry(
1318
1370
  async () => {
@@ -1346,7 +1398,17 @@ async function fetchDiff(diffUrl, githubToken, signal) {
1346
1398
  }
1347
1399
  var MAX_DIFF_FETCH_ATTEMPTS = 3;
1348
1400
  async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, options) {
1349
- const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
1401
+ const {
1402
+ pollIntervalMs,
1403
+ maxConsecutiveErrors,
1404
+ routerRelay,
1405
+ reviewOnly,
1406
+ repoConfig,
1407
+ roles,
1408
+ synthesizeRepos,
1409
+ githubUsername,
1410
+ signal
1411
+ } = options;
1350
1412
  const { log, logError, logWarn } = logger;
1351
1413
  log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
1352
1414
  let consecutiveAuthErrors = 0;
@@ -1355,10 +1417,15 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1355
1417
  while (!signal?.aborted) {
1356
1418
  try {
1357
1419
  const pollBody = { agent_id: agentId };
1420
+ if (githubUsername) pollBody.github_username = githubUsername;
1421
+ if (roles) pollBody.roles = roles;
1358
1422
  if (reviewOnly) pollBody.review_only = true;
1359
1423
  if (repoConfig?.list?.length) {
1360
1424
  pollBody.repos = repoConfig.list;
1361
1425
  }
1426
+ if (synthesizeRepos) pollBody.synthesize_repos = synthesizeRepos;
1427
+ if (agentInfo.model) pollBody.model = agentInfo.model;
1428
+ if (agentInfo.tool) pollBody.tool = agentInfo.tool;
1362
1429
  const pollResponse = await client.post("/api/tasks/poll", pollBody);
1363
1430
  consecutiveAuthErrors = 0;
1364
1431
  consecutiveErrors = 0;
@@ -1377,7 +1444,8 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1377
1444
  logger,
1378
1445
  agentSession,
1379
1446
  routerRelay,
1380
- signal
1447
+ signal,
1448
+ githubUsername
1381
1449
  );
1382
1450
  if (result.diffFetchFailed) {
1383
1451
  agentSession.errorsEncountered++;
@@ -1430,20 +1498,22 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1430
1498
  await sleep2(pollIntervalMs, signal);
1431
1499
  }
1432
1500
  }
1433
- async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
1501
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal, githubUsername) {
1434
1502
  const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
1435
1503
  const { log, logError, logWarn } = logger;
1436
1504
  log(`${icons.success} Claimed task ${task_id} (${role}) \u2014 ${owner}/${repo}#${pr_number}`);
1437
1505
  log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
1438
1506
  let claimResponse;
1439
1507
  try {
1508
+ const claimBody = {
1509
+ agent_id: agentId,
1510
+ role,
1511
+ model: agentInfo.model,
1512
+ tool: agentInfo.tool
1513
+ };
1514
+ if (githubUsername) claimBody.github_username = githubUsername;
1440
1515
  claimResponse = await withRetry(
1441
- () => client.post(`/api/tasks/${task_id}/claim`, {
1442
- agent_id: agentId,
1443
- role,
1444
- model: agentInfo.model,
1445
- tool: agentInfo.tool
1446
- }),
1516
+ () => client.post(`/api/tasks/${task_id}/claim`, claimBody),
1447
1517
  { maxAttempts: 2 },
1448
1518
  signal
1449
1519
  );
@@ -1800,19 +1870,19 @@ function sleep2(ms, signal) {
1800
1870
  resolve2();
1801
1871
  return;
1802
1872
  }
1803
- const timer = setTimeout(resolve2, ms);
1804
- signal?.addEventListener(
1805
- "abort",
1806
- () => {
1807
- clearTimeout(timer);
1808
- resolve2();
1809
- },
1810
- { once: true }
1811
- );
1873
+ const onAbort = () => {
1874
+ clearTimeout(timer);
1875
+ resolve2();
1876
+ };
1877
+ const timer = setTimeout(() => {
1878
+ signal?.removeEventListener("abort", onAbort);
1879
+ resolve2();
1880
+ }, ms);
1881
+ signal?.addEventListener("abort", onAbort, { once: true });
1812
1882
  });
1813
1883
  }
1814
1884
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
1815
- const client = new ApiClient(platformUrl);
1885
+ const client = new ApiClient(platformUrl, { apiKey: options?.apiKey });
1816
1886
  const session = consumptionDeps?.session ?? createSessionTracker();
1817
1887
  const deps = consumptionDeps ?? { agentId, session };
1818
1888
  const logger = createLogger(options?.label);
@@ -1846,6 +1916,9 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
1846
1916
  routerRelay: options?.routerRelay,
1847
1917
  reviewOnly: options?.reviewOnly,
1848
1918
  repoConfig: options?.repoConfig,
1919
+ roles: options?.roles,
1920
+ synthesizeRepos: options?.synthesizeRepos,
1921
+ githubUsername: options?.githubUsername,
1849
1922
  signal: abortController.signal
1850
1923
  });
1851
1924
  log(formatExitSummary(agentSession));
@@ -1867,6 +1940,10 @@ async function startAgentRouter() {
1867
1940
  const auth = resolveGithubToken2(configToken);
1868
1941
  const logger = createLogger(agentConfig?.name ?? "agent[0]");
1869
1942
  logAuthMethod(auth.method, logger.log);
1943
+ const githubUsername = config.githubUsername ?? await resolveGithubUsername(auth.token) ?? void 0;
1944
+ if (githubUsername) {
1945
+ logger.log(`GitHub identity: ${githubUsername}`);
1946
+ }
1870
1947
  const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
1871
1948
  const reviewDeps = {
1872
1949
  commandTemplate: commandTemplate ?? "",
@@ -1878,6 +1955,7 @@ async function startAgentRouter() {
1878
1955
  const model = agentConfig?.model ?? "unknown";
1879
1956
  const tool = agentConfig?.tool ?? "unknown";
1880
1957
  const label = agentConfig?.name ?? "agent[0]";
1958
+ const roles = agentConfig ? computeRoles(agentConfig) : void 0;
1881
1959
  await startAgent(
1882
1960
  agentId,
1883
1961
  config.platformUrl,
@@ -1892,12 +1970,16 @@ async function startAgentRouter() {
1892
1970
  routerRelay: router,
1893
1971
  reviewOnly: agentConfig?.review_only,
1894
1972
  repoConfig: agentConfig?.repos,
1895
- label
1973
+ roles,
1974
+ synthesizeRepos: agentConfig?.synthesize_repos,
1975
+ githubUsername,
1976
+ label,
1977
+ apiKey: config.apiKey
1896
1978
  }
1897
1979
  );
1898
1980
  router.stop();
1899
1981
  }
1900
- function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
1982
+ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth, githubUsername) {
1901
1983
  const agentId = crypto.randomUUID();
1902
1984
  let commandTemplate;
1903
1985
  let agentConfig;
@@ -1941,6 +2023,7 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
1941
2023
  const session = createSessionTracker();
1942
2024
  const model = agentConfig?.model ?? "unknown";
1943
2025
  const tool = agentConfig?.tool ?? "unknown";
2026
+ const roles = agentConfig ? computeRoles(agentConfig) : void 0;
1944
2027
  const agentPromise = startAgent(
1945
2028
  agentId,
1946
2029
  config.platformUrl,
@@ -1953,7 +2036,11 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
1953
2036
  routerRelay,
1954
2037
  reviewOnly: agentConfig?.review_only,
1955
2038
  repoConfig: agentConfig?.repos,
1956
- label
2039
+ roles,
2040
+ synthesizeRepos: agentConfig?.synthesize_repos,
2041
+ githubUsername,
2042
+ label,
2043
+ apiKey: config.apiKey
1957
2044
  }
1958
2045
  ).finally(() => {
1959
2046
  routerRelay?.stop();
@@ -1967,6 +2054,10 @@ agentCommand.command("start").description("Start agents in polling mode").option
1967
2054
  const configToken = resolveGithubToken(void 0, config.githubToken);
1968
2055
  const auth = resolveGithubToken2(configToken);
1969
2056
  logAuthMethod(auth.method, console.log.bind(console));
2057
+ const githubUsername = config.githubUsername ?? await resolveGithubUsername(auth.token) ?? void 0;
2058
+ if (githubUsername) {
2059
+ console.log(`GitHub identity: ${githubUsername}`);
2060
+ }
1970
2061
  if (opts.all) {
1971
2062
  if (!config.agents || config.agents.length === 0) {
1972
2063
  console.error("No agents configured in ~/.opencara/config.yml");
@@ -1977,7 +2068,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
1977
2068
  const promises = [];
1978
2069
  let startFailed = false;
1979
2070
  for (let i = 0; i < config.agents.length; i++) {
1980
- const p = startAgentByIndex(config, i, pollIntervalMs, auth);
2071
+ const p = startAgentByIndex(config, i, pollIntervalMs, auth, githubUsername);
1981
2072
  if (p) {
1982
2073
  promises.push(p);
1983
2074
  } else {
@@ -2014,7 +2105,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
2014
2105
  process.exit(1);
2015
2106
  return;
2016
2107
  }
2017
- const p = startAgentByIndex(config, agentIndex, pollIntervalMs, auth);
2108
+ const p = startAgentByIndex(config, agentIndex, pollIntervalMs, auth, githubUsername);
2018
2109
  if (!p) {
2019
2110
  process.exit(1);
2020
2111
  return;
@@ -2024,7 +2115,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
2024
2115
  });
2025
2116
 
2026
2117
  // src/index.ts
2027
- var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.12.1");
2118
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.14.0");
2028
2119
  program.addCommand(agentCommand);
2029
2120
  program.action(() => {
2030
2121
  startAgentRouter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Distributed AI code review agent — poll, review, and submit PR reviews using your own AI tools",
5
5
  "type": "module",
6
6
  "license": "MIT",