opencara 0.18.0 → 0.18.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +177 -38
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -84,7 +84,7 @@ Review prompts are delivered via **stdin** to your command. The command reads st
84
84
 
85
85
  | Field | Default | Description |
86
86
  | ------------------------ | -------------------------- | ------------------------------------- |
87
- | `platform_url` | `https://api.opencara.dev` | OpenCara server URL |
87
+ | `platform_url` | `https://api.opencara.com` | OpenCara server URL |
88
88
  | `github_token` | -- | GitHub token for private repo diffs |
89
89
  | `codebase_dir` | -- | Default clone directory for repos |
90
90
  | `max_diff_size_kb` | `100` | Skip PRs with diffs larger than this |
package/dist/index.js CHANGED
@@ -378,7 +378,7 @@ import * as fs from "fs";
378
378
  import * as path from "path";
379
379
  import * as os from "os";
380
380
  import { parse as parseToml2, stringify as stringifyToml } from "smol-toml";
381
- var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
381
+ var DEFAULT_PLATFORM_URL = "https://api.opencara.com";
382
382
  var CONFIG_DIR = path.join(os.homedir(), ".opencara");
383
383
  var CONFIG_FILE = process.env.OPENCARA_CONFIG && process.env.OPENCARA_CONFIG.trim() ? path.resolve(process.env.OPENCARA_CONFIG) : path.join(CONFIG_DIR, "config.toml");
