opencara 0.14.0 → 0.15.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 +464 -50
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@ import { Command as Command2 } from "commander";
6
6
  // src/commands/agent.ts
7
7
  import { Command } from "commander";
8
8
  import crypto from "crypto";
9
- import * as fs4 from "fs";
10
- import * as path4 from "path";
9
+ import * as fs5 from "fs";
10
+ import * as path5 from "path";
11
11
 
12
12
  // ../shared/dist/types.js
13
13
  function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
@@ -117,6 +117,10 @@ import { parse, stringify } from "yaml";
117
117
  var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
118
118
  var CONFIG_DIR = path.join(os.homedir(), ".opencara");
119
119
  var CONFIG_FILE = process.env.OPENCARA_CONFIG && process.env.OPENCARA_CONFIG.trim() ? path.resolve(process.env.OPENCARA_CONFIG) : path.join(CONFIG_DIR, "config.yml");
120
+ function ensureConfigDir() {
121
+ const dir = path.dirname(CONFIG_FILE);
122
+ fs.mkdirSync(dir, { recursive: true });
123
+ }
120
124
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
121
125
  var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
122
126
  var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
@@ -256,8 +260,23 @@ function validateConfigData(data, envPlatformUrl) {
256
260
  );
257
261
  overrides.maxConsecutiveErrors = DEFAULT_MAX_CONSECUTIVE_ERRORS;
258
262
  }
263
+ for (const field of [
264
+ "max_reviews_per_day",
265
+ "max_tokens_per_day",
266
+ "max_tokens_per_review"
267
+ ]) {
268
+ if (field in data && typeof data[field] === "number" && data[field] <= 0) {
269
+ console.warn(
270
+ `\u26A0 Config warning: ${field} must be > 0, got ${data[field]}, ignoring (unlimited)`
271
+ );
272
+ }
273
+ }
259
274
  return overrides;
260
275
  }
