opencara 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/dist/index.js +447 -86
- package/package.json +29 -5
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# opencara
|
|
2
|
+
|
|
3
|
+
Distributed AI code review agent for GitHub pull requests. Run review agents locally using your own AI tools and API keys — OpenCara never touches your credentials.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
GitHub PR → OpenCara server creates review task
|
|
9
|
+
→ Your agent polls for tasks → Claims one → Fetches diff from GitHub
|
|
10
|
+
→ Reviews locally with your AI tool → Submits result
|
|
11
|
+
→ Server posts the review to the PR
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 1. Install
|
|
18
|
+
npm i -g opencara
|
|
19
|
+
|
|
20
|
+
# 2. Create config
|
|
21
|
+
mkdir -p ~/.opencara
|
|
22
|
+
cat > ~/.opencara/config.yml << 'EOF'
|
|
23
|
+
platform_url: https://opencara-server.opencara.workers.dev
|
|
24
|
+
agents:
|
|
25
|
+
- model: claude-sonnet-4-6
|
|
26
|
+
tool: claude
|
|
27
|
+
command: claude --model claude-sonnet-4-6 --allowedTools '*' --print
|
|
28
|
+
EOF
|
|
29
|
+
|
|
30
|
+
# 3. Start
|
|
31
|
+
opencara agent start
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Your agent is now polling for review tasks every 10 seconds.
|
|
35
|
+
|
|
36
|
+
## Supported AI Tools
|
|
37
|
+
|
|
38
|
+
| Tool | Install | Example models |
|
|
39
|
+
| ---------- | ------------------------------------ | ---------------------------------- |
|
|
40
|
+
| Claude | `npm i -g @anthropic-ai/claude-code` | claude-sonnet-4-6, claude-opus-4-6 |
|
|
41
|
+
| Codex | `npm i -g @openai/codex` | gpt-5.4-codex |
|
|
42
|
+
| Gemini CLI | `npm i -g @google/gemini-cli` | gemini-2.5-pro, gemini-2.5-flash |
|
|
43
|
+
| Qwen CLI | `npm i -g qwen` | qwen3.5-plus, glm-5, kimi-k2.5 |
|
|
44
|
+
|
|
45
|
+
Each tool requires its own API key configured per its documentation.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Edit `~/.opencara/config.yml`:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
platform_url: https://opencara-server.opencara.workers.dev
|
|
53
|
+
|
|
54
|
+
agents:
|
|
55
|
+
- model: claude-sonnet-4-6
|
|
56
|
+
tool: claude
|
|
57
|
+
command: claude --model claude-sonnet-4-6 --allowedTools '*' --print
|
|
58
|
+
|
|
59
|
+
- model: gemini-2.5-pro
|
|
60
|
+
tool: gemini
|
|
61
|
+
command: gemini -m gemini-2.5-pro
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Review prompts are delivered via **stdin** to your command. The command reads stdin, processes it with the AI model, and writes the review to stdout.
|
|
65
|
+
|
|
66
|
+
### Agent Config Fields
|
|
67
|
+
|
|
68
|
+
| Field | Required | Default | Description |
|
|
69
|
+
| -------------- | -------- | ------- | ------------------------------------------------------ |
|
|
70
|
+
| `model` | Yes | -- | AI model identifier (e.g., `claude-sonnet-4-6`) |
|
|
71
|
+
| `tool` | Yes | -- | AI tool identifier (e.g., `claude`, `codex`, `gemini`) |
|
|
72
|
+
| `command` | Yes\* | -- | Shell command to execute reviews (stdin -> stdout) |
|
|
73
|
+
| `name` | No | -- | Display name in CLI logs (local only) |
|
|
74
|
+
| `review_only` | No | `false` | If `true`, agent only reviews, never synthesizes |
|
|
75
|
+
| `github_token` | No | -- | Per-agent GitHub token for private repos |
|
|
76
|
+
| `codebase_dir` | No | -- | Local clone directory for context-aware reviews |
|
|
77
|
+
| `repos` | No | -- | Repo filtering (mode: all/own/whitelist/blacklist) |
|
|
78
|
+
|
|
79
|
+
\*Required unless `agent_command` is set globally.
|
|
80
|
+
|
|
81
|
+
### Global Config Fields
|
|
82
|
+
|
|
83
|
+
| Field | Default | Description |
|
|
84
|
+
| ------------------------ | -------------------------- | ------------------------------------- |
|
|
85
|
+
| `platform_url` | `https://api.opencara.dev` | OpenCara server URL |
|
|
86
|
+
| `github_token` | -- | GitHub token for private repo diffs |
|
|
87
|
+
| `codebase_dir` | -- | Default clone directory for repos |
|
|
88
|
+
| `max_diff_size_kb` | `100` | Skip PRs with diffs larger than this |
|
|
89
|
+
| `max_consecutive_errors` | `10` | Stop agent after N consecutive errors |
|
|
90
|
+
|
|
91
|
+
## CLI Reference
|
|
92
|
+
|
|
93
|
+
### Commands
|
|
94
|
+
|
|
95
|
+
| Command | Description |
|
|
96
|
+
| ---------------------- | ----------------------------------------------- |
|
|
97
|
+
| `opencara` | Start agent in router mode (stdin/stdout relay) |
|
|
98
|
+
| `opencara agent start` | Start an agent in polling mode |
|
|
99
|
+
|
|
100
|
+
### `opencara agent start` Options
|
|
101
|
+
|
|
102
|
+
| Option | Default | Description |
|
|
103
|
+
| --------------------------- | ------- | ---------------------------------------- |
|
|
104
|
+
| `--agent <index>` | `0` | Agent index from config.yml (0-based) |
|
|
105
|
+
| `--all` | -- | Start all configured agents concurrently |
|
|
106
|
+
| `--poll-interval <seconds>` | `10` | Poll interval in seconds |
|
|
107
|
+
|
|
108
|
+
## Environment Variables
|
|
109
|
+
|
|
110
|
+
| Variable | Description |
|
|
111
|
+
| ----------------------- | ------------------------------------------------------------------ |
|
|
112
|
+
| `OPENCARA_CONFIG` | Path to alternate config file (overrides `~/.opencara/config.yml`) |
|
|
113
|
+
| `OPENCARA_PLATFORM_URL` | Override the platform URL from config |
|
|
114
|
+
| `GITHUB_TOKEN` | GitHub token (fallback for private repo access) |
|
|
115
|
+
|
|
116
|
+
## Private Repos
|
|
117
|
+
|
|
118
|
+
The CLI resolves GitHub tokens using a fallback chain:
|
|
119
|
+
|
|
120
|
+
1. `GITHUB_TOKEN` environment variable
|
|
121
|
+
2. `gh auth token` (if GitHub CLI is installed)
|
|
122
|
+
3. `github_token` in config.yml (global or per-agent)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,84 @@ function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// ../shared/dist/api.js
|
|
32
|
+
var DEFAULT_REGISTRY = {
|
|
33
|
+
tools: [
|
|
34
|
+
{
|
|
35
|
+
name: "claude",
|
|
36
|
+
displayName: "Claude",
|
|
37
|
+
binary: "claude",
|
|
38
|
+
commandTemplate: "claude --model ${MODEL} --allowedTools '*' --print",
|
|
39
|
+
tokenParser: "claude"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "codex",
|
|
43
|
+
displayName: "Codex",
|
|
44
|
+
binary: "codex",
|
|
45
|
+
commandTemplate: "codex --model ${MODEL} exec",
|
|
46
|
+
tokenParser: "codex"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "gemini",
|
|
50
|
+
displayName: "Gemini",
|
|
51
|
+
binary: "gemini",
|
|
52
|
+
commandTemplate: "gemini -m ${MODEL}",
|
|
53
|
+
tokenParser: "gemini"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "qwen",
|
|
57
|
+
displayName: "Qwen",
|
|
58
|
+
binary: "qwen",
|
|
59
|
+
commandTemplate: "qwen --model ${MODEL} -y",
|
|
60
|
+
tokenParser: "qwen"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
models: [
|
|
64
|
+
{
|
|
65
|
+
name: "claude-opus-4-6",
|
|
66
|
+
displayName: "Claude Opus 4.6",
|
|
67
|
+
tools: ["claude"]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "claude-opus-4-6[1m]",
|
|
71
|
+
displayName: "Claude Opus 4.6 (1M context)",
|
|
72
|
+
tools: ["claude"]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "claude-sonnet-4-6",
|
|
76
|
+
displayName: "Claude Sonnet 4.6",
|
|
77
|
+
tools: ["claude"]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "claude-sonnet-4-6[1m]",
|
|
81
|
+
displayName: "Claude Sonnet 4.6 (1M context)",
|
|
82
|
+
tools: ["claude"]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "gpt-5-codex",
|
|
86
|
+
displayName: "GPT-5 Codex",
|
|
87
|
+
tools: ["codex"]
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "gemini-2.5-pro",
|
|
91
|
+
displayName: "Gemini 2.5 Pro",
|
|
92
|
+
tools: ["gemini"]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "qwen3.5-plus",
|
|
96
|
+
displayName: "Qwen 3.5 Plus",
|
|
97
|
+
tools: ["qwen"]
|
|
98
|
+
},
|
|
99
|
+
{ name: "glm-5", displayName: "GLM-5", tools: ["qwen"] },
|
|
100
|
+
{ name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"] },
|
|
101
|
+
{
|
|
102
|
+
name: "minimax-m2.5",
|
|
103
|
+
displayName: "Minimax M2.5",
|
|
104
|
+
tools: ["qwen"]
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
|
|
31
109
|
// ../shared/dist/review-config.js
|
|
32
110
|
import { parse as parseYaml } from "yaml";
|
|
33
111
|
|
|
@@ -49,6 +127,16 @@ var RepoConfigError = class extends Error {
|
|
|
49
127
|
this.name = "RepoConfigError";
|
|
50
128
|
}
|
|
51
129
|
};
|
|
130
|
+
var ConfigValidationError = class extends Error {
|
|
131
|
+
constructor(message) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "ConfigValidationError";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var KNOWN_TOOL_NAMES = new Set(DEFAULT_REGISTRY.tools.map((t) => t.name));
|
|
137
|
+
var TOOL_ALIASES = {
|
|
138
|
+
"claude-code": "claude"
|
|
139
|
+
};
|
|
52
140
|
function parseRepoConfig(obj, index) {
|
|
53
141
|
const raw = obj.repos;
|
|
54
142
|
if (raw === void 0 || raw === null) return void 0;
|
|
@@ -92,15 +180,33 @@ function parseAgents(data) {
|
|
|
92
180
|
for (let i = 0; i < raw.length; i++) {
|
|
93
181
|
const entry = raw[i];
|
|
94
182
|
if (!entry || typeof entry !== "object") {
|
|
95
|
-
console.warn(
|
|
183
|
+
console.warn(`\u26A0 Config warning: agents[${i}] is not an object, skipping agent`);
|
|
96
184
|
continue;
|
|
97
185
|
}
|
|
98
186
|
const obj = entry;
|
|
99
187
|
if (typeof obj.model !== "string" || typeof obj.tool !== "string") {
|
|
100
|
-
console.warn(
|
|
188
|
+
console.warn(
|
|
189
|
+
`\u26A0 Config warning: agents[${i}] missing required model/tool fields, skipping agent`
|
|
190
|
+
);
|
|
101
191
|
continue;
|
|
102
192
|
}
|
|
103
|
-
|
|
193
|
+
let resolvedTool = obj.tool;
|
|
194
|
+
if (!KNOWN_TOOL_NAMES.has(resolvedTool)) {
|
|
195
|
+
const alias = TOOL_ALIASES[resolvedTool];
|
|
196
|
+
if (alias) {
|
|
197
|
+
console.warn(
|
|
198
|
+
`\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" is deprecated, using "${alias}" instead`
|
|
199
|
+
);
|
|
200
|
+
resolvedTool = alias;
|
|
201
|
+
} else {
|
|
202
|
+
const toolNames = [...KNOWN_TOOL_NAMES].join(", ");
|
|
203
|
+
console.warn(
|
|
204
|
+
`\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" not in registry (known: ${toolNames}), skipping agent`
|
|
205
|
+
);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const agent = { model: obj.model, tool: resolvedTool };
|
|
104
210
|
if (typeof obj.name === "string") agent.name = obj.name;
|
|
105
211
|
if (typeof obj.command === "string") agent.command = obj.command;
|
|
106
212
|
if (obj.router === true) agent.router = true;
|
|
@@ -113,6 +219,35 @@ function parseAgents(data) {
|
|
|
113
219
|
}
|
|
114
220
|
return agents;
|
|
115
221
|
}
|
|
222
|
+
function isValidHttpUrl(value) {
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(value);
|
|
225
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function validateConfigData(data, envPlatformUrl) {
|
|
231
|
+
const overrides = {};
|
|
232
|
+
if (!envPlatformUrl && typeof data.platform_url === "string" && !isValidHttpUrl(data.platform_url)) {
|
|
233
|
+
throw new ConfigValidationError(
|
|
234
|
+
`\u2717 Config error: platform_url "${data.platform_url}" is not a valid URL`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (typeof data.max_diff_size_kb === "number" && data.max_diff_size_kb <= 0) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`\u26A0 Config warning: max_diff_size_kb must be > 0, got ${data.max_diff_size_kb}, using default (${DEFAULT_MAX_DIFF_SIZE_KB})`
|
|
240
|
+
);
|
|
241
|
+
overrides.maxDiffSizeKb = DEFAULT_MAX_DIFF_SIZE_KB;
|
|
242
|
+
}
|
|
243
|
+
if (typeof data.max_consecutive_errors === "number" && data.max_consecutive_errors <= 0) {
|
|
244
|
+
console.warn(
|
|
245
|
+
`\u26A0 Config warning: max_consecutive_errors must be > 0, got ${data.max_consecutive_errors}, using default (${DEFAULT_MAX_CONSECUTIVE_ERRORS})`
|
|
246
|
+
);
|
|
247
|
+
overrides.maxConsecutiveErrors = DEFAULT_MAX_CONSECUTIVE_ERRORS;
|
|
248
|
+
}
|
|
249
|
+
return overrides;
|
|
250
|
+
}
|
|
116
251
|
function loadConfig() {
|
|
117
252
|
const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
|
|
118
253
|
const defaults = {
|
|
@@ -132,10 +267,11 @@ function loadConfig() {
|
|
|
132
267
|
if (!data || typeof data !== "object") {
|
|
133
268
|
return defaults;
|
|
134
269
|
}
|
|
270
|
+
const overrides = validateConfigData(data, envPlatformUrl);
|
|
135
271
|
return {
|
|
136
272
|
platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
|
|
137
|
-
maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
|
|
138
|
-
maxConsecutiveErrors: typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
273
|
+
maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
|
|
274
|
+
maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
|
|
139
275
|
githubToken: typeof data.github_token === "string" ? data.github_token : null,
|
|
140
276
|
codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
|
|
141
277
|
agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
|
|
@@ -267,9 +403,10 @@ function logAuthMethod(method, log) {
|
|
|
267
403
|
|
|
268
404
|
// src/http.ts
|
|
269
405
|
var HttpError = class extends Error {
|
|
270
|
-
constructor(status, message) {
|
|
406
|
+
constructor(status, message, errorCode) {
|
|
271
407
|
super(message);
|
|
272
408
|
this.status = status;
|
|
409
|
+
this.errorCode = errorCode;
|
|
273
410
|
this.name = "HttpError";
|
|
274
411
|
}
|
|
275
412
|
};
|
|
@@ -307,13 +444,17 @@ var ApiClient = class {
|
|
|
307
444
|
async handleResponse(res, path5) {
|
|
308
445
|
if (!res.ok) {
|
|
309
446
|
let message = `HTTP ${res.status}`;
|
|
447
|
+
let errorCode;
|
|
310
448
|
try {
|
|
311
449
|
const body = await res.json();
|
|
312
|
-
if (body.error
|
|
450
|
+
if (body.error && typeof body.error === "object" && "code" in body.error) {
|
|
451
|
+
errorCode = body.error.code;
|
|
452
|
+
message = body.error.message;
|
|
453
|
+
}
|
|
313
454
|
} catch {
|
|
314
455
|
}
|
|
315
456
|
this.log(`${res.status} ${message} (${path5})`);
|
|
316
|
-
throw new HttpError(res.status, message);
|
|
457
|
+
throw new HttpError(res.status, message, errorCode);
|
|
317
458
|
}
|
|
318
459
|
this.log(`${res.status} OK (${path5})`);
|
|
319
460
|
return await res.json();
|
|
@@ -611,12 +752,13 @@ function buildSystemPrompt(owner, repo, mode = "full") {
|
|
|
611
752
|
const template = mode === "compact" ? COMPACT_SYSTEM_PROMPT_TEMPLATE : FULL_SYSTEM_PROMPT_TEMPLATE;
|
|
612
753
|
return template.replace("{owner}", owner).replace("{repo}", repo);
|
|
613
754
|
}
|
|
614
|
-
function buildUserMessage(prompt, diffContent) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
755
|
+
function buildUserMessage(prompt, diffContent, contextBlock) {
|
|
756
|
+
const parts = [prompt];
|
|
757
|
+
if (contextBlock) {
|
|
758
|
+
parts.push(contextBlock);
|
|
759
|
+
}
|
|
760
|
+
parts.push(diffContent);
|
|
761
|
+
return parts.join("\n\n---\n\n");
|
|
620
762
|
}
|
|
621
763
|
var SECTION_VERDICT_PATTERN = /##\s*Verdict\s*\n+\s*(APPROVE|REQUEST_CHANGES|COMMENT)\b/im;
|
|
622
764
|
var LEGACY_VERDICT_PATTERN = /^VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)\s*$/m;
|
|
@@ -656,7 +798,7 @@ async function executeReview(req, deps, runTool = executeTool) {
|
|
|
656
798
|
}, effectiveTimeout);
|
|
657
799
|
try {
|
|
658
800
|
const systemPrompt = buildSystemPrompt(req.owner, req.repo, req.reviewMode);
|
|
659
|
-
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
801
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
|
|
660
802
|
const fullPrompt = `${systemPrompt}
|
|
661
803
|
|
|
662
804
|
${userMessage}`;
|
|
@@ -728,27 +870,28 @@ For each finding, explain clearly what the problem is and how to fix it.
|
|
|
728
870
|
## Verdict
|
|
729
871
|
APPROVE | REQUEST_CHANGES | COMMENT`;
|
|
730
872
|
}
|
|
731
|
-
function buildSummaryUserMessage(prompt, reviews, diffContent) {
|
|
873
|
+
function buildSummaryUserMessage(prompt, reviews, diffContent, contextBlock) {
|
|
732
874
|
const reviewSections = reviews.map((r) => `### Review by ${r.model}/${r.tool} (Verdict: ${r.verdict})
|
|
733
875
|
${r.review}`).join("\n\n");
|
|
734
|
-
|
|
735
|
-
${prompt}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
Pull request diff:
|
|
740
|
-
|
|
741
|
-
${diffContent}
|
|
742
|
-
|
|
743
|
-
---
|
|
876
|
+
const parts = [`Project review guidelines:
|
|
877
|
+
${prompt}`];
|
|
878
|
+
if (contextBlock) {
|
|
879
|
+
parts.push(contextBlock);
|
|
880
|
+
}
|
|
881
|
+
parts.push(`Pull request diff:
|
|
744
882
|
|
|
745
|
-
|
|
883
|
+
${diffContent}`);
|
|
884
|
+
parts.push(`Compact reviews from other agents:
|
|
746
885
|
|
|
747
|
-
${reviewSections}
|
|
886
|
+
${reviewSections}`);
|
|
887
|
+
return parts.join("\n\n---\n\n");
|
|
748
888
|
}
|
|
749
|
-
function calculateInputSize(prompt, reviews, diffContent) {
|
|
889
|
+
function calculateInputSize(prompt, reviews, diffContent, contextBlock) {
|
|
750
890
|
let size = Buffer.byteLength(prompt, "utf-8");
|
|
751
891
|
size += Buffer.byteLength(diffContent, "utf-8");
|
|
892
|
+
if (contextBlock) {
|
|
893
|
+
size += Buffer.byteLength(contextBlock, "utf-8");
|
|
894
|
+
}
|
|
752
895
|
for (const r of reviews) {
|
|
753
896
|
size += Buffer.byteLength(r.review, "utf-8");
|
|
754
897
|
size += Buffer.byteLength(r.model, "utf-8");
|
|
@@ -758,7 +901,7 @@ function calculateInputSize(prompt, reviews, diffContent) {
|
|
|
758
901
|
return size;
|
|
759
902
|
}
|
|
760
903
|
async function executeSummary(req, deps, runTool = executeTool) {
|
|
761
|
-
const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent);
|
|
904
|
+
const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent, req.contextBlock);
|
|
762
905
|
if (inputSize > MAX_INPUT_SIZE_BYTES) {
|
|
763
906
|
throw new InputTooLargeError(
|
|
764
907
|
`Summary input too large (${Math.round(inputSize / 1024)}KB > ${Math.round(MAX_INPUT_SIZE_BYTES / 1024)}KB limit)`
|
|
@@ -775,7 +918,12 @@ async function executeSummary(req, deps, runTool = executeTool) {
|
|
|
775
918
|
}, effectiveTimeout);
|
|
776
919
|
try {
|
|
777
920
|
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
778
|
-
const userMessage = buildSummaryUserMessage(
|
|
921
|
+
const userMessage = buildSummaryUserMessage(
|
|
922
|
+
req.prompt,
|
|
923
|
+
req.reviews,
|
|
924
|
+
req.diffContent,
|
|
925
|
+
req.contextBlock
|
|
926
|
+
);
|
|
779
927
|
const fullPrompt = `${systemPrompt}
|
|
780
928
|
|
|
781
929
|
${userMessage}`;
|
|
@@ -874,7 +1022,7 @@ var RouterRelay = class {
|
|
|
874
1022
|
req.repo,
|
|
875
1023
|
req.reviewMode
|
|
876
1024
|
);
|
|
877
|
-
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
1025
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
|
|
878
1026
|
return `${systemPrompt}
|
|
879
1027
|
|
|
880
1028
|
${userMessage}`;
|
|
@@ -882,7 +1030,12 @@ ${userMessage}`;
|
|
|
882
1030
|
/** Build the full prompt for a summary request */
|
|
883
1031
|
buildSummaryPrompt(req) {
|
|
884
1032
|
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
885
|
-
const userMessage = buildSummaryUserMessage(
|
|
1033
|
+
const userMessage = buildSummaryUserMessage(
|
|
1034
|
+
req.prompt,
|
|
1035
|
+
req.reviews,
|
|
1036
|
+
req.diffContent,
|
|
1037
|
+
req.contextBlock
|
|
1038
|
+
);
|
|
886
1039
|
return `${systemPrompt}
|
|
887
1040
|
|
|
888
1041
|
${userMessage}`;
|
|
@@ -961,18 +1114,196 @@ function formatPostReviewStats(session) {
|
|
|
961
1114
|
return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
|
|
962
1115
|
}
|
|
963
1116
|
|
|
964
|
-
// src/
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1117
|
+
// src/pr-context.ts
|
|
1118
|
+
async function githubGet(url, deps) {
|
|
1119
|
+
const headers = {
|
|
1120
|
+
Accept: "application/vnd.github+json"
|
|
1121
|
+
};
|
|
1122
|
+
if (deps.githubToken) {
|
|
1123
|
+
headers["Authorization"] = `Bearer ${deps.githubToken}`;
|
|
1124
|
+
}
|
|
1125
|
+
const response = await fetch(url, { headers, signal: deps.signal });
|
|
1126
|
+
if (!response.ok) {
|
|
1127
|
+
throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
|
|
1128
|
+
}
|
|
1129
|
+
return response.json();
|
|
1130
|
+
}
|
|
1131
|
+
async function fetchPRMetadata(owner, repo, prNumber, deps) {
|
|
1132
|
+
try {
|
|
1133
|
+
const pr = await githubGet(
|
|
1134
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
1135
|
+
deps
|
|
1136
|
+
);
|
|
1137
|
+
return {
|
|
1138
|
+
title: pr.title,
|
|
1139
|
+
body: pr.body ?? "",
|
|
1140
|
+
author: pr.user?.login ?? "unknown",
|
|
1141
|
+
labels: pr.labels.map((l) => l.name),
|
|
1142
|
+
baseBranch: pr.base.ref,
|
|
1143
|
+
headBranch: pr.head.ref
|
|
1144
|
+
};
|
|
1145
|
+
} catch {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
async function fetchIssueComments(owner, repo, prNumber, deps) {
|
|
1150
|
+
try {
|
|
1151
|
+
const comments = await githubGet(
|
|
1152
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
|
|
1153
|
+
deps
|
|
1154
|
+
);
|
|
1155
|
+
return comments.map((c) => ({
|
|
1156
|
+
author: c.user?.login ?? "unknown",
|
|
1157
|
+
body: c.body,
|
|
1158
|
+
createdAt: c.created_at
|
|
1159
|
+
}));
|
|
1160
|
+
} catch {
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
async function fetchReviewComments(owner, repo, prNumber, deps) {
|
|
1165
|
+
try {
|
|
1166
|
+
const comments = await githubGet(
|
|
1167
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
|
1168
|
+
deps
|
|
1169
|
+
);
|
|
1170
|
+
return comments.map((c) => ({
|
|
1171
|
+
author: c.user?.login ?? "unknown",
|
|
1172
|
+
body: c.body,
|
|
1173
|
+
path: c.path,
|
|
1174
|
+
line: c.line,
|
|
1175
|
+
createdAt: c.created_at
|
|
1176
|
+
}));
|
|
1177
|
+
} catch {
|
|
1178
|
+
return [];
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
async function fetchExistingReviews(owner, repo, prNumber, deps) {
|
|
1182
|
+
try {
|
|
1183
|
+
const reviews = await githubGet(
|
|
1184
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
|
|
1185
|
+
deps
|
|
1186
|
+
);
|
|
1187
|
+
return reviews.filter((r) => r.state !== "PENDING").map((r) => ({
|
|
1188
|
+
author: r.user?.login ?? "unknown",
|
|
1189
|
+
state: r.state,
|
|
1190
|
+
body: r.body ?? ""
|
|
1191
|
+
}));
|
|
1192
|
+
} catch {
|
|
1193
|
+
return [];
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function fetchPRContext(owner, repo, prNumber, deps) {
|
|
1197
|
+
const [metadata, comments, reviewThreads, existingReviews] = await Promise.all([
|
|
1198
|
+
fetchPRMetadata(owner, repo, prNumber, deps),
|
|
1199
|
+
fetchIssueComments(owner, repo, prNumber, deps),
|
|
1200
|
+
fetchReviewComments(owner, repo, prNumber, deps),
|
|
1201
|
+
fetchExistingReviews(owner, repo, prNumber, deps)
|
|
1202
|
+
]);
|
|
1203
|
+
return { metadata, comments, reviewThreads, existingReviews };
|
|
1204
|
+
}
|
|
1205
|
+
function formatPRContext(context, codebaseDir) {
|
|
1206
|
+
const sections = [];
|
|
1207
|
+
if (context.metadata) {
|
|
1208
|
+
const m = context.metadata;
|
|
1209
|
+
const lines = ["## PR Context", `**Title**: ${m.title}`, `**Author**: @${m.author}`];
|
|
1210
|
+
if (m.body) {
|
|
1211
|
+
lines.push(`**Description**: ${m.body}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (m.labels.length > 0) {
|
|
1214
|
+
lines.push(`**Labels**: ${m.labels.join(", ")}`);
|
|
1215
|
+
}
|
|
1216
|
+
lines.push(`**Branches**: ${m.headBranch} \u2192 ${m.baseBranch}`);
|
|
1217
|
+
sections.push(lines.join("\n"));
|
|
1218
|
+
}
|
|
1219
|
+
if (context.comments.length > 0) {
|
|
1220
|
+
const commentLines = context.comments.map((c) => `@${c.author}: ${c.body}`);
|
|
1221
|
+
sections.push(
|
|
1222
|
+
`## Discussion (${context.comments.length} comment${context.comments.length === 1 ? "" : "s"})
|
|
1223
|
+
${commentLines.join("\n")}`
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
if (context.reviewThreads.length > 0) {
|
|
1227
|
+
const threadLines = context.reviewThreads.map((t) => {
|
|
1228
|
+
const location = t.line ? `\`${t.path}:${t.line}\`` : `\`${t.path}\``;
|
|
1229
|
+
return `@${t.author} on ${location}: ${t.body}`;
|
|
1230
|
+
});
|
|
1231
|
+
sections.push(`## Review Threads (${context.reviewThreads.length})
|
|
1232
|
+
${threadLines.join("\n")}`);
|
|
1233
|
+
}
|
|
1234
|
+
if (context.existingReviews.length > 0) {
|
|
1235
|
+
const reviewLines = context.existingReviews.map((r) => {
|
|
1236
|
+
const body = r.body ? ` ${r.body}` : "";
|
|
1237
|
+
return `@${r.author}: [${r.state}]${body}`;
|
|
1238
|
+
});
|
|
1239
|
+
sections.push(
|
|
1240
|
+
`## Existing Reviews (${context.existingReviews.length})
|
|
1241
|
+
${reviewLines.join("\n")}`
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
if (codebaseDir) {
|
|
1245
|
+
sections.push(`## Local Codebase
|
|
1246
|
+
The full repository is available at: ${codebaseDir}`);
|
|
1247
|
+
}
|
|
1248
|
+
return sanitizeTokens(sections.join("\n\n"));
|
|
1249
|
+
}
|
|
1250
|
+
function hasContent(context) {
|
|
1251
|
+
return context.metadata !== null || context.comments.length > 0 || context.reviewThreads.length > 0 || context.existingReviews.length > 0;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/logger.ts
|
|
1255
|
+
import pc from "picocolors";
|
|
1256
|
+
var icons = {
|
|
1257
|
+
start: pc.green("\u25CF"),
|
|
1258
|
+
polling: pc.cyan("\u21BB"),
|
|
1259
|
+
success: pc.green("\u2713"),
|
|
1260
|
+
running: pc.blue("\u25B6"),
|
|
1261
|
+
stop: pc.red("\u25A0"),
|
|
1262
|
+
warn: pc.yellow("\u26A0"),
|
|
1263
|
+
error: pc.red("\u2717")
|
|
1264
|
+
};
|
|
1265
|
+
function timestamp() {
|
|
1266
|
+
const now = /* @__PURE__ */ new Date();
|
|
1267
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
1268
|
+
const m = String(now.getMinutes()).padStart(2, "0");
|
|
1269
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
1270
|
+
return `${h}:${m}:${s}`;
|
|
1271
|
+
}
|
|
968
1272
|
function createLogger(label) {
|
|
969
|
-
const
|
|
1273
|
+
const labelStr = label ? ` ${pc.dim(`[${label}]`)}` : "";
|
|
970
1274
|
return {
|
|
971
|
-
log: (msg) => console.log(`${
|
|
972
|
-
logError: (msg) => console.error(`${
|
|
973
|
-
logWarn: (msg) => console.warn(`${
|
|
1275
|
+
log: (msg) => console.log(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${sanitizeTokens(msg)}`),
|
|
1276
|
+
logError: (msg) => console.error(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.red(sanitizeTokens(msg))}`),
|
|
1277
|
+
logWarn: (msg) => console.warn(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.yellow(sanitizeTokens(msg))}`)
|
|
974
1278
|
};
|
|
975
1279
|
}
|
|
1280
|
+
function createAgentSession() {
|
|
1281
|
+
return {
|
|
1282
|
+
startTime: Date.now(),
|
|
1283
|
+
tasksCompleted: 0,
|
|
1284
|
+
errorsEncountered: 0
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function formatUptime(ms) {
|
|
1288
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
1289
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1290
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
1291
|
+
const seconds = totalSeconds % 60;
|
|
1292
|
+
if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
|
|
1293
|
+
if (minutes > 0) return `${minutes}m${seconds}s`;
|
|
1294
|
+
return `${seconds}s`;
|
|
1295
|
+
}
|
|
1296
|
+
function formatExitSummary(stats) {
|
|
1297
|
+
const uptime = formatUptime(Date.now() - stats.startTime);
|
|
1298
|
+
const tasks = stats.tasksCompleted === 1 ? "1 task" : `${stats.tasksCompleted} tasks`;
|
|
1299
|
+
const errors = stats.errorsEncountered === 1 ? "1 error" : `${stats.errorsEncountered} errors`;
|
|
1300
|
+
return `${icons.stop} Shutting down \u2014 ${tasks} completed, ${errors}, uptime ${uptime}`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/commands/agent.ts
|
|
1304
|
+
var DEFAULT_POLL_INTERVAL_MS = 1e4;
|
|
1305
|
+
var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
|
|
1306
|
+
var MAX_POLL_BACKOFF_MS = 3e5;
|
|
976
1307
|
var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
|
|
977
1308
|
function toApiDiffUrl(webUrl) {
|
|
978
1309
|
const match = webUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.diff)?$/);
|
|
@@ -1012,10 +1343,10 @@ async function fetchDiff(diffUrl, githubToken, signal) {
|
|
|
1012
1343
|
);
|
|
1013
1344
|
}
|
|
1014
1345
|
var MAX_DIFF_FETCH_ATTEMPTS = 3;
|
|
1015
|
-
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, options) {
|
|
1346
|
+
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, options) {
|
|
1016
1347
|
const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
|
|
1017
1348
|
const { log, logError, logWarn } = logger;
|
|
1018
|
-
log(
|
|
1349
|
+
log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
|
|
1019
1350
|
let consecutiveAuthErrors = 0;
|
|
1020
1351
|
let consecutiveErrors = 0;
|
|
1021
1352
|
const diffFailCounts = /* @__PURE__ */ new Map();
|
|
@@ -1023,6 +1354,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1023
1354
|
try {
|
|
1024
1355
|
const pollBody = { agent_id: agentId };
|
|
1025
1356
|
if (reviewOnly) pollBody.review_only = true;
|
|
1357
|
+
if (repoConfig?.mode === "whitelist" && repoConfig.list?.length) {
|
|
1358
|
+
pollBody.repos = repoConfig.list;
|
|
1359
|
+
}
|
|
1026
1360
|
const pollResponse = await client.post("/api/tasks/poll", pollBody);
|
|
1027
1361
|
consecutiveAuthErrors = 0;
|
|
1028
1362
|
consecutiveErrors = 0;
|
|
@@ -1039,10 +1373,12 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1039
1373
|
consumptionDeps,
|
|
1040
1374
|
agentInfo,
|
|
1041
1375
|
logger,
|
|
1376
|
+
agentSession,
|
|
1042
1377
|
routerRelay,
|
|
1043
1378
|
signal
|
|
1044
1379
|
);
|
|
1045
1380
|
if (result.diffFetchFailed) {
|
|
1381
|
+
agentSession.errorsEncountered++;
|
|
1046
1382
|
const count = (diffFailCounts.get(task.task_id) ?? 0) + 1;
|
|
1047
1383
|
diffFailCounts.set(task.task_id, count);
|
|
1048
1384
|
if (count >= MAX_DIFF_FETCH_ATTEMPTS) {
|
|
@@ -1052,20 +1388,21 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1052
1388
|
}
|
|
1053
1389
|
} catch (err) {
|
|
1054
1390
|
if (signal?.aborted) break;
|
|
1391
|
+
agentSession.errorsEncountered++;
|
|
1055
1392
|
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
|
|
1056
1393
|
consecutiveAuthErrors++;
|
|
1057
1394
|
consecutiveErrors++;
|
|
1058
1395
|
logError(
|
|
1059
|
-
|
|
1396
|
+
`${icons.error} Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
|
|
1060
1397
|
);
|
|
1061
1398
|
if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
|
|
1062
|
-
logError(
|
|
1399
|
+
logError(`${icons.error} Authentication failed repeatedly. Exiting.`);
|
|
1063
1400
|
break;
|
|
1064
1401
|
}
|
|
1065
1402
|
} else {
|
|
1066
1403
|
consecutiveAuthErrors = 0;
|
|
1067
1404
|
consecutiveErrors++;
|
|
1068
|
-
logError(
|
|
1405
|
+
logError(`${icons.error} Poll error: ${err.message}`);
|
|
1069
1406
|
}
|
|
1070
1407
|
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
1071
1408
|
logError(
|
|
@@ -1091,11 +1428,10 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1091
1428
|
await sleep2(pollIntervalMs, signal);
|
|
1092
1429
|
}
|
|
1093
1430
|
}
|
|
1094
|
-
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
|
|
1431
|
+
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
|
|
1095
1432
|
const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
|
|
1096
1433
|
const { log, logError, logWarn } = logger;
|
|
1097
|
-
log(`
|
|
1098
|
-
Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
1434
|
+
log(`${icons.success} Claimed task ${task_id} (${role}) \u2014 ${owner}/${repo}#${pr_number}`);
|
|
1099
1435
|
log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
|
|
1100
1436
|
let claimResponse;
|
|
1101
1437
|
try {
|
|
@@ -1110,15 +1446,14 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1110
1446
|
signal
|
|
1111
1447
|
);
|
|
1112
1448
|
} catch (err) {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1449
|
+
if (err instanceof HttpError) {
|
|
1450
|
+
const codeInfo = err.errorCode ? ` [${err.errorCode}]` : "";
|
|
1451
|
+
logError(` Claim rejected${codeInfo}: ${err.message}`);
|
|
1452
|
+
} else {
|
|
1453
|
+
logError(` Failed to claim task ${task_id}: ${err.message}`);
|
|
1454
|
+
}
|
|
1119
1455
|
return {};
|
|
1120
1456
|
}
|
|
1121
|
-
log(` Claimed as ${role}`);
|
|
1122
1457
|
let diffContent;
|
|
1123
1458
|
try {
|
|
1124
1459
|
diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
|
|
@@ -1171,6 +1506,21 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1171
1506
|
);
|
|
1172
1507
|
}
|
|
1173
1508
|
}
|
|
1509
|
+
let contextBlock;
|
|
1510
|
+
try {
|
|
1511
|
+
const prContext = await fetchPRContext(owner, repo, pr_number, {
|
|
1512
|
+
githubToken: reviewDeps.githubToken,
|
|
1513
|
+
signal
|
|
1514
|
+
});
|
|
1515
|
+
if (hasContent(prContext)) {
|
|
1516
|
+
contextBlock = formatPRContext(prContext, taskReviewDeps.codebaseDir);
|
|
1517
|
+
log(" PR context fetched");
|
|
1518
|
+
}
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
logWarn(
|
|
1521
|
+
` Warning: failed to fetch PR context: ${err.message}. Continuing without.`
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1174
1524
|
try {
|
|
1175
1525
|
if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
|
|
1176
1526
|
await executeSummaryTask(
|
|
@@ -1188,7 +1538,8 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1188
1538
|
consumptionDeps,
|
|
1189
1539
|
logger,
|
|
1190
1540
|
routerRelay,
|
|
1191
|
-
signal
|
|
1541
|
+
signal,
|
|
1542
|
+
contextBlock
|
|
1192
1543
|
);
|
|
1193
1544
|
} else {
|
|
1194
1545
|
await executeReviewTask(
|
|
@@ -1205,15 +1556,18 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1205
1556
|
consumptionDeps,
|
|
1206
1557
|
logger,
|
|
1207
1558
|
routerRelay,
|
|
1208
|
-
signal
|
|
1559
|
+
signal,
|
|
1560
|
+
contextBlock
|
|
1209
1561
|
);
|
|
1210
1562
|
}
|
|
1563
|
+
agentSession.tasksCompleted++;
|
|
1211
1564
|
} catch (err) {
|
|
1565
|
+
agentSession.errorsEncountered++;
|
|
1212
1566
|
if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
|
|
1213
|
-
logError(` ${err.message}`);
|
|
1567
|
+
logError(` ${icons.error} ${err.message}`);
|
|
1214
1568
|
await safeReject(client, task_id, agentId, err.message, logger);
|
|
1215
1569
|
} else {
|
|
1216
|
-
logError(` Error on task ${task_id}: ${err.message}`);
|
|
1570
|
+
logError(` ${icons.error} Error on task ${task_id}: ${err.message}`);
|
|
1217
1571
|
await safeError(client, task_id, agentId, err.message, logger);
|
|
1218
1572
|
}
|
|
1219
1573
|
} finally {
|
|
@@ -1253,18 +1607,19 @@ async function safeError(client, taskId, agentId, error, logger) {
|
|
|
1253
1607
|
);
|
|
1254
1608
|
}
|
|
1255
1609
|
}
|
|
1256
|
-
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
1610
|
+
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
|
|
1257
1611
|
let reviewText;
|
|
1258
1612
|
let verdict;
|
|
1259
1613
|
let tokensUsed;
|
|
1260
1614
|
if (routerRelay) {
|
|
1261
|
-
logger.log(` Executing review
|
|
1615
|
+
logger.log(` ${icons.running} Executing review: [router mode]`);
|
|
1262
1616
|
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
1263
1617
|
owner,
|
|
1264
1618
|
repo,
|
|
1265
1619
|
reviewMode: "full",
|
|
1266
1620
|
prompt,
|
|
1267
|
-
diffContent
|
|
1621
|
+
diffContent,
|
|
1622
|
+
contextBlock
|
|
1268
1623
|
});
|
|
1269
1624
|
const response = await routerRelay.sendPrompt(
|
|
1270
1625
|
"review_request",
|
|
@@ -1277,7 +1632,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1277
1632
|
verdict = parsed.verdict;
|
|
1278
1633
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1279
1634
|
} else {
|
|
1280
|
-
logger.log(` Executing review
|
|
1635
|
+
logger.log(` ${icons.running} Executing review: ${reviewDeps.commandTemplate}`);
|
|
1281
1636
|
const result = await executeReview(
|
|
1282
1637
|
{
|
|
1283
1638
|
taskId,
|
|
@@ -1287,7 +1642,8 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1287
1642
|
repo,
|
|
1288
1643
|
prNumber,
|
|
1289
1644
|
timeout: timeoutSeconds,
|
|
1290
|
-
reviewMode: "full"
|
|
1645
|
+
reviewMode: "full",
|
|
1646
|
+
contextBlock
|
|
1291
1647
|
},
|
|
1292
1648
|
reviewDeps
|
|
1293
1649
|
);
|
|
@@ -1308,22 +1664,23 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1308
1664
|
signal
|
|
1309
1665
|
);
|
|
1310
1666
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1311
|
-
logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1667
|
+
logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1312
1668
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1313
1669
|
}
|
|
1314
|
-
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
1670
|
+
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
|
|
1315
1671
|
if (reviews.length === 0) {
|
|
1316
1672
|
let reviewText;
|
|
1317
1673
|
let verdict;
|
|
1318
1674
|
let tokensUsed2;
|
|
1319
1675
|
if (routerRelay) {
|
|
1320
|
-
logger.log(` Executing summary
|
|
1676
|
+
logger.log(` ${icons.running} Executing summary: [router mode]`);
|
|
1321
1677
|
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
1322
1678
|
owner,
|
|
1323
1679
|
repo,
|
|
1324
1680
|
reviewMode: "full",
|
|
1325
1681
|
prompt,
|
|
1326
|
-
diffContent
|
|
1682
|
+
diffContent,
|
|
1683
|
+
contextBlock
|
|
1327
1684
|
});
|
|
1328
1685
|
const response = await routerRelay.sendPrompt(
|
|
1329
1686
|
"review_request",
|
|
@@ -1336,7 +1693,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1336
1693
|
verdict = parsed.verdict;
|
|
1337
1694
|
tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1338
1695
|
} else {
|
|
1339
|
-
logger.log(` Executing summary
|
|
1696
|
+
logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
|
|
1340
1697
|
const result = await executeReview(
|
|
1341
1698
|
{
|
|
1342
1699
|
taskId,
|
|
@@ -1346,7 +1703,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1346
1703
|
repo,
|
|
1347
1704
|
prNumber,
|
|
1348
1705
|
timeout: timeoutSeconds,
|
|
1349
|
-
reviewMode: "full"
|
|
1706
|
+
reviewMode: "full",
|
|
1707
|
+
contextBlock
|
|
1350
1708
|
},
|
|
1351
1709
|
reviewDeps
|
|
1352
1710
|
);
|
|
@@ -1367,7 +1725,9 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1367
1725
|
signal
|
|
1368
1726
|
);
|
|
1369
1727
|
recordSessionUsage(consumptionDeps.session, tokensUsed2);
|
|
1370
|
-
logger.log(
|
|
1728
|
+
logger.log(
|
|
1729
|
+
` ${icons.success} Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`
|
|
1730
|
+
);
|
|
1371
1731
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1372
1732
|
return;
|
|
1373
1733
|
}
|
|
@@ -1381,13 +1741,14 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1381
1741
|
let summaryText;
|
|
1382
1742
|
let tokensUsed;
|
|
1383
1743
|
if (routerRelay) {
|
|
1384
|
-
logger.log(` Executing summary
|
|
1744
|
+
logger.log(` ${icons.running} Executing summary: [router mode]`);
|
|
1385
1745
|
const fullPrompt = routerRelay.buildSummaryPrompt({
|
|
1386
1746
|
owner,
|
|
1387
1747
|
repo,
|
|
1388
1748
|
prompt,
|
|
1389
1749
|
reviews: summaryReviews,
|
|
1390
|
-
diffContent
|
|
1750
|
+
diffContent,
|
|
1751
|
+
contextBlock
|
|
1391
1752
|
});
|
|
1392
1753
|
const response = await routerRelay.sendPrompt(
|
|
1393
1754
|
"summary_request",
|
|
@@ -1398,7 +1759,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1398
1759
|
summaryText = response;
|
|
1399
1760
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1400
1761
|
} else {
|
|
1401
|
-
logger.log(` Executing summary
|
|
1762
|
+
logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
|
|
1402
1763
|
const result = await executeSummary(
|
|
1403
1764
|
{
|
|
1404
1765
|
taskId,
|
|
@@ -1408,7 +1769,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1408
1769
|
repo,
|
|
1409
1770
|
prNumber,
|
|
1410
1771
|
timeout: timeoutSeconds,
|
|
1411
|
-
diffContent
|
|
1772
|
+
diffContent,
|
|
1773
|
+
contextBlock
|
|
1412
1774
|
},
|
|
1413
1775
|
reviewDeps
|
|
1414
1776
|
);
|
|
@@ -1427,7 +1789,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1427
1789
|
signal
|
|
1428
1790
|
);
|
|
1429
1791
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1430
|
-
logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1792
|
+
logger.log(` ${icons.success} Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1431
1793
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1432
1794
|
}
|
|
1433
1795
|
function sleep2(ms, signal) {
|
|
@@ -1453,31 +1815,30 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
|
|
|
1453
1815
|
const deps = consumptionDeps ?? { agentId, session };
|
|
1454
1816
|
const logger = createLogger(options?.label);
|
|
1455
1817
|
const { log, logError, logWarn } = logger;
|
|
1456
|
-
|
|
1457
|
-
log(
|
|
1818
|
+
const agentSession = createAgentSession();
|
|
1819
|
+
log(`${icons.start} Agent started (polling ${platformUrl})`);
|
|
1458
1820
|
log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
|
|
1459
1821
|
if (!reviewDeps) {
|
|
1460
|
-
logError(
|
|
1822
|
+
logError(`${icons.error} No review command configured. Set command in config.yml`);
|
|
1461
1823
|
return;
|
|
1462
1824
|
}
|
|
1463
1825
|
if (reviewDeps.commandTemplate && !options?.routerRelay) {
|
|
1464
1826
|
log("Testing command...");
|
|
1465
1827
|
const result = await testCommand(reviewDeps.commandTemplate);
|
|
1466
1828
|
if (result.ok) {
|
|
1467
|
-
log(
|
|
1829
|
+
log(`${icons.success} Command test ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
|
|
1468
1830
|
} else {
|
|
1469
|
-
logWarn(
|
|
1831
|
+
logWarn(`${icons.warn} Command test failed (${result.error}). Reviews may fail.`);
|
|
1470
1832
|
}
|
|
1471
1833
|
}
|
|
1472
1834
|
const abortController = new AbortController();
|
|
1473
1835
|
process.on("SIGINT", () => {
|
|
1474
|
-
log("\nShutting down...");
|
|
1475
1836
|
abortController.abort();
|
|
1476
1837
|
});
|
|
1477
1838
|
process.on("SIGTERM", () => {
|
|
1478
1839
|
abortController.abort();
|
|
1479
1840
|
});
|
|
1480
|
-
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
|
|
1841
|
+
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, agentSession, {
|
|
1481
1842
|
pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
1482
1843
|
maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
1483
1844
|
routerRelay: options?.routerRelay,
|
|
@@ -1485,7 +1846,7 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
|
|
|
1485
1846
|
repoConfig: options?.repoConfig,
|
|
1486
1847
|
signal: abortController.signal
|
|
1487
1848
|
});
|
|
1488
|
-
log(
|
|
1849
|
+
log(formatExitSummary(agentSession));
|
|
1489
1850
|
}
|
|
1490
1851
|
async function startAgentRouter() {
|
|
1491
1852
|
const config = loadConfig();
|
|
@@ -1661,7 +2022,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
|
|
|
1661
2022
|
});
|
|
1662
2023
|
|
|
1663
2024
|
// src/index.ts
|
|
1664
|
-
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.
|
|
2025
|
+
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.12.0");
|
|
1665
2026
|
program.addCommand(agentCommand);
|
|
1666
2027
|
program.action(() => {
|
|
1667
2028
|
startAgentRouter();
|
package/package.json
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Distributed AI code review agent — poll, review, and submit PR reviews using your own AI tools",
|
|
4
5
|
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "OpenCara <https://github.com/OpenCara>",
|
|
8
|
+
"homepage": "https://github.com/OpenCara/OpenCara#readme",
|
|
5
9
|
"repository": {
|
|
6
10
|
"type": "git",
|
|
7
11
|
"url": "https://github.com/OpenCara/OpenCara.git",
|
|
8
12
|
"directory": "packages/cli"
|
|
9
13
|
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/OpenCara/OpenCara/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"code-review",
|
|
20
|
+
"github",
|
|
21
|
+
"pull-request",
|
|
22
|
+
"cli",
|
|
23
|
+
"agent",
|
|
24
|
+
"review",
|
|
25
|
+
"openai",
|
|
26
|
+
"claude",
|
|
27
|
+
"gemini"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
10
32
|
"bin": {
|
|
11
33
|
"opencara": "dist/index.js"
|
|
12
34
|
},
|
|
13
35
|
"files": [
|
|
14
|
-
"dist"
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
15
38
|
],
|
|
16
39
|
"scripts": {
|
|
17
40
|
"build": "tsup",
|
|
@@ -20,14 +43,15 @@
|
|
|
20
43
|
},
|
|
21
44
|
"dependencies": {
|
|
22
45
|
"commander": "^13.0.0",
|
|
46
|
+
"picocolors": "^1.1.1",
|
|
23
47
|
"yaml": "^2.7.0"
|
|
24
48
|
},
|
|
25
49
|
"devDependencies": {
|
|
26
|
-
"@opencara/shared": "workspace:*",
|
|
27
|
-
"@opencara/server": "workspace:*",
|
|
28
50
|
"@cloudflare/workers-types": "^4.20250214.0",
|
|
29
|
-
"
|
|
51
|
+
"@opencara/server": "workspace:*",
|
|
52
|
+
"@opencara/shared": "workspace:*",
|
|
30
53
|
"@types/node": "^22.0.0",
|
|
54
|
+
"hono": "^4.7.0",
|
|
31
55
|
"tsup": "^8.5.1",
|
|
32
56
|
"tsx": "^4.0.0"
|
|
33
57
|
}
|