384
384
  function ensureConfigDir() {
@@ -565,7 +565,6 @@ function loadConfig() {
565
565
  const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
566
566
  const defaults = {
567
567
  platformUrl: envPlatformUrl || DEFAULT_PLATFORM_URL,
568
- apiKey: null,
569
568
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
570
569
  maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
571
570
  codebaseDir: null,
@@ -609,7 +608,6 @@ function loadConfig() {
609
608
  }
610
609
  return {
611
610
  platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
612
- apiKey: typeof data.api_key === "string" ? data.api_key.trim() || null : null,
613
611
  maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
614
612
  maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
615
613
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
@@ -816,11 +814,16 @@ To authenticate, visit: ${initData.verification_uri}`);
816
814
  if (Date.now() >= deadline) {
817
815
  break;
818
816
  }
819
- const tokenRes = await fetchFn(`${platformUrl}/api/auth/device/token`, {
820
- method: "POST",
821
- headers: { "Content-Type": "application/json" },
822
- body: JSON.stringify({ device_code: initData.device_code })
823
- });
817
+ let tokenRes;
818
+ try {
819
+ tokenRes = await fetchFn(`${platformUrl}/api/auth/device/token`, {
820
+ method: "POST",
821
+ headers: { "Content-Type": "application/json" },
822
+ body: JSON.stringify({ device_code: initData.device_code })
823
+ });
824
+ } catch {
825
+ continue;
826
+ }
824
827
  if (!tokenRes.ok) {
825
828
  try {
826
829
  await tokenRes.text();
@@ -2195,8 +2198,14 @@ You MUST output ONLY a valid JSON object matching this exact schema (no markdown
2195
2198
 
2196
2199
  - "duplicates": array of matches found (empty array if no duplicates)
2197
2200
  - "similarity": "exact" = identical intent/change, "high" = very similar with minor differences, "partial" = overlapping but distinct
2198
- - "index_entry": a single line in the format: \`- #<number> [label1] [label2] \u2014 <short description>\`
2201
+ - "index_entry": a single line in the format: \`- <number>(<label1>, <label2>, ...): <short description>\` where labels are inferred from GitHub labels, PR/issue title, body, and any available context`);
2202
+ if (task.customPrompt) {
2203
+ parts.push(`
2204
+ ## Repo-Specific Instructions
2199
2205
 
2206
+ ${task.customPrompt}`);
2207
+ }
2208
+ parts.push(`
2200
2209
  ## Index of Existing Items
2201
2210
 
2202
2211
  <UNTRUSTED_CONTENT>`);
@@ -2346,7 +2355,7 @@ async function executeDedup(prompt, timeoutSeconds, deps, runTool = executeTool,
2346
2355
  }
2347
2356
  async function executeDedupTask(client, agentId, taskId, task, diffContent, timeoutSeconds, reviewDeps, consumptionDeps, logger, signal, role = "pr_dedup") {
2348
2357
  logger.log(` ${icons.running} Executing dedup: ${reviewDeps.commandTemplate}`);
2349
- const prompt = buildDedupPrompt({ ...task, diffContent });
2358
+ const prompt = buildDedupPrompt({ ...task, diffContent, customPrompt: task.prompt });
2350
2359
  const result = await executeDedup(
2351
2360
  prompt,
2352
2361
  timeoutSeconds,
@@ -2607,6 +2616,11 @@ function buildTriagePrompt(task) {
2607
2616
  const title = task.issue_title ?? `PR #${task.pr_number}`;
2608
2617
  const rawBody = task.issue_body ?? "";
2609
2618
  const safeBody = truncateToBytes(rawBody, MAX_ISSUE_BODY_BYTES);
2619
+ const repoPromptSection = task.prompt ? `
2620
+
2621
+ ## Repo-Specific Instructions
2622
+
2623
+ ${task.prompt}` : "";
2610
2624
  const userMessage = [
2611
2625
  `## Issue Title`,
2612
2626
  title,
@@ -2616,7 +2630,7 @@ function buildTriagePrompt(task) {
2616
2630
  safeBody,
2617
2631
  "</UNTRUSTED_CONTENT>"
2618
2632
  ].join("\n");
2619
- return `${TRIAGE_SYSTEM_PROMPT}
2633
+ return `${TRIAGE_SYSTEM_PROMPT}${repoPromptSection}
2620
2634
 
2621
2635
  ${userMessage}`;
2622
2636
  }
@@ -3167,7 +3181,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3167
3181
  issue_title: task.issue_title,
3168
3182
  issue_body: task.issue_body,
3169
3183
  diff_url,
3170
- index_issue_body: task.index_issue_body
3184
+ index_issue_body: task.index_issue_body,
3185
+ prompt
3171
3186
  },
3172
3187
  diffContent,
3173
3188
  timeout_seconds,
@@ -3568,7 +3583,7 @@ function sleep2(ms, signal) {
3568
3583
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
3569
3584
  const client = new ApiClient(platformUrl, {
3570
3585
  authToken: options?.authToken,
3571
- cliVersion: "0.18.0",
3586
+ cliVersion: "0.18.2",
3572
3587
  versionOverride: options?.versionOverride,
3573
3588
  onTokenRefresh: options?.onTokenRefresh
3574
3589
  });
@@ -4075,11 +4090,77 @@ async function updateIssueComment(owner, repo, commentId, body, token, fetchFn =
4075
4090
  }
4076
4091
  function formatEntry(item, compact = false) {
4077
4092
  if (compact) {
4078
- return `- #${item.number} \u2014 ${item.title}`;
4093
+ return `- ${item.number}(): ${item.title}`;
4079
4094
  }
4080
- const labels = item.labels.map((l) => `[${l.name}]`).join(" ");
4081
- const labelPart = labels ? ` ${labels}` : "";
4082
- return `- #${item.number}${labelPart} \u2014 ${item.title}`;
4095
+ const labels = item.labels.map((l) => l.name).join(", ");
4096
+ return `- ${item.number}(${labels}): ${item.title}`;
4097
+ }
4098
+ var AI_ENTRY_TIMEOUT_MS = 6e4;
4099
+ function buildIndexEntryPrompt(item, kind) {
4100
+ const typeLabel = kind === "prs" ? "PR" : "Issue";
4101
+ const labels = item.labels.map((l) => l.name).join(", ");
4102
+ return `You are a dedup index entry generator. Given a GitHub ${typeLabel}, produce a concise one-line description suitable for duplicate detection.
4103
+
4104
+ ## Input
4105
+
4106
+ ${typeLabel} #${item.number}: ${item.title}
4107
+ Labels: ${labels || "(none)"}
4108
+ State: ${item.state}
4109
+
4110
+ ## Output Format
4111
+
4112
+ Respond with ONLY a JSON object (no markdown fences, no preamble):
4113
+
4114
+ {
4115
+ "description": "<concise one-line description for duplicate detection>"
4116
+ }
4117
+
4118
+ The description should capture the core intent/change of the ${typeLabel.toLowerCase()} in a way that helps identify duplicates. Keep it under 120 characters.`;
4119
+ }
4120
+ function parseIndexEntryResponse(stdout) {
4121
+ const jsonStr = extractJson(stdout);
4122
+ if (!jsonStr) return null;
4123
+ try {
4124
+ const parsed = JSON.parse(jsonStr);
4125
+ if (typeof parsed.description === "string" && parsed.description.length > 0) {
4126
+ return parsed.description;
4127
+ }
4128
+ } catch {
4129
+ }
4130
+ return null;
4131
+ }
4132
+ function resolveAgentCommand(toolName) {
4133
+ const config = loadConfig();
4134
+ if (config.agents) {
4135
+ const agent = config.agents.find((a) => a.tool === toolName);
4136
+ if (agent) {
4137
+ const cmd = agent.command ?? config.agentCommand;
4138
+ if (cmd) return cmd;
4139
+ }
4140
+ }
4141
+ const registryTool = DEFAULT_REGISTRY.tools.find((t) => t.name === toolName);
4142
+ if (registryTool) {
4143
+ const defaultModel = DEFAULT_REGISTRY.models.find((m) => m.tools.includes(toolName));
4144
+ const modelName = defaultModel?.name ?? "";
4145
+ return registryTool.commandTemplate.replaceAll("${MODEL}", modelName);
4146
+ }
4147
+ return null;
4148
+ }
4149
+ async function generateAIEntry(item, kind, commandTemplate, runTool = executeTool) {
4150
+ const prompt = buildIndexEntryPrompt(item, kind);
4151
+ try {
4152
+ const result = await runTool(commandTemplate, prompt, AI_ENTRY_TIMEOUT_MS);
4153
+ return parseIndexEntryResponse(result.stdout);
4154
+ } catch {
4155
+ return null;
4156
+ }
4157
+ }
4158
+ function formatEntryWithDescription(item, description, compact = false) {
4159
+ if (compact) {
4160
+ return `- ${item.number}(): ${description}`;
4161
+ }
4162
+ const labels = item.labels.map((l) => l.name).join(", ");
4163
+ return `- ${item.number}(${labels}): ${description}`;
4083
4164
  }
4084
4165
  function categorizeItems(items, recentDays = DEFAULT_RECENT_DAYS, nowMs = Date.now()) {
4085
4166
  const cutoff = nowMs - recentDays * 24 * 60 * 60 * 1e3;
@@ -4099,22 +4180,28 @@ function categorizeItems(items, recentDays = DEFAULT_RECENT_DAYS, nowMs = Date.n
4099
4180
  }
4100
4181
  function parseExistingNumbers(body) {
4101
4182
  const numbers = /* @__PURE__ */ new Set();
4102
- const regex = /^- #(\d+)/gm;
4183
+ const regex = /^- #?(\d+)/gm;
4103
4184
  let match;
4104
4185
  while ((match = regex.exec(body)) !== null) {
4105
4186
  numbers.add(parseInt(match[1], 10));
4106
4187
  }
4107
4188
  return numbers;
4108
4189
  }
4109
- function buildCommentBody(marker, header, items, existingBody, compact = false) {
4190
+ function buildCommentBody(marker, header, items, existingBody, compact = false, descriptions) {
4110
4191
  const existingNumbers = existingBody ? parseExistingNumbers(existingBody) : /* @__PURE__ */ new Set();
4111
4192
  const newItems = items.filter((item) => !existingNumbers.has(item.number));
4112
4193
  let body = existingBody ?? `${marker}
4113
4194
  ## ${header}
4114
4195
  `;
4115
4196
  for (const item of newItems) {
4116
- body += `
4197
+ const aiDesc = descriptions?.get(item.number);
4198
+ if (aiDesc) {
4199
+ body += `
4200
+ ${formatEntryWithDescription(item, aiDesc, compact)}`;
4201
+ } else {
4202
+ body += `
4117
4203
  ${formatEntry(item, compact)}`;
4204
+ }
4118
4205
  }
4119
4206
  return body;
4120
4207
  }
@@ -4134,6 +4221,7 @@ async function initIndex(opts) {
4134
4221
  const fetchFn = opts.fetchFn ?? fetch;
4135
4222
  const log = opts.log ?? (() => {
4136
4223
  });
4224
+ const runTool = opts.runTool ?? executeTool;
4137
4225
  log(`Scanning ${kind}...`);
4138
4226
  const items = kind === "prs" ? await fetchAllPRs(owner, repo, token, fetchFn, log) : await fetchAllIssues(owner, repo, token, fetchFn, log);
4139
4227
  log(`${icons.info} Found ${items.length} ${kind}.`);
@@ -4143,34 +4231,64 @@ async function initIndex(opts) {
4143
4231
  );
4144
4232
  const comments = await fetchIssueComments2(owner, repo, indexIssue, token, fetchFn);
4145
4233
  const found = findIndexComments(comments);
4146
- const openBody = buildCommentBody(OPEN_MARKER, "Open Items", open, found.open?.body ?? null);
4234
+ const existingOpen = found.open ? parseExistingNumbers(found.open.body) : /* @__PURE__ */ new Set();
4235
+ const existingRecent = found.recent ? parseExistingNumbers(found.recent.body) : /* @__PURE__ */ new Set();
4236
+ const existingArchived = found.archived ? parseExistingNumbers(found.archived.body) : /* @__PURE__ */ new Set();
4237
+ const newOpenItems = open.filter((i) => !existingOpen.has(i.number));
4238
+ const newRecentItems = recentlyClosed.filter((i) => !existingRecent.has(i.number));
4239
+ const newArchivedItems = archived.filter((i) => !existingArchived.has(i.number));
4240
+ const newEntries = newOpenItems.length + newRecentItems.length + newArchivedItems.length;
4241
+ const descriptions = /* @__PURE__ */ new Map();
4242
+ if (opts.agentCommandTemplate && newEntries > 0) {
4243
+ const allNewItems = [...newOpenItems, ...newRecentItems, ...newArchivedItems];
4244
+ log(`
4245
+ Generating AI-enriched descriptions for ${allNewItems.length} items...`);
4246
+ for (let i = 0; i < allNewItems.length; i++) {
4247
+ const item = allNewItems[i];
4248
+ log(` Processing item ${i + 1}/${allNewItems.length} (#${item.number})...`);
4249
+ const desc = await generateAIEntry(item, kind, opts.agentCommandTemplate, runTool);
4250
+ if (desc) {
4251
+ descriptions.set(item.number, desc);
4252
+ } else {
4253
+ log(` ${icons.warn} AI failed for #${item.number}, using raw title`);
4254
+ }
4255
+ }
4256
+ const enriched = descriptions.size;
4257
+ log(
4258
+ `${icons.info} AI enrichment: ${enriched}/${allNewItems.length} items enriched successfully`
4259
+ );
4260
+ }
4261
+ const openBody = buildCommentBody(
4262
+ OPEN_MARKER,
4263
+ "Open Items",
4264
+ open,
4265
+ found.open?.body ?? null,
4266
+ false,
4267
+ descriptions
4268
+ );
4147
4269
  const recentBody = buildCommentBody(
4148
4270
  RECENT_MARKER,
4149
4271
  "Recently Closed Items",
4150
4272
  recentlyClosed,
4151
- found.recent?.body ?? null
4273
+ found.recent?.body ?? null,
4274
+ false,
4275
+ descriptions
4152
4276
  );
4153
4277
  const archivedBody = buildCommentBody(
4154
4278
  ARCHIVED_MARKER,
4155
4279
  "Archived Items",
4156
4280
  archived,
4157
4281
  found.archived?.body ?? null,
4158
- true
4282
+ true,
4159
4283
  // compact format
4284
+ descriptions
4160
4285
  );
4161
- const existingOpen = found.open ? parseExistingNumbers(found.open.body) : /* @__PURE__ */ new Set();
4162
- const existingRecent = found.recent ? parseExistingNumbers(found.recent.body) : /* @__PURE__ */ new Set();
4163
- const existingArchived = found.archived ? parseExistingNumbers(found.archived.body) : /* @__PURE__ */ new Set();
4164
- const newOpen = open.filter((i) => !existingOpen.has(i.number)).length;
4165
- const newRecent = recentlyClosed.filter((i) => !existingRecent.has(i.number)).length;
4166
- const newArchived = archived.filter((i) => !existingArchived.has(i.number)).length;
4167
- const newEntries = newOpen + newRecent + newArchived;
4168
4286
  if (dryRun) {
4169
4287
  log(`
4170
4288
  ${icons.info} Dry run \u2014 would update index issue #${indexIssue}:`);
4171
- log(` Open Items: ${open.length} entries (${newOpen} new)`);
4172
- log(` Recently Closed: ${recentlyClosed.length} entries (${newRecent} new)`);
4173
- log(` Archived: ${archived.length} entries (${newArchived} new)`);
4289
+ log(` Open Items: ${open.length} entries (${newOpenItems.length} new)`);
4290
+ log(` Recently Closed: ${recentlyClosed.length} entries (${newRecentItems.length} new)`);
4291
+ log(` Archived: ${archived.length} entries (${newArchivedItems.length} new)`);
4174
4292
  return {
4175
4293
  openCount: open.length,
4176
4294
  recentCount: recentlyClosed.length,
@@ -4209,6 +4327,7 @@ async function runDedupInit(options, deps = {}) {
4209
4327
  const log = deps.log ?? console.log;
4210
4328
  const logError = deps.logError ?? console.error;
4211
4329
  const loadAuthFn = deps.loadAuthFn ?? loadAuth;
4330
+ const resolveCmd = deps.resolveAgentCommandFn ?? resolveAgentCommand;
4212
4331
  const auth = loadAuthFn();
4213
4332
  if (!auth || auth.expires_at <= Date.now()) {
4214
4333
  logError(`${icons.error} Not authenticated. Run: ${pc3.cyan("opencara auth login")}`);
@@ -4273,6 +4392,19 @@ async function runDedupInit(options, deps = {}) {
4273
4392
  process.exitCode = 1;
4274
4393
  return;
4275
4394
  }
4395
+ let agentCommandTemplate;
4396
+ if (options.agent) {
4397
+ const cmd = resolveCmd(options.agent);
4398
+ if (!cmd) {
4399
+ logError(
4400
+ `${icons.error} Unknown agent tool "${options.agent}". Available: ${DEFAULT_REGISTRY.tools.map((t) => t.name).join(", ")}`
4401
+ );
4402
+ process.exitCode = 1;
4403
+ return;
4404
+ }
4405
+ agentCommandTemplate = cmd;
4406
+ log(`Using AI agent "${options.agent}" for enriched descriptions`);
4407
+ }
4276
4408
  for (const target of filteredTargets) {
4277
4409
  log(`
4278
4410
  ${pc3.bold(`Initializing ${target.kind} dedup index (issue #${target.indexIssue})...`)}`);
@@ -4284,16 +4416,23 @@ ${pc3.bold(`Initializing ${target.kind} dedup index (issue #${target.indexIssue}
4284
4416
  recentDays,
4285
4417
  dryRun: options.dryRun ?? false,
4286
4418
  token,
4419
+ agentCommandTemplate,
4287
4420
  fetchFn,
4288
- log
4421
+ log,
4422
+ runTool: deps.runTool
4289
4423
  });
4290
4424
  }
4291
4425
  }
4292
4426
  function dedupCommand() {
4293
4427
  const dedup = new Command3("dedup").description("Dedup index management");
4294
- dedup.command("init").description("Scan existing PRs/issues and populate dedup index").requiredOption("--repo <owner/repo>", "Target repository (e.g., OpenCara/OpenCara)").option("--all", "Initialize both PR and issue dedup indexes").option("--dry-run", "Show what would be done without making changes").option("--days <number>", "Recently closed window in days (default: 30)", "30").action(async (options) => {
4295
- await runDedupInit(options);
4296
- });
4428
+ dedup.command("init").description("Scan existing PRs/issues and populate dedup index").requiredOption("--repo <owner/repo>", "Target repository (e.g., OpenCara/OpenCara)").option("--all", "Initialize both PR and issue dedup indexes").option("--dry-run", "Show what would be done without making changes").option("--days <number>", "Recently closed window in days (default: 30)", "30").option(
4429
+ "--agent <tool-name>",
4430
+ "Use AI agent to generate enriched descriptions (e.g., claude, codex, gemini, qwen)"
4431
+ ).action(
4432
+ async (options) => {
4433
+ await runDedupInit(options);
4434
+ }
4435
+ );
4297
4436
  return dedup;
4298
4437
  }
4299
4438
 
@@ -4424,7 +4563,7 @@ var statusCommand = new Command4("status").description("Show agent config, conne
4424
4563
  });
4425
4564
 
4426
4565
  // src/index.ts
4427
- var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.0");
4566
+ var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.2");
4428
4567
  program.addCommand(agentCommand);
4429
4568
  program.addCommand(authCommand());
4430
4569
  program.addCommand(dedupCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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",
@@ -30,10 +30,13 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "bin": {
33
- "opencara": "dist/index.js"
33
+ "opencara": "dist/index.js",
34
+ "opencara-codex-agent": "bin/opencara-codex-agent",
35
+ "opencara-gemini-agent": "bin/opencara-gemini-agent"
34
36
  },
35
37
  "files": [
36
38
  "dist",
39
+ "bin",
37
40
  "README.md"
38
41
  ],
39
42
  "scripts": {