276
+ function parsePositiveInt(value) {
277
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
278
+ return null;
279
+ }
261
280
  function loadConfig() {
262
281
  const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
263
282
  const defaults = {
@@ -269,7 +288,12 @@ function loadConfig() {
269
288
  githubUsername: null,
270
289
  codebaseDir: null,
271
290
  agentCommand: null,
272
- agents: null
291
+ agents: null,
292
+ usageLimits: {
293
+ maxReviewsPerDay: null,
294
+ maxTokensPerDay: null,
295
+ maxTokensPerReview: null
296
+ }
273
297
  };
274
298
  if (!fs.existsSync(CONFIG_FILE)) {
275
299
  return defaults;
@@ -289,7 +313,12 @@ function loadConfig() {
289
313
  githubUsername: typeof data.github_username === "string" ? data.github_username : null,
290
314
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
291
315
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
292
- agents: parseAgents(data)
316
+ agents: parseAgents(data),
317
+ usageLimits: {
318
+ maxReviewsPerDay: parsePositiveInt(data.max_reviews_per_day),
319
+ maxTokensPerDay: parsePositiveInt(data.max_tokens_per_day),
320
+ maxTokensPerReview: parsePositiveInt(data.max_tokens_per_review)
321
+ }
293
322
  };
294
323
  }
295
324
  function resolveGithubToken(agentToken, globalToken) {
@@ -465,24 +494,24 @@ var ApiClient = class {
465
494
  }
466
495
  return h;
467
496
  }
468
- async get(path5) {
469
- this.log(`GET ${path5}`);
470
- const res = await fetch(`${this.baseUrl}${path5}`, {
497
+ async get(path6) {
498
+ this.log(`GET ${path6}`);
499
+ const res = await fetch(`${this.baseUrl}${path6}`, {
471
500
  method: "GET",
472
501
  headers: this.headers()
473
502
  });
474
- return this.handleResponse(res, path5);
503
+ return this.handleResponse(res, path6);
475
504
  }
476
- async post(path5, body) {
477
- this.log(`POST ${path5}`);
478
- const res = await fetch(`${this.baseUrl}${path5}`, {
505
+ async post(path6, body) {
506
+ this.log(`POST ${path6}`);
507
+ const res = await fetch(`${this.baseUrl}${path6}`, {
479
508
  method: "POST",
480
509
  headers: this.headers(),
481
510
  body: body !== void 0 ? JSON.stringify(body) : void 0
482
511
  });
483
- return this.handleResponse(res, path5);
512
+ return this.handleResponse(res, path6);
484
513
  }
485
- async handleResponse(res, path5) {
514
+ async handleResponse(res, path6) {
486
515
  if (!res.ok) {
487
516
  let message = `HTTP ${res.status}`;
488
517
  let errorCode;
@@ -494,10 +523,10 @@ var ApiClient = class {
494
523
  }
495
524
  } catch {
496
525
  }
497
- this.log(`${res.status} ${message} (${path5})`);
526
+ this.log(`${res.status} ${message} (${path6})`);
498
527
  throw new HttpError(res.status, message, errorCode);
499
528
  }
500
- this.log(`${res.status} OK (${path5})`);
529
+ this.log(`${res.status} OK (${path6})`);
501
530
  return await res.json();
502
531
  }
503
532
  };
@@ -629,18 +658,35 @@ function parseClaudeTokens(text) {
629
658
  const inputMatch = text.match(/"input_tokens"\s*:\s*(\d+)/);
630
659
  const outputMatch = text.match(/"output_tokens"\s*:\s*(\d+)/);
631
660
  if (inputMatch && outputMatch) {
632
- return parseInt(inputMatch[1], 10) + parseInt(outputMatch[1], 10);
661
+ return {
662
+ input: parseInt(inputMatch[1], 10),
663
+ output: parseInt(outputMatch[1], 10)
664
+ };
633
665
  }
634
666
  return null;
635
667
  }
636
668
  function parseTokenUsage(stdout, stderr) {
637
669
  const codexMatch = stdout.match(/tokens\s+used[\s:]*([0-9,]+)/i);
638
- if (codexMatch) return { tokens: parseInt(codexMatch[1].replace(/,/g, ""), 10), parsed: true };
639
- const claudeTotal = parseClaudeTokens(stdout) ?? parseClaudeTokens(stderr);
640
- if (claudeTotal !== null) return { tokens: claudeTotal, parsed: true };
670
+ if (codexMatch) {
671
+ const total = parseInt(codexMatch[1].replace(/,/g, ""), 10);
672
+ return { tokens: total, parsed: true, input: 0, output: total };
673
+ }
674
+ const claudeResult = parseClaudeTokens(stdout) ?? parseClaudeTokens(stderr);
675
+ if (claudeResult !== null) {
676
+ return {
677
+ tokens: claudeResult.input + claudeResult.output,
678
+ parsed: true,
679
+ input: claudeResult.input,
680
+ output: claudeResult.output
681
+ };
682
+ }
641
683
  const qwenMatch = stdout.match(/"tokens"\s*:\s*\{[^}]*"total"\s*:\s*(\d+)/);
642
- if (qwenMatch) return { tokens: parseInt(qwenMatch[1], 10), parsed: true };
643
- return { tokens: estimateTokens(stdout), parsed: false };
684
+ if (qwenMatch) {
685
+ const total = parseInt(qwenMatch[1], 10);
686
+ return { tokens: total, parsed: true, input: 0, output: total };
687
+ }
688
+ const estimated = estimateTokens(stdout);
689
+ return { tokens: estimated, parsed: false, input: 0, output: estimated };
644
690
  }
645
691
  function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
646
692
  const promptViaArg = commandTemplate.includes("${PROMPT}");
@@ -729,7 +775,18 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
729
775
  console.warn(`Tool stderr: ${stderr.slice(0, MAX_STDERR_LENGTH)}`);
730
776
  }
731
777
  const usage2 = parseTokenUsage(stdout, stderr);
732
- resolve2({ stdout, stderr, tokensUsed: usage2.tokens, tokensParsed: usage2.parsed });
778
+ resolve2({
779
+ stdout,
780
+ stderr,
781
+ tokensUsed: usage2.tokens,
782
+ tokensParsed: usage2.parsed,
783
+ tokenDetail: {
784
+ input: usage2.input,
785
+ output: usage2.output,
786
+ total: usage2.tokens,
787
+ parsed: usage2.parsed
788
+ }
789
+ });
733
790
  return;
734
791
  }
735
792
  const errMsg = stderr ? `Tool "${command}" failed (exit code ${code}): ${stderr.slice(0, MAX_STDERR_LENGTH)}` : `Tool "${command}" failed with exit code ${code}`;
@@ -737,7 +794,18 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
737
794
  return;
738
795
  }
739
796
  const usage = parseTokenUsage(stdout, stderr);
740
- resolve2({ stdout, stderr, tokensUsed: usage.tokens, tokensParsed: usage.parsed });
797
+ resolve2({
798
+ stdout,
799
+ stderr,
800
+ tokensUsed: usage.tokens,
801
+ tokensParsed: usage.parsed,
802
+ tokenDetail: {
803
+ input: usage.input,
804
+ output: usage.output,
805
+ total: usage.tokens,
806
+ parsed: usage.parsed
807
+ }
808
+ });
741
809
  });
742
810
  });
743
811
  }
@@ -861,11 +929,19 @@ ${userMessage}`;
861
929
  );
862
930
  const { verdict, review } = extractVerdict(result.stdout);
863
931
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
932
+ const detail = result.tokenDetail;
933
+ const tokenDetail = result.tokensParsed ? detail : {
934
+ input: inputTokens,
935
+ output: detail.output,
936
+ total: inputTokens + detail.output,
937
+ parsed: false
938
+ };
864
939
  return {
865
940
  review,
866
941
  verdict,
867
942
  tokensUsed: result.tokensUsed + inputTokens,
868
- tokensEstimated: !result.tokensParsed
943
+ tokensEstimated: !result.tokensParsed,
944
+ tokenDetail
869
945
  };
870
946
  } finally {
871
947
  clearTimeout(abortTimer);
@@ -985,10 +1061,18 @@ ${userMessage}`;
985
1061
  deps.codebaseDir ?? void 0
986
1062
  );
987
1063
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
1064
+ const detail = result.tokenDetail;
1065
+ const tokenDetail = result.tokensParsed ? detail : {
1066
+ input: inputTokens,
1067
+ output: detail.output,
1068
+ total: inputTokens + detail.output,
1069
+ parsed: false
1070
+ };
988
1071
  return {
989
1072
  summary: result.stdout,
990
1073
  tokensUsed: result.tokensUsed + inputTokens,
991
- tokensEstimated: !result.tokensParsed
1074
+ tokensEstimated: !result.tokensParsed,
1075
+ tokenDetail
992
1076
  };
993
1077
  } finally {
994
1078
  clearTimeout(abortTimer);
@@ -1153,16 +1237,190 @@ var RouterTimeoutError = class extends Error {
1153
1237
 
1154
1238
  // src/consumption.ts
1155
1239
  function createSessionTracker() {
1156
- return { tokens: 0, reviews: 0 };
1240
+ return { tokens: 0, reviews: 0, tokenBreakdown: { input: 0, output: 0, estimated: 0 } };
1157
1241
  }
1158
- function recordSessionUsage(session, tokensUsed) {
1159
- session.tokens += tokensUsed;
1160
- session.reviews += 1;
1242
+ function recordSessionUsage(session, tokensOrOptions) {
1243
+ if (typeof tokensOrOptions === "number") {
1244
+ session.tokens += tokensOrOptions;
1245
+ session.reviews += 1;
1246
+ session.tokenBreakdown.estimated += tokensOrOptions;
1247
+ } else {
1248
+ session.tokens += tokensOrOptions.totalTokens;
1249
+ session.reviews += 1;
1250
+ if (tokensOrOptions.estimated) {
1251
+ session.tokenBreakdown.estimated += tokensOrOptions.totalTokens;
1252
+ } else {
1253
+ session.tokenBreakdown.input += tokensOrOptions.inputTokens;
1254
+ session.tokenBreakdown.output += tokensOrOptions.outputTokens;
1255
+ }
1256
+ }
1161
1257
  }
1162
1258
  function formatPostReviewStats(session) {
1163
- return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
1259
+ const { input, output, estimated } = session.tokenBreakdown;
1260
+ const hasBreakdown = input > 0 || output > 0;
1261
+ let detail = "";
1262
+ if (hasBreakdown) {
1263
+ const parts = [];
1264
+ if (input > 0) parts.push(`${input.toLocaleString()} in`);
1265
+ if (output > 0) parts.push(`${output.toLocaleString()} out`);
1266
+ if (estimated > 0) parts.push(`${estimated.toLocaleString()} est`);
1267
+ detail = ` (${parts.join(" + ")})`;
1268
+ }
1269
+ return ` Session: ${session.tokens.toLocaleString()} tokens${detail} / ${session.reviews} reviews`;
1164
1270
  }
1165
1271
 
1272
+ // src/usage-tracker.ts
1273
+ import * as fs4 from "fs";
1274
+ import * as path4 from "path";
1275
+ var USAGE_FILE = path4.join(CONFIG_DIR, "usage.json");
1276
+ var MAX_HISTORY_DAYS = 30;
1277
+ var WARNING_THRESHOLD = 0.8;
1278
+ function todayKey() {
1279
+ const now = /* @__PURE__ */ new Date();
1280
+ const y = now.getFullYear();
1281
+ const m = String(now.getMonth() + 1).padStart(2, "0");
1282
+ const d = String(now.getDate()).padStart(2, "0");
1283
+ return `${y}-${m}-${d}`;
1284
+ }
1285
+ function totalTokens(t) {
1286
+ return t.input + t.output + t.estimated;
1287
+ }
1288
+ var UsageTracker = class {
1289
+ data;
1290
+ filePath;
1291
+ constructor(filePath = USAGE_FILE) {
1292
+ this.filePath = filePath;
1293
+ this.data = this.load();
1294
+ this.pruneHistory();
1295
+ }
1296
+ load() {
1297
+ try {
1298
+ if (fs4.existsSync(this.filePath)) {
1299
+ const raw = fs4.readFileSync(this.filePath, "utf-8");
1300
+ const parsed = JSON.parse(raw);
1301
+ if (parsed && Array.isArray(parsed.days)) {
1302
+ return parsed;
1303
+ }
1304
+ }
1305
+ } catch {
1306
+ }
1307
+ return { days: [] };
1308
+ }
1309
+ save() {
1310
+ ensureConfigDir();
1311
+ fs4.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), {
1312
+ encoding: "utf-8",
1313
+ mode: 384
1314
+ });
1315
+ }
1316
+ /** Get or create today's usage record. Prunes old history. */
1317
+ getToday() {
1318
+ const key = todayKey();
1319
+ let today = this.data.days.find((d) => d.date === key);
1320
+ if (!today) {
1321
+ today = { date: key, reviews: 0, tokens: { input: 0, output: 0, estimated: 0 } };
1322
+ this.data.days.push(today);
1323
+ this.pruneHistory();
1324
+ }
1325
+ return today;
1326
+ }
1327
+ /** Record a completed review with its token usage. */
1328
+ recordReview(tokens) {
1329
+ const today = this.getToday();
1330
+ today.reviews += 1;
1331
+ if (tokens.estimated) {
1332
+ today.tokens.estimated += tokens.input + tokens.output;
1333
+ } else {
1334
+ today.tokens.input += tokens.input;
1335
+ today.tokens.output += tokens.output;
1336
+ }
1337
+ this.save();
1338
+ }
1339
+ /** Check whether a new review is allowed under the configured limits. */
1340
+ checkLimits(limits) {
1341
+ const today = this.getToday();
1342
+ const todayTokenTotal = totalTokens(today.tokens);
1343
+ if (limits.maxReviewsPerDay !== null && today.reviews >= limits.maxReviewsPerDay) {
1344
+ return {
1345
+ allowed: false,
1346
+ reason: `Daily review limit reached (${today.reviews}/${limits.maxReviewsPerDay})`
1347
+ };
1348
+ }
1349
+ if (limits.maxTokensPerDay !== null && todayTokenTotal >= limits.maxTokensPerDay) {
1350
+ return {
1351
+ allowed: false,
1352
+ reason: `Daily token budget exhausted (${todayTokenTotal.toLocaleString()}/${limits.maxTokensPerDay.toLocaleString()})`
1353
+ };
1354
+ }
1355
+ const warnings = [];
1356
+ if (limits.maxReviewsPerDay !== null) {
1357
+ const ratio = today.reviews / limits.maxReviewsPerDay;
1358
+ if (ratio >= WARNING_THRESHOLD) {
1359
+ warnings.push(
1360
+ `Reviews: ${today.reviews}/${limits.maxReviewsPerDay} (${Math.round(ratio * 100)}%)`
1361
+ );
1362
+ }
1363
+ }
1364
+ if (limits.maxTokensPerDay !== null) {
1365
+ const ratio = todayTokenTotal / limits.maxTokensPerDay;
1366
+ if (ratio >= WARNING_THRESHOLD) {
1367
+ warnings.push(
1368
+ `Tokens: ${todayTokenTotal.toLocaleString()}/${limits.maxTokensPerDay.toLocaleString()} (${Math.round(ratio * 100)}%)`
1369
+ );
1370
+ }
1371
+ }
1372
+ return { allowed: true, warning: warnings.length > 0 ? warnings.join("; ") : void 0 };
1373
+ }
1374
+ /** Check whether a specific review's estimated token count exceeds the per-review limit. */
1375
+ checkPerReviewLimit(estimatedTokens, limits) {
1376
+ if (limits.maxTokensPerReview !== null && estimatedTokens > limits.maxTokensPerReview) {
1377
+ return {
1378
+ allowed: false,
1379
+ reason: `Estimated tokens (${estimatedTokens.toLocaleString()}) exceed per-review limit (${limits.maxTokensPerReview.toLocaleString()})`
1380
+ };
1381
+ }
1382
+ return { allowed: true };
1383
+ }
1384
+ /** Remove entries older than MAX_HISTORY_DAYS. */
1385
+ pruneHistory() {
1386
+ if (this.data.days.length <= MAX_HISTORY_DAYS) return;
1387
+ this.data.days.sort((a, b) => b.date.localeCompare(a.date));
1388
+ this.data.days = this.data.days.slice(0, MAX_HISTORY_DAYS);
1389
+ }
1390
+ /** Format a usage summary for display on shutdown. */
1391
+ formatSummary(limits) {
1392
+ const today = this.getToday();
1393
+ const todayTokenTotal = totalTokens(today.tokens);
1394
+ const lines = ["Usage Summary:"];
1395
+ lines.push(` Date: ${today.date}`);
1396
+ lines.push(
1397
+ ` Reviews: ${today.reviews}${limits.maxReviewsPerDay !== null ? `/${limits.maxReviewsPerDay}` : ""}`
1398
+ );
1399
+ const tokenParts = [];
1400
+ if (today.tokens.input > 0) tokenParts.push(`${today.tokens.input.toLocaleString()} in`);
1401
+ if (today.tokens.output > 0) tokenParts.push(`${today.tokens.output.toLocaleString()} out`);
1402
+ if (today.tokens.estimated > 0)
1403
+ tokenParts.push(`${today.tokens.estimated.toLocaleString()} est`);
1404
+ const breakdown = tokenParts.length > 0 ? ` (${tokenParts.join(" + ")})` : "";
1405
+ lines.push(
1406
+ ` Tokens: ${todayTokenTotal.toLocaleString()}${limits.maxTokensPerDay !== null ? `/${limits.maxTokensPerDay.toLocaleString()}` : ""}${breakdown}`
1407
+ );
1408
+ if (limits.maxTokensPerDay !== null) {
1409
+ const remaining = Math.max(0, limits.maxTokensPerDay - todayTokenTotal);
1410
+ lines.push(` Remaining token budget: ${remaining.toLocaleString()}`);
1411
+ }
1412
+ if (limits.maxReviewsPerDay !== null) {
1413
+ const remaining = Math.max(0, limits.maxReviewsPerDay - today.reviews);
1414
+ lines.push(` Remaining reviews: ${remaining}`);
1415
+ }
1416
+ return lines.join("\n");
1417
+ }
1418
+ /** Get all stored usage data (for testing/inspection). */
1419
+ getData() {
1420
+ return this.data;
1421
+ }
1422
+ };
1423
+
1166
1424
  // src/pr-context.ts
1167
1425
  async function githubGet(url, deps) {
1168
1426
  const headers = {
@@ -1365,7 +1623,8 @@ function computeRoles(agent) {
1365
1623
  if (agent.synthesizer_only) return ["summary"];
1366
1624
  return ["review", "summary"];
1367
1625
  }
1368
- async function fetchDiff(diffUrl, githubToken, signal) {
1626
+ async function fetchDiff(diffUrl, githubToken, signal, maxDiffSizeKb) {
1627
+ const maxBytes = maxDiffSizeKb ? maxDiffSizeKb * 1024 : Infinity;
1369
1628
  return withRetry(
1370
1629
  async () => {
1371
1630
  const headers = {};
@@ -1390,13 +1649,56 @@ async function fetchDiff(diffUrl, githubToken, signal) {
1390
1649
  }
1391
1650
  throw new Error(msg);
1392
1651
  }
1652
+ if (maxBytes < Infinity) {
1653
+ const contentLength = parseInt(response.headers.get("content-length") ?? "", 10);
1654
+ if (!isNaN(contentLength) && contentLength > maxBytes) {
1655
+ if (response.body) {
1656
+ void response.body.cancel();
1657
+ }
1658
+ throw new DiffTooLargeError(
1659
+ `Diff too large (${Math.round(contentLength / 1024)}KB > ${maxDiffSizeKb}KB, from Content-Length)`
1660
+ );
1661
+ }
1662
+ if (response.body) {
1663
+ const reader = response.body.getReader();
1664
+ const chunks = [];
1665
+ let totalBytes = 0;
1666
+ for (; ; ) {
1667
+ const { done, value } = await reader.read();
1668
+ if (done) break;
1669
+ totalBytes += value.length;
1670
+ if (totalBytes > maxBytes) {
1671
+ void reader.cancel();
1672
+ throw new DiffTooLargeError(`Diff too large (>${maxDiffSizeKb}KB)`);
1673
+ }
1674
+ chunks.push(value);
1675
+ }
1676
+ return new TextDecoder().decode(concatUint8Arrays(chunks, totalBytes));
1677
+ }
1678
+ }
1393
1679
  return response.text();
1394
1680
  },
1395
1681
  { maxAttempts: 2 },
1396
1682
  signal
1397
1683
  );
1398
1684
  }
1685
+ function concatUint8Arrays(chunks, totalLength) {
1686
+ const result = new Uint8Array(totalLength);
1687
+ let offset = 0;
1688
+ for (const chunk of chunks) {
1689
+ result.set(chunk, offset);
1690
+ offset += chunk.length;
1691
+ }
1692
+ return result;
1693
+ }
1399
1694
  var MAX_DIFF_FETCH_ATTEMPTS = 3;
1695
+ function appendContributorAttribution(text, githubUsername) {
1696
+ if (!githubUsername) return text;
1697
+ return `${text}
1698
+
1699
+ ---
1700
+ Contributed by [@${githubUsername}](https://github.com/${githubUsername})`;
1701
+ }
1400
1702
  async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, options) {
1401
1703
  const {
1402
1704
  pollIntervalMs,
@@ -1415,6 +1717,16 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1415
1717
  let consecutiveErrors = 0;
1416
1718
  const diffFailCounts = /* @__PURE__ */ new Map();
1417
1719
  while (!signal?.aborted) {
1720
+ if (consumptionDeps.usageTracker && consumptionDeps.usageLimits) {
1721
+ const limitStatus = consumptionDeps.usageTracker.checkLimits(consumptionDeps.usageLimits);
1722
+ if (!limitStatus.allowed) {
1723
+ log(`${icons.stop} ${limitStatus.reason}. Stopping.`);
1724
+ break;
1725
+ }
1726
+ if (limitStatus.warning) {
1727
+ logWarn(`${icons.warn} Approaching limits: ${limitStatus.warning}`);
1728
+ }
1729
+ }
1418
1730
  try {
1419
1731
  const pollBody = { agent_id: agentId };
1420
1732
  if (githubUsername) pollBody.github_username = githubUsername;
@@ -1528,7 +1840,12 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
1528
1840
  }
1529
1841
  let diffContent;
1530
1842
  try {
1531
- diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
1843
+ diffContent = await fetchDiff(
1844
+ diff_url,
1845
+ reviewDeps.githubToken,
1846
+ signal,
1847
+ reviewDeps.maxDiffSizeKb
1848
+ );
1532
1849
  log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
1533
1850
  } catch (err) {
1534
1851
  logError(` Failed to fetch diff for task ${task_id}: ${err.message}`);
@@ -1567,8 +1884,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
1567
1884
  validatePathSegment(owner, "owner");
1568
1885
  validatePathSegment(repo, "repo");
1569
1886
  validatePathSegment(task_id, "task_id");
1570
- const repoScopedDir = path4.join(CONFIG_DIR, "repos", owner, repo, task_id);
1571
- fs4.mkdirSync(repoScopedDir, { recursive: true });
1887
+ const repoScopedDir = path5.join(CONFIG_DIR, "repos", owner, repo, task_id);
1888
+ fs5.mkdirSync(repoScopedDir, { recursive: true });
1572
1889
  taskCheckoutPath = repoScopedDir;
1573
1890
  taskReviewDeps = { ...reviewDeps, codebaseDir: repoScopedDir };
1574
1891
  log(` Working directory: ${repoScopedDir}`);
@@ -1611,7 +1928,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
1611
1928
  logger,
1612
1929
  routerRelay,
1613
1930
  signal,
1614
- contextBlock
1931
+ contextBlock,
1932
+ githubUsername
1615
1933
  );
1616
1934
  } else {
1617
1935
  await executeReviewTask(
@@ -1629,7 +1947,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
1629
1947
  logger,
1630
1948
  routerRelay,
1631
1949
  signal,
1632
- contextBlock
1950
+ contextBlock,
1951
+ githubUsername
1633
1952
  );
1634
1953
  }
1635
1954
  agentSession.tasksCompleted++;
@@ -1679,10 +1998,21 @@ async function safeError(client, taskId, agentId, error, logger) {
1679
1998
  );
1680
1999
  }
1681
2000
  }
1682
- async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
2001
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock, githubUsername) {
2002
+ if (consumptionDeps.usageLimits?.maxTokensPerReview != null && consumptionDeps.usageTracker) {
2003
+ const estimatedInput = estimateTokens(diffContent + prompt + (contextBlock ?? ""));
2004
+ const perReviewCheck = consumptionDeps.usageTracker.checkPerReviewLimit(
2005
+ estimatedInput,
2006
+ consumptionDeps.usageLimits
2007
+ );
2008
+ if (!perReviewCheck.allowed) {
2009
+ throw new Error(perReviewCheck.reason);
2010
+ }
2011
+ }
1683
2012
  let reviewText;
1684
2013
  let verdict;
1685
2014
  let tokensUsed;
2015
+ let usageOpts;
1686
2016
  if (routerRelay) {
1687
2017
  logger.log(` ${icons.running} Executing review: [router mode]`);
1688
2018
  const fullPrompt = routerRelay.buildReviewPrompt({
@@ -1703,6 +2033,12 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1703
2033
  reviewText = parsed.review;
1704
2034
  verdict = parsed.verdict;
1705
2035
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
2036
+ usageOpts = {
2037
+ inputTokens: estimateTokens(fullPrompt),
2038
+ outputTokens: estimateTokens(response),
2039
+ totalTokens: tokensUsed,
2040
+ estimated: true
2041
+ };
1706
2042
  } else {
1707
2043
  logger.log(` ${icons.running} Executing review: ${reviewDeps.commandTemplate}`);
1708
2044
  const result = await executeReview(
@@ -1722,8 +2058,14 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1722
2058
  reviewText = result.review;
1723
2059
  verdict = result.verdict;
1724
2060
  tokensUsed = result.tokensUsed;
2061
+ usageOpts = {
2062
+ inputTokens: result.tokenDetail.input,
2063
+ outputTokens: result.tokenDetail.output,
2064
+ totalTokens: result.tokensUsed,
2065
+ estimated: result.tokensEstimated
2066
+ };
1725
2067
  }
1726
- const sanitizedReview = sanitizeTokens(reviewText);
2068
+ const sanitizedReview = appendContributorAttribution(sanitizeTokens(reviewText), githubUsername);
1727
2069
  await withRetry(
1728
2070
  () => client.post(`/api/tasks/${taskId}/result`, {
1729
2071
  agent_id: agentId,
@@ -1735,15 +2077,23 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1735
2077
  { maxAttempts: 3 },
1736
2078
  signal
1737
2079
  );
1738
- recordSessionUsage(consumptionDeps.session, tokensUsed);
2080
+ recordSessionUsage(consumptionDeps.session, usageOpts);
2081
+ if (consumptionDeps.usageTracker) {
2082
+ consumptionDeps.usageTracker.recordReview({
2083
+ input: usageOpts.inputTokens,
2084
+ output: usageOpts.outputTokens,
2085
+ estimated: usageOpts.estimated
2086
+ });
2087
+ }
1739
2088
  logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1740
2089
  logger.log(formatPostReviewStats(consumptionDeps.session));
1741
2090
  }
1742
- async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
2091
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock, githubUsername) {
1743
2092
  if (reviews.length === 0) {
1744
2093
  let reviewText;
1745
2094
  let verdict;
1746
2095
  let tokensUsed2;
2096
+ let usageOpts2;
1747
2097
  if (routerRelay) {
1748
2098
  logger.log(` ${icons.running} Executing summary: [router mode]`);
1749
2099
  const fullPrompt = routerRelay.buildReviewPrompt({
@@ -1764,6 +2114,12 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1764
2114
  reviewText = parsed.review;
1765
2115
  verdict = parsed.verdict;
1766
2116
  tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
2117
+ usageOpts2 = {
2118
+ inputTokens: estimateTokens(fullPrompt),
2119
+ outputTokens: estimateTokens(response),
2120
+ totalTokens: tokensUsed2,
2121
+ estimated: true
2122
+ };
1767
2123
  } else {
1768
2124
  logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
1769
2125
  const result = await executeReview(
@@ -1783,8 +2139,17 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1783
2139
  reviewText = result.review;
1784
2140
  verdict = result.verdict;
1785
2141
  tokensUsed2 = result.tokensUsed;
2142
+ usageOpts2 = {
2143
+ inputTokens: result.tokenDetail.input,
2144
+ outputTokens: result.tokenDetail.output,
2145
+ totalTokens: result.tokensUsed,
2146
+ estimated: result.tokensEstimated
2147
+ };
1786
2148
  }
1787
- const sanitizedReview = sanitizeTokens(reviewText);
2149
+ const sanitizedReview = appendContributorAttribution(
2150
+ sanitizeTokens(reviewText),
2151
+ githubUsername
2152
+ );
1788
2153
  await withRetry(
1789
2154
  () => client.post(`/api/tasks/${taskId}/result`, {
1790
2155
  agent_id: agentId,
@@ -1796,7 +2161,14 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1796
2161
  { maxAttempts: 3 },
1797
2162
  signal
1798
2163
  );
1799
- recordSessionUsage(consumptionDeps.session, tokensUsed2);
2164
+ recordSessionUsage(consumptionDeps.session, usageOpts2);
2165
+ if (consumptionDeps.usageTracker) {
2166
+ consumptionDeps.usageTracker.recordReview({
2167
+ input: usageOpts2.inputTokens,
2168
+ output: usageOpts2.outputTokens,
2169
+ estimated: usageOpts2.estimated
2170
+ });
2171
+ }
1800
2172
  logger.log(
1801
2173
  ` ${icons.success} Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`
1802
2174
  );
@@ -1812,6 +2184,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1812
2184
  }));
1813
2185
  let summaryText;
1814
2186
  let tokensUsed;
2187
+ let usageOpts;
1815
2188
  if (routerRelay) {
1816
2189
  logger.log(` ${icons.running} Executing summary: [router mode]`);
1817
2190
  const fullPrompt = routerRelay.buildSummaryPrompt({
@@ -1830,6 +2203,12 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1830
2203
  );
1831
2204
  summaryText = response;
1832
2205
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
2206
+ usageOpts = {
2207
+ inputTokens: estimateTokens(fullPrompt),
2208
+ outputTokens: estimateTokens(response),
2209
+ totalTokens: tokensUsed,
2210
+ estimated: true
2211
+ };
1833
2212
  } else {
1834
2213
  logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
1835
2214
  const result = await executeSummary(
@@ -1848,8 +2227,17 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1848
2227
  );
1849
2228
  summaryText = result.summary;
1850
2229
  tokensUsed = result.tokensUsed;
2230
+ usageOpts = {
2231
+ inputTokens: result.tokenDetail.input,
2232
+ outputTokens: result.tokenDetail.output,
2233
+ totalTokens: result.tokensUsed,
2234
+ estimated: result.tokensEstimated
2235
+ };
1851
2236
  }
1852
- const sanitizedSummary = sanitizeTokens(summaryText);
2237
+ const sanitizedSummary = appendContributorAttribution(
2238
+ sanitizeTokens(summaryText),
2239
+ githubUsername
2240
+ );
1853
2241
  await withRetry(
1854
2242
  () => client.post(`/api/tasks/${taskId}/result`, {
1855
2243
  agent_id: agentId,
@@ -1860,7 +2248,14 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1860
2248
  { maxAttempts: 3 },
1861
2249
  signal
1862
2250
  );
1863
- recordSessionUsage(consumptionDeps.session, tokensUsed);
2251
+ recordSessionUsage(consumptionDeps.session, usageOpts);
2252
+ if (consumptionDeps.usageTracker) {
2253
+ consumptionDeps.usageTracker.recordReview({
2254
+ input: usageOpts.inputTokens,
2255
+ output: usageOpts.outputTokens,
2256
+ estimated: usageOpts.estimated
2257
+ });
2258
+ }
1864
2259
  logger.log(` ${icons.success} Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1865
2260
  logger.log(formatPostReviewStats(consumptionDeps.session));
1866
2261
  }
@@ -1884,7 +2279,17 @@ function sleep2(ms, signal) {
1884
2279
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
1885
2280
  const client = new ApiClient(platformUrl, { apiKey: options?.apiKey });
1886
2281
  const session = consumptionDeps?.session ?? createSessionTracker();
1887
- const deps = consumptionDeps ?? { agentId, session };
2282
+ const usageTracker = consumptionDeps?.usageTracker ?? new UsageTracker();
2283
+ const usageLimits = options?.usageLimits ?? {
2284
+ maxReviewsPerDay: null,
2285
+ maxTokensPerDay: null,
2286
+ maxTokensPerReview: null
2287
+ };
2288
+ const deps = consumptionDeps ? {
2289
+ ...consumptionDeps,
2290
+ usageTracker: consumptionDeps.usageTracker ?? usageTracker,
2291
+ usageLimits: consumptionDeps.usageLimits ?? usageLimits
2292
+ } : { agentId, session, usageTracker, usageLimits };
1888
2293
  const logger = createLogger(options?.label);
1889
2294
  const { log, logError, logWarn } = logger;
1890
2295
  const agentSession = createAgentSession();
@@ -1921,6 +2326,9 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
1921
2326
  githubUsername: options?.githubUsername,
1922
2327
  signal: abortController.signal
1923
2328
  });
2329
+ if (deps.usageTracker) {
2330
+ log(deps.usageTracker.formatSummary(deps.usageLimits ?? usageLimits));
2331
+ }
1924
2332
  log(formatExitSummary(agentSession));
1925
2333
  }
1926
2334
  async function startAgentRouter() {
@@ -1952,6 +2360,7 @@ async function startAgentRouter() {
1952
2360
  codebaseDir
1953
2361
  };
1954
2362
  const session = createSessionTracker();
2363
+ const usageTracker = new UsageTracker();
1955
2364
  const model = agentConfig?.model ?? "unknown";
1956
2365
  const tool = agentConfig?.tool ?? "unknown";
1957
2366
  const label = agentConfig?.name ?? "agent[0]";
@@ -1963,7 +2372,9 @@ async function startAgentRouter() {
1963
2372
  reviewDeps,
1964
2373
  {
1965
2374
  agentId,
1966
- session
2375
+ session,
2376
+ usageTracker,
2377
+ usageLimits: config.usageLimits
1967
2378
  },
1968
2379
  {
1969
2380
  maxConsecutiveErrors: config.maxConsecutiveErrors,
@@ -1974,7 +2385,8 @@ async function startAgentRouter() {
1974
2385
  synthesizeRepos: agentConfig?.synthesize_repos,
1975
2386
  githubUsername,
1976
2387
  label,
1977
- apiKey: config.apiKey
2388
+ apiKey: config.apiKey,
2389
+ usageLimits: config.usageLimits
1978
2390
  }
1979
2391
  );
1980
2392
  router.stop();
@@ -2021,6 +2433,7 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth, githubUsern
2021
2433
  routerRelay.start();
2022
2434
  }
2023
2435
  const session = createSessionTracker();
2436
+ const usageTracker = new UsageTracker();
2024
2437
  const model = agentConfig?.model ?? "unknown";
2025
2438
  const tool = agentConfig?.tool ?? "unknown";
2026
2439
  const roles = agentConfig ? computeRoles(agentConfig) : void 0;
@@ -2029,7 +2442,7 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth, githubUsern
2029
2442
  config.platformUrl,
2030
2443
  { model, tool },
2031
2444
  reviewDeps,
2032
- { agentId, session },
2445
+ { agentId, session, usageTracker, usageLimits: config.usageLimits },
2033
2446
  {
2034
2447
  pollIntervalMs,
2035
2448
  maxConsecutiveErrors: config.maxConsecutiveErrors,
@@ -2040,7 +2453,8 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth, githubUsern
2040
2453
  synthesizeRepos: agentConfig?.synthesize_repos,
2041
2454
  githubUsername,
2042
2455
  label,
2043
- apiKey: config.apiKey
2456
+ apiKey: config.apiKey,
2457
+ usageLimits: config.usageLimits
2044
2458
  }
2045
2459
  ).finally(() => {
2046
2460
  routerRelay?.stop();
@@ -2115,7 +2529,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
2115
2529
  });
2116
2530
 
2117
2531
  // src/index.ts
2118
- var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.14.0");
2532
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.15.0");
2119
2533
  program.addCommand(agentCommand);
2120
2534
  program.action(() => {
2121
2535
  startAgentRouter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",