opencara 0.11.0 → 0.12.1
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 +450 -87
- 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;
|
|
@@ -66,13 +154,15 @@ function parseRepoConfig(obj, index) {
|
|
|
66
154
|
);
|
|
67
155
|
}
|
|
68
156
|
const config = { mode };
|
|
157
|
+
const list = reposObj.list;
|
|
69
158
|
if (mode === "whitelist" || mode === "blacklist") {
|
|
70
|
-
const list = reposObj.list;
|
|
71
159
|
if (!Array.isArray(list) || list.length === 0) {
|
|
72
160
|
throw new RepoConfigError(
|
|
73
161
|
`agents[${index}].repos.list is required and must be non-empty for mode '${mode}'`
|
|
74
162
|
);
|
|
75
163
|
}
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
76
166
|
for (let j = 0; j < list.length; j++) {
|
|
77
167
|
if (typeof list[j] !== "string" || !REPO_PATTERN.test(list[j])) {
|
|
78
168
|
throw new RepoConfigError(
|
|
@@ -92,15 +182,33 @@ function parseAgents(data) {
|
|
|
92
182
|
for (let i = 0; i < raw.length; i++) {
|
|
93
183
|
const entry = raw[i];
|
|
94
184
|
if (!entry || typeof entry !== "object") {
|
|
95
|
-
console.warn(
|
|
185
|
+
console.warn(`\u26A0 Config warning: agents[${i}] is not an object, skipping agent`);
|
|
96
186
|
continue;
|
|
97
187
|
}
|
|
98
188
|
const obj = entry;
|
|
99
189
|
if (typeof obj.model !== "string" || typeof obj.tool !== "string") {
|
|
100
|
-
console.warn(
|
|
190
|
+
console.warn(
|
|
191
|
+
`\u26A0 Config warning: agents[${i}] missing required model/tool fields, skipping agent`
|
|
192
|
+
);
|
|
101
193
|
continue;
|
|
102
194
|
}
|
|
103
|
-
|
|
195
|
+
let resolvedTool = obj.tool;
|
|
196
|
+
if (!KNOWN_TOOL_NAMES.has(resolvedTool)) {
|
|
197
|
+
const alias = TOOL_ALIASES[resolvedTool];
|
|
198
|
+
if (alias) {
|
|
199
|
+
console.warn(
|
|
200
|
+
`\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" is deprecated, using "${alias}" instead`
|
|
201
|
+
);
|
|
202
|
+
resolvedTool = alias;
|
|
203
|
+
} else {
|
|
204
|
+
const toolNames = [...KNOWN_TOOL_NAMES].join(", ");
|
|
205
|
+
console.warn(
|
|
206
|
+
`\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" not in registry (known: ${toolNames}), skipping agent`
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const agent = { model: obj.model, tool: resolvedTool };
|
|
104
212
|
if (typeof obj.name === "string") agent.name = obj.name;
|
|
105
213
|
if (typeof obj.command === "string") agent.command = obj.command;
|
|
106
214
|
if (obj.router === true) agent.router = true;
|
|
@@ -113,6 +221,35 @@ function parseAgents(data) {
|
|
|
113
221
|
}
|
|
114
222
|
return agents;
|
|
115
223
|
}
|
|
224
|
+
function isValidHttpUrl(value) {
|
|
225
|
+
try {
|
|
226
|
+
const url = new URL(value);
|
|
227
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function validateConfigData(data, envPlatformUrl) {
|
|
233
|
+
const overrides = {};
|
|
234
|
+
if (!envPlatformUrl && typeof data.platform_url === "string" && !isValidHttpUrl(data.platform_url)) {
|
|
235
|
+
throw new ConfigValidationError(
|
|
236
|
+
`\u2717 Config error: platform_url "${data.platform_url}" is not a valid URL`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (typeof data.max_diff_size_kb === "number" && data.max_diff_size_kb <= 0) {
|
|
240
|
+
console.warn(
|
|
241
|
+
`\u26A0 Config warning: max_diff_size_kb must be > 0, got ${data.max_diff_size_kb}, using default (${DEFAULT_MAX_DIFF_SIZE_KB})`
|
|
242
|
+
);
|
|
243
|
+
overrides.maxDiffSizeKb = DEFAULT_MAX_DIFF_SIZE_KB;
|
|
244
|
+
}
|
|
245
|
+
if (typeof data.max_consecutive_errors === "number" && data.max_consecutive_errors <= 0) {
|
|
246
|
+
console.warn(
|
|
247
|
+
`\u26A0 Config warning: max_consecutive_errors must be > 0, got ${data.max_consecutive_errors}, using default (${DEFAULT_MAX_CONSECUTIVE_ERRORS})`
|
|
248
|
+
);
|
|
249
|
+
overrides.maxConsecutiveErrors = DEFAULT_MAX_CONSECUTIVE_ERRORS;
|
|
250
|
+
}
|
|
251
|
+
return overrides;
|
|
252
|
+
}
|
|
116
253
|
function loadConfig() {
|
|
117
254
|
const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
|
|
118
255
|
const defaults = {
|
|
@@ -132,10 +269,11 @@ function loadConfig() {
|
|
|
132
269
|
if (!data || typeof data !== "object") {
|
|
133
270
|
return defaults;
|
|
134
271
|
}
|
|
272
|
+
const overrides = validateConfigData(data, envPlatformUrl);
|
|
135
273
|
return {
|
|
136
274
|
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,
|
|
275
|
+
maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
|
|
276
|
+
maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
|
|
139
277
|
githubToken: typeof data.github_token === "string" ? data.github_token : null,
|
|
140
278
|
codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
|
|
141
279
|
agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
|
|
@@ -267,9 +405,10 @@ function logAuthMethod(method, log) {
|
|
|
267
405
|
|
|
268
406
|
// src/http.ts
|
|
269
407
|
var HttpError = class extends Error {
|
|
270
|
-
constructor(status, message) {
|
|
408
|
+
constructor(status, message, errorCode) {
|
|
271
409
|
super(message);
|
|
272
410
|
this.status = status;
|
|
411
|
+
this.errorCode = errorCode;
|
|
273
412
|
this.name = "HttpError";
|
|
274
413
|
}
|
|
275
414
|
};
|
|
@@ -307,13 +446,17 @@ var ApiClient = class {
|
|
|
307
446
|
async handleResponse(res, path5) {
|
|
308
447
|
if (!res.ok) {
|
|
309
448
|
let message = `HTTP ${res.status}`;
|
|
449
|
+
let errorCode;
|
|
310
450
|
try {
|
|
311
451
|
const body = await res.json();
|
|
312
|
-
if (body.error
|
|
452
|
+
if (body.error && typeof body.error === "object" && "code" in body.error) {
|
|
453
|
+
errorCode = body.error.code;
|
|
454
|
+
message = body.error.message;
|
|
455
|
+
}
|
|
313
456
|
} catch {
|
|
314
457
|
}
|
|
315
458
|
this.log(`${res.status} ${message} (${path5})`);
|
|
316
|
-
throw new HttpError(res.status, message);
|
|
459
|
+
throw new HttpError(res.status, message, errorCode);
|
|
317
460
|
}
|
|
318
461
|
this.log(`${res.status} OK (${path5})`);
|
|
319
462
|
return await res.json();
|
|
@@ -611,12 +754,13 @@ function buildSystemPrompt(owner, repo, mode = "full") {
|
|
|
611
754
|
const template = mode === "compact" ? COMPACT_SYSTEM_PROMPT_TEMPLATE : FULL_SYSTEM_PROMPT_TEMPLATE;
|
|
612
755
|
return template.replace("{owner}", owner).replace("{repo}", repo);
|
|
613
756
|
}
|
|
614
|
-
function buildUserMessage(prompt, diffContent) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
757
|
+
function buildUserMessage(prompt, diffContent, contextBlock) {
|
|
758
|
+
const parts = [prompt];
|
|
759
|
+
if (contextBlock) {
|
|
760
|
+
parts.push(contextBlock);
|
|
761
|
+
}
|
|
762
|
+
parts.push(diffContent);
|
|
763
|
+
return parts.join("\n\n---\n\n");
|
|
620
764
|
}
|
|
621
765
|
var SECTION_VERDICT_PATTERN = /##\s*Verdict\s*\n+\s*(APPROVE|REQUEST_CHANGES|COMMENT)\b/im;
|
|
622
766
|
var LEGACY_VERDICT_PATTERN = /^VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)\s*$/m;
|
|
@@ -656,7 +800,7 @@ async function executeReview(req, deps, runTool = executeTool) {
|
|
|
656
800
|
}, effectiveTimeout);
|
|
657
801
|
try {
|
|
658
802
|
const systemPrompt = buildSystemPrompt(req.owner, req.repo, req.reviewMode);
|
|
659
|
-
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
803
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
|
|
660
804
|
const fullPrompt = `${systemPrompt}
|
|
661
805
|
|
|
662
806
|
${userMessage}`;
|
|
@@ -728,27 +872,28 @@ For each finding, explain clearly what the problem is and how to fix it.
|
|
|
728
872
|
## Verdict
|
|
729
873
|
APPROVE | REQUEST_CHANGES | COMMENT`;
|
|
730
874
|
}
|
|
731
|
-
function buildSummaryUserMessage(prompt, reviews, diffContent) {
|
|
875
|
+
function buildSummaryUserMessage(prompt, reviews, diffContent, contextBlock) {
|
|
732
876
|
const reviewSections = reviews.map((r) => `### Review by ${r.model}/${r.tool} (Verdict: ${r.verdict})
|
|
733
877
|
${r.review}`).join("\n\n");
|
|
734
|
-
|
|
735
|
-
${prompt}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
Pull request diff:
|
|
740
|
-
|
|
741
|
-
${diffContent}
|
|
742
|
-
|
|
743
|
-
---
|
|
878
|
+
const parts = [`Project review guidelines:
|
|
879
|
+
${prompt}`];
|
|
880
|
+
if (contextBlock) {
|
|
881
|
+
parts.push(contextBlock);
|
|
882
|
+
}
|
|
883
|
+
parts.push(`Pull request diff:
|
|
744
884
|
|
|
745
|
-
|
|
885
|
+
${diffContent}`);
|
|
886
|
+
parts.push(`Compact reviews from other agents:
|
|
746
887
|
|
|
747
|
-
${reviewSections}
|
|
888
|
+
${reviewSections}`);
|
|
889
|
+
return parts.join("\n\n---\n\n");
|
|
748
890
|
}
|
|
749
|
-
function calculateInputSize(prompt, reviews, diffContent) {
|
|
891
|
+
function calculateInputSize(prompt, reviews, diffContent, contextBlock) {
|
|
750
892
|
let size = Buffer.byteLength(prompt, "utf-8");
|
|
751
893
|
size += Buffer.byteLength(diffContent, "utf-8");
|
|
894
|
+
if (contextBlock) {
|
|
895
|
+
size += Buffer.byteLength(contextBlock, "utf-8");
|
|
896
|
+
}
|
|
752
897
|
for (const r of reviews) {
|
|
753
898
|
size += Buffer.byteLength(r.review, "utf-8");
|
|
754
899
|
size += Buffer.byteLength(r.model, "utf-8");
|
|
@@ -758,7 +903,7 @@ function calculateInputSize(prompt, reviews, diffContent) {
|
|
|
758
903
|
return size;
|
|
759
904
|
}
|
|
760
905
|
async function executeSummary(req, deps, runTool = executeTool) {
|
|
761
|
-
const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent);
|
|
906
|
+
const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent, req.contextBlock);
|
|
762
907
|
if (inputSize > MAX_INPUT_SIZE_BYTES) {
|
|
763
908
|
throw new InputTooLargeError(
|
|
764
909
|
`Summary input too large (${Math.round(inputSize / 1024)}KB > ${Math.round(MAX_INPUT_SIZE_BYTES / 1024)}KB limit)`
|
|
@@ -775,7 +920,12 @@ async function executeSummary(req, deps, runTool = executeTool) {
|
|
|
775
920
|
}, effectiveTimeout);
|
|
776
921
|
try {
|
|
777
922
|
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
778
|
-
const userMessage = buildSummaryUserMessage(
|
|
923
|
+
const userMessage = buildSummaryUserMessage(
|
|
924
|
+
req.prompt,
|
|
925
|
+
req.reviews,
|
|
926
|
+
req.diffContent,
|
|
927
|
+
req.contextBlock
|
|
928
|
+
);
|
|
779
929
|
const fullPrompt = `${systemPrompt}
|
|
780
930
|
|
|
781
931
|
${userMessage}`;
|
|
@@ -874,7 +1024,7 @@ var RouterRelay = class {
|
|
|
874
1024
|
req.repo,
|
|
875
1025
|
req.reviewMode
|
|
876
1026
|
);
|
|
877
|
-
const userMessage = buildUserMessage(req.prompt, req.diffContent);
|
|
1027
|
+
const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
|
|
878
1028
|
return `${systemPrompt}
|
|
879
1029
|
|
|
880
1030
|
${userMessage}`;
|
|
@@ -882,7 +1032,12 @@ ${userMessage}`;
|
|
|
882
1032
|
/** Build the full prompt for a summary request */
|
|
883
1033
|
buildSummaryPrompt(req) {
|
|
884
1034
|
const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
|
|
885
|
-
const userMessage = buildSummaryUserMessage(
|
|
1035
|
+
const userMessage = buildSummaryUserMessage(
|
|
1036
|
+
req.prompt,
|
|
1037
|
+
req.reviews,
|
|
1038
|
+
req.diffContent,
|
|
1039
|
+
req.contextBlock
|
|
1040
|
+
);
|
|
886
1041
|
return `${systemPrompt}
|
|
887
1042
|
|
|
888
1043
|
${userMessage}`;
|
|
@@ -961,18 +1116,196 @@ function formatPostReviewStats(session) {
|
|
|
961
1116
|
return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
|
|
962
1117
|
}
|
|
963
1118
|
|
|
964
|
-
// src/
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1119
|
+
// src/pr-context.ts
|
|
1120
|
+
async function githubGet(url, deps) {
|
|
1121
|
+
const headers = {
|
|
1122
|
+
Accept: "application/vnd.github+json"
|
|
1123
|
+
};
|
|
1124
|
+
if (deps.githubToken) {
|
|
1125
|
+
headers["Authorization"] = `Bearer ${deps.githubToken}`;
|
|
1126
|
+
}
|
|
1127
|
+
const response = await fetch(url, { headers, signal: deps.signal });
|
|
1128
|
+
if (!response.ok) {
|
|
1129
|
+
throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
|
|
1130
|
+
}
|
|
1131
|
+
return response.json();
|
|
1132
|
+
}
|
|
1133
|
+
async function fetchPRMetadata(owner, repo, prNumber, deps) {
|
|
1134
|
+
try {
|
|
1135
|
+
const pr = await githubGet(
|
|
1136
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
1137
|
+
deps
|
|
1138
|
+
);
|
|
1139
|
+
return {
|
|
1140
|
+
title: pr.title,
|
|
1141
|
+
body: pr.body ?? "",
|
|
1142
|
+
author: pr.user?.login ?? "unknown",
|
|
1143
|
+
labels: pr.labels.map((l) => l.name),
|
|
1144
|
+
baseBranch: pr.base.ref,
|
|
1145
|
+
headBranch: pr.head.ref
|
|
1146
|
+
};
|
|
1147
|
+
} catch {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
async function fetchIssueComments(owner, repo, prNumber, deps) {
|
|
1152
|
+
try {
|
|
1153
|
+
const comments = await githubGet(
|
|
1154
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
|
|
1155
|
+
deps
|
|
1156
|
+
);
|
|
1157
|
+
return comments.map((c) => ({
|
|
1158
|
+
author: c.user?.login ?? "unknown",
|
|
1159
|
+
body: c.body,
|
|
1160
|
+
createdAt: c.created_at
|
|
1161
|
+
}));
|
|
1162
|
+
} catch {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function fetchReviewComments(owner, repo, prNumber, deps) {
|
|
1167
|
+
try {
|
|
1168
|
+
const comments = await githubGet(
|
|
1169
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
|
1170
|
+
deps
|
|
1171
|
+
);
|
|
1172
|
+
return comments.map((c) => ({
|
|
1173
|
+
author: c.user?.login ?? "unknown",
|
|
1174
|
+
body: c.body,
|
|
1175
|
+
path: c.path,
|
|
1176
|
+
line: c.line,
|
|
1177
|
+
createdAt: c.created_at
|
|
1178
|
+
}));
|
|
1179
|
+
} catch {
|
|
1180
|
+
return [];
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
async function fetchExistingReviews(owner, repo, prNumber, deps) {
|
|
1184
|
+
try {
|
|
1185
|
+
const reviews = await githubGet(
|
|
1186
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
|
|
1187
|
+
deps
|
|
1188
|
+
);
|
|
1189
|
+
return reviews.filter((r) => r.state !== "PENDING").map((r) => ({
|
|
1190
|
+
author: r.user?.login ?? "unknown",
|
|
1191
|
+
state: r.state,
|
|
1192
|
+
body: r.body ?? ""
|
|
1193
|
+
}));
|
|
1194
|
+
} catch {
|
|
1195
|
+
return [];
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function fetchPRContext(owner, repo, prNumber, deps) {
|
|
1199
|
+
const [metadata, comments, reviewThreads, existingReviews] = await Promise.all([
|
|
1200
|
+
fetchPRMetadata(owner, repo, prNumber, deps),
|
|
1201
|
+
fetchIssueComments(owner, repo, prNumber, deps),
|
|
1202
|
+
fetchReviewComments(owner, repo, prNumber, deps),
|
|
1203
|
+
fetchExistingReviews(owner, repo, prNumber, deps)
|
|
1204
|
+
]);
|
|
1205
|
+
return { metadata, comments, reviewThreads, existingReviews };
|
|
1206
|
+
}
|
|
1207
|
+
function formatPRContext(context, codebaseDir) {
|
|
1208
|
+
const sections = [];
|
|
1209
|
+
if (context.metadata) {
|
|
1210
|
+
const m = context.metadata;
|
|
1211
|
+
const lines = ["## PR Context", `**Title**: ${m.title}`, `**Author**: @${m.author}`];
|
|
1212
|
+
if (m.body) {
|
|
1213
|
+
lines.push(`**Description**: ${m.body}`);
|
|
1214
|
+
}
|
|
1215
|
+
if (m.labels.length > 0) {
|
|
1216
|
+
lines.push(`**Labels**: ${m.labels.join(", ")}`);
|
|
1217
|
+
}
|
|
1218
|
+
lines.push(`**Branches**: ${m.headBranch} \u2192 ${m.baseBranch}`);
|
|
1219
|
+
sections.push(lines.join("\n"));
|
|
1220
|
+
}
|
|
1221
|
+
if (context.comments.length > 0) {
|
|
1222
|
+
const commentLines = context.comments.map((c) => `@${c.author}: ${c.body}`);
|
|
1223
|
+
sections.push(
|
|
1224
|
+
`## Discussion (${context.comments.length} comment${context.comments.length === 1 ? "" : "s"})
|
|
1225
|
+
${commentLines.join("\n")}`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (context.reviewThreads.length > 0) {
|
|
1229
|
+
const threadLines = context.reviewThreads.map((t) => {
|
|
1230
|
+
const location = t.line ? `\`${t.path}:${t.line}\`` : `\`${t.path}\``;
|
|
1231
|
+
return `@${t.author} on ${location}: ${t.body}`;
|
|
1232
|
+
});
|
|
1233
|
+
sections.push(`## Review Threads (${context.reviewThreads.length})
|
|
1234
|
+
${threadLines.join("\n")}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (context.existingReviews.length > 0) {
|
|
1237
|
+
const reviewLines = context.existingReviews.map((r) => {
|
|
1238
|
+
const body = r.body ? ` ${r.body}` : "";
|
|
1239
|
+
return `@${r.author}: [${r.state}]${body}`;
|
|
1240
|
+
});
|
|
1241
|
+
sections.push(
|
|
1242
|
+
`## Existing Reviews (${context.existingReviews.length})
|
|
1243
|
+
${reviewLines.join("\n")}`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
if (codebaseDir) {
|
|
1247
|
+
sections.push(`## Local Codebase
|
|
1248
|
+
The full repository is available at: ${codebaseDir}`);
|
|
1249
|
+
}
|
|
1250
|
+
return sanitizeTokens(sections.join("\n\n"));
|
|
1251
|
+
}
|
|
1252
|
+
function hasContent(context) {
|
|
1253
|
+
return context.metadata !== null || context.comments.length > 0 || context.reviewThreads.length > 0 || context.existingReviews.length > 0;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/logger.ts
|
|
1257
|
+
import pc from "picocolors";
|
|
1258
|
+
var icons = {
|
|
1259
|
+
start: pc.green("\u25CF"),
|
|
1260
|
+
polling: pc.cyan("\u21BB"),
|
|
1261
|
+
success: pc.green("\u2713"),
|
|
1262
|
+
running: pc.blue("\u25B6"),
|
|
1263
|
+
stop: pc.red("\u25A0"),
|
|
1264
|
+
warn: pc.yellow("\u26A0"),
|
|
1265
|
+
error: pc.red("\u2717")
|
|
1266
|
+
};
|
|
1267
|
+
function timestamp() {
|
|
1268
|
+
const now = /* @__PURE__ */ new Date();
|
|
1269
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
1270
|
+
const m = String(now.getMinutes()).padStart(2, "0");
|
|
1271
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
1272
|
+
return `${h}:${m}:${s}`;
|
|
1273
|
+
}
|
|
968
1274
|
function createLogger(label) {
|
|
969
|
-
const
|
|
1275
|
+
const labelStr = label ? ` ${pc.dim(`[${label}]`)}` : "";
|
|
970
1276
|
return {
|
|
971
|
-
log: (msg) => console.log(`${
|
|
972
|
-
logError: (msg) => console.error(`${
|
|
973
|
-
logWarn: (msg) => console.warn(`${
|
|
1277
|
+
log: (msg) => console.log(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${sanitizeTokens(msg)}`),
|
|
1278
|
+
logError: (msg) => console.error(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.red(sanitizeTokens(msg))}`),
|
|
1279
|
+
logWarn: (msg) => console.warn(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.yellow(sanitizeTokens(msg))}`)
|
|
974
1280
|
};
|
|
975
1281
|
}
|
|
1282
|
+
function createAgentSession() {
|
|
1283
|
+
return {
|
|
1284
|
+
startTime: Date.now(),
|
|
1285
|
+
tasksCompleted: 0,
|
|
1286
|
+
errorsEncountered: 0
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function formatUptime(ms) {
|
|
1290
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
1291
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1292
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
1293
|
+
const seconds = totalSeconds % 60;
|
|
1294
|
+
if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
|
|
1295
|
+
if (minutes > 0) return `${minutes}m${seconds}s`;
|
|
1296
|
+
return `${seconds}s`;
|
|
1297
|
+
}
|
|
1298
|
+
function formatExitSummary(stats) {
|
|
1299
|
+
const uptime = formatUptime(Date.now() - stats.startTime);
|
|
1300
|
+
const tasks = stats.tasksCompleted === 1 ? "1 task" : `${stats.tasksCompleted} tasks`;
|
|
1301
|
+
const errors = stats.errorsEncountered === 1 ? "1 error" : `${stats.errorsEncountered} errors`;
|
|
1302
|
+
return `${icons.stop} Shutting down \u2014 ${tasks} completed, ${errors}, uptime ${uptime}`;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// src/commands/agent.ts
|
|
1306
|
+
var DEFAULT_POLL_INTERVAL_MS = 1e4;
|
|
1307
|
+
var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
|
|
1308
|
+
var MAX_POLL_BACKOFF_MS = 3e5;
|
|
976
1309
|
var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
|
|
977
1310
|
function toApiDiffUrl(webUrl) {
|
|
978
1311
|
const match = webUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.diff)?$/);
|
|
@@ -1012,10 +1345,10 @@ async function fetchDiff(diffUrl, githubToken, signal) {
|
|
|
1012
1345
|
);
|
|
1013
1346
|
}
|
|
1014
1347
|
var MAX_DIFF_FETCH_ATTEMPTS = 3;
|
|
1015
|
-
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, options) {
|
|
1348
|
+
async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, options) {
|
|
1016
1349
|
const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
|
|
1017
1350
|
const { log, logError, logWarn } = logger;
|
|
1018
|
-
log(
|
|
1351
|
+
log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
|
|
1019
1352
|
let consecutiveAuthErrors = 0;
|
|
1020
1353
|
let consecutiveErrors = 0;
|
|
1021
1354
|
const diffFailCounts = /* @__PURE__ */ new Map();
|
|
@@ -1023,6 +1356,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1023
1356
|
try {
|
|
1024
1357
|
const pollBody = { agent_id: agentId };
|
|
1025
1358
|
if (reviewOnly) pollBody.review_only = true;
|
|
1359
|
+
if (repoConfig?.list?.length) {
|
|
1360
|
+
pollBody.repos = repoConfig.list;
|
|
1361
|
+
}
|
|
1026
1362
|
const pollResponse = await client.post("/api/tasks/poll", pollBody);
|
|
1027
1363
|
consecutiveAuthErrors = 0;
|
|
1028
1364
|
consecutiveErrors = 0;
|
|
@@ -1039,10 +1375,12 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1039
1375
|
consumptionDeps,
|
|
1040
1376
|
agentInfo,
|
|
1041
1377
|
logger,
|
|
1378
|
+
agentSession,
|
|
1042
1379
|
routerRelay,
|
|
1043
1380
|
signal
|
|
1044
1381
|
);
|
|
1045
1382
|
if (result.diffFetchFailed) {
|
|
1383
|
+
agentSession.errorsEncountered++;
|
|
1046
1384
|
const count = (diffFailCounts.get(task.task_id) ?? 0) + 1;
|
|
1047
1385
|
diffFailCounts.set(task.task_id, count);
|
|
1048
1386
|
if (count >= MAX_DIFF_FETCH_ATTEMPTS) {
|
|
@@ -1052,20 +1390,21 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1052
1390
|
}
|
|
1053
1391
|
} catch (err) {
|
|
1054
1392
|
if (signal?.aborted) break;
|
|
1393
|
+
agentSession.errorsEncountered++;
|
|
1055
1394
|
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
|
|
1056
1395
|
consecutiveAuthErrors++;
|
|
1057
1396
|
consecutiveErrors++;
|
|
1058
1397
|
logError(
|
|
1059
|
-
|
|
1398
|
+
`${icons.error} Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
|
|
1060
1399
|
);
|
|
1061
1400
|
if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
|
|
1062
|
-
logError(
|
|
1401
|
+
logError(`${icons.error} Authentication failed repeatedly. Exiting.`);
|
|
1063
1402
|
break;
|
|
1064
1403
|
}
|
|
1065
1404
|
} else {
|
|
1066
1405
|
consecutiveAuthErrors = 0;
|
|
1067
1406
|
consecutiveErrors++;
|
|
1068
|
-
logError(
|
|
1407
|
+
logError(`${icons.error} Poll error: ${err.message}`);
|
|
1069
1408
|
}
|
|
1070
1409
|
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
1071
1410
|
logError(
|
|
@@ -1091,11 +1430,10 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
|
|
|
1091
1430
|
await sleep2(pollIntervalMs, signal);
|
|
1092
1431
|
}
|
|
1093
1432
|
}
|
|
1094
|
-
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
|
|
1433
|
+
async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
|
|
1095
1434
|
const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
|
|
1096
1435
|
const { log, logError, logWarn } = logger;
|
|
1097
|
-
log(`
|
|
1098
|
-
Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
1436
|
+
log(`${icons.success} Claimed task ${task_id} (${role}) \u2014 ${owner}/${repo}#${pr_number}`);
|
|
1099
1437
|
log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
|
|
1100
1438
|
let claimResponse;
|
|
1101
1439
|
try {
|
|
@@ -1110,15 +1448,14 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1110
1448
|
signal
|
|
1111
1449
|
);
|
|
1112
1450
|
} catch (err) {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1451
|
+
if (err instanceof HttpError) {
|
|
1452
|
+
const codeInfo = err.errorCode ? ` [${err.errorCode}]` : "";
|
|
1453
|
+
logError(` Claim rejected${codeInfo}: ${err.message}`);
|
|
1454
|
+
} else {
|
|
1455
|
+
logError(` Failed to claim task ${task_id}: ${err.message}`);
|
|
1456
|
+
}
|
|
1119
1457
|
return {};
|
|
1120
1458
|
}
|
|
1121
|
-
log(` Claimed as ${role}`);
|
|
1122
1459
|
let diffContent;
|
|
1123
1460
|
try {
|
|
1124
1461
|
diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
|
|
@@ -1171,6 +1508,21 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1171
1508
|
);
|
|
1172
1509
|
}
|
|
1173
1510
|
}
|
|
1511
|
+
let contextBlock;
|
|
1512
|
+
try {
|
|
1513
|
+
const prContext = await fetchPRContext(owner, repo, pr_number, {
|
|
1514
|
+
githubToken: reviewDeps.githubToken,
|
|
1515
|
+
signal
|
|
1516
|
+
});
|
|
1517
|
+
if (hasContent(prContext)) {
|
|
1518
|
+
contextBlock = formatPRContext(prContext, taskReviewDeps.codebaseDir);
|
|
1519
|
+
log(" PR context fetched");
|
|
1520
|
+
}
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
logWarn(
|
|
1523
|
+
` Warning: failed to fetch PR context: ${err.message}. Continuing without.`
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1174
1526
|
try {
|
|
1175
1527
|
if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
|
|
1176
1528
|
await executeSummaryTask(
|
|
@@ -1188,7 +1540,8 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1188
1540
|
consumptionDeps,
|
|
1189
1541
|
logger,
|
|
1190
1542
|
routerRelay,
|
|
1191
|
-
signal
|
|
1543
|
+
signal,
|
|
1544
|
+
contextBlock
|
|
1192
1545
|
);
|
|
1193
1546
|
} else {
|
|
1194
1547
|
await executeReviewTask(
|
|
@@ -1205,15 +1558,18 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
|
|
|
1205
1558
|
consumptionDeps,
|
|
1206
1559
|
logger,
|
|
1207
1560
|
routerRelay,
|
|
1208
|
-
signal
|
|
1561
|
+
signal,
|
|
1562
|
+
contextBlock
|
|
1209
1563
|
);
|
|
1210
1564
|
}
|
|
1565
|
+
agentSession.tasksCompleted++;
|
|
1211
1566
|
} catch (err) {
|
|
1567
|
+
agentSession.errorsEncountered++;
|
|
1212
1568
|
if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
|
|
1213
|
-
logError(` ${err.message}`);
|
|
1569
|
+
logError(` ${icons.error} ${err.message}`);
|
|
1214
1570
|
await safeReject(client, task_id, agentId, err.message, logger);
|
|
1215
1571
|
} else {
|
|
1216
|
-
logError(` Error on task ${task_id}: ${err.message}`);
|
|
1572
|
+
logError(` ${icons.error} Error on task ${task_id}: ${err.message}`);
|
|
1217
1573
|
await safeError(client, task_id, agentId, err.message, logger);
|
|
1218
1574
|
}
|
|
1219
1575
|
} finally {
|
|
@@ -1253,18 +1609,19 @@ async function safeError(client, taskId, agentId, error, logger) {
|
|
|
1253
1609
|
);
|
|
1254
1610
|
}
|
|
1255
1611
|
}
|
|
1256
|
-
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
1612
|
+
async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
|
|
1257
1613
|
let reviewText;
|
|
1258
1614
|
let verdict;
|
|
1259
1615
|
let tokensUsed;
|
|
1260
1616
|
if (routerRelay) {
|
|
1261
|
-
logger.log(` Executing review
|
|
1617
|
+
logger.log(` ${icons.running} Executing review: [router mode]`);
|
|
1262
1618
|
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
1263
1619
|
owner,
|
|
1264
1620
|
repo,
|
|
1265
1621
|
reviewMode: "full",
|
|
1266
1622
|
prompt,
|
|
1267
|
-
diffContent
|
|
1623
|
+
diffContent,
|
|
1624
|
+
contextBlock
|
|
1268
1625
|
});
|
|
1269
1626
|
const response = await routerRelay.sendPrompt(
|
|
1270
1627
|
"review_request",
|
|
@@ -1277,7 +1634,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1277
1634
|
verdict = parsed.verdict;
|
|
1278
1635
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1279
1636
|
} else {
|
|
1280
|
-
logger.log(` Executing review
|
|
1637
|
+
logger.log(` ${icons.running} Executing review: ${reviewDeps.commandTemplate}`);
|
|
1281
1638
|
const result = await executeReview(
|
|
1282
1639
|
{
|
|
1283
1640
|
taskId,
|
|
@@ -1287,7 +1644,8 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1287
1644
|
repo,
|
|
1288
1645
|
prNumber,
|
|
1289
1646
|
timeout: timeoutSeconds,
|
|
1290
|
-
reviewMode: "full"
|
|
1647
|
+
reviewMode: "full",
|
|
1648
|
+
contextBlock
|
|
1291
1649
|
},
|
|
1292
1650
|
reviewDeps
|
|
1293
1651
|
);
|
|
@@ -1308,22 +1666,23 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
|
|
|
1308
1666
|
signal
|
|
1309
1667
|
);
|
|
1310
1668
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1311
|
-
logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1669
|
+
logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1312
1670
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1313
1671
|
}
|
|
1314
|
-
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
|
|
1672
|
+
async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
|
|
1315
1673
|
if (reviews.length === 0) {
|
|
1316
1674
|
let reviewText;
|
|
1317
1675
|
let verdict;
|
|
1318
1676
|
let tokensUsed2;
|
|
1319
1677
|
if (routerRelay) {
|
|
1320
|
-
logger.log(` Executing summary
|
|
1678
|
+
logger.log(` ${icons.running} Executing summary: [router mode]`);
|
|
1321
1679
|
const fullPrompt = routerRelay.buildReviewPrompt({
|
|
1322
1680
|
owner,
|
|
1323
1681
|
repo,
|
|
1324
1682
|
reviewMode: "full",
|
|
1325
1683
|
prompt,
|
|
1326
|
-
diffContent
|
|
1684
|
+
diffContent,
|
|
1685
|
+
contextBlock
|
|
1327
1686
|
});
|
|
1328
1687
|
const response = await routerRelay.sendPrompt(
|
|
1329
1688
|
"review_request",
|
|
@@ -1336,7 +1695,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1336
1695
|
verdict = parsed.verdict;
|
|
1337
1696
|
tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1338
1697
|
} else {
|
|
1339
|
-
logger.log(` Executing summary
|
|
1698
|
+
logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
|
|
1340
1699
|
const result = await executeReview(
|
|
1341
1700
|
{
|
|
1342
1701
|
taskId,
|
|
@@ -1346,7 +1705,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1346
1705
|
repo,
|
|
1347
1706
|
prNumber,
|
|
1348
1707
|
timeout: timeoutSeconds,
|
|
1349
|
-
reviewMode: "full"
|
|
1708
|
+
reviewMode: "full",
|
|
1709
|
+
contextBlock
|
|
1350
1710
|
},
|
|
1351
1711
|
reviewDeps
|
|
1352
1712
|
);
|
|
@@ -1367,7 +1727,9 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1367
1727
|
signal
|
|
1368
1728
|
);
|
|
1369
1729
|
recordSessionUsage(consumptionDeps.session, tokensUsed2);
|
|
1370
|
-
logger.log(
|
|
1730
|
+
logger.log(
|
|
1731
|
+
` ${icons.success} Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`
|
|
1732
|
+
);
|
|
1371
1733
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1372
1734
|
return;
|
|
1373
1735
|
}
|
|
@@ -1381,13 +1743,14 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1381
1743
|
let summaryText;
|
|
1382
1744
|
let tokensUsed;
|
|
1383
1745
|
if (routerRelay) {
|
|
1384
|
-
logger.log(` Executing summary
|
|
1746
|
+
logger.log(` ${icons.running} Executing summary: [router mode]`);
|
|
1385
1747
|
const fullPrompt = routerRelay.buildSummaryPrompt({
|
|
1386
1748
|
owner,
|
|
1387
1749
|
repo,
|
|
1388
1750
|
prompt,
|
|
1389
1751
|
reviews: summaryReviews,
|
|
1390
|
-
diffContent
|
|
1752
|
+
diffContent,
|
|
1753
|
+
contextBlock
|
|
1391
1754
|
});
|
|
1392
1755
|
const response = await routerRelay.sendPrompt(
|
|
1393
1756
|
"summary_request",
|
|
@@ -1398,7 +1761,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1398
1761
|
summaryText = response;
|
|
1399
1762
|
tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
|
|
1400
1763
|
} else {
|
|
1401
|
-
logger.log(` Executing summary
|
|
1764
|
+
logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
|
|
1402
1765
|
const result = await executeSummary(
|
|
1403
1766
|
{
|
|
1404
1767
|
taskId,
|
|
@@ -1408,7 +1771,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1408
1771
|
repo,
|
|
1409
1772
|
prNumber,
|
|
1410
1773
|
timeout: timeoutSeconds,
|
|
1411
|
-
diffContent
|
|
1774
|
+
diffContent,
|
|
1775
|
+
contextBlock
|
|
1412
1776
|
},
|
|
1413
1777
|
reviewDeps
|
|
1414
1778
|
);
|
|
@@ -1427,7 +1791,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
|
|
|
1427
1791
|
signal
|
|
1428
1792
|
);
|
|
1429
1793
|
recordSessionUsage(consumptionDeps.session, tokensUsed);
|
|
1430
|
-
logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1794
|
+
logger.log(` ${icons.success} Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
|
|
1431
1795
|
logger.log(formatPostReviewStats(consumptionDeps.session));
|
|
1432
1796
|
}
|
|
1433
1797
|
function sleep2(ms, signal) {
|
|
@@ -1453,31 +1817,30 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
|
|
|
1453
1817
|
const deps = consumptionDeps ?? { agentId, session };
|
|
1454
1818
|
const logger = createLogger(options?.label);
|
|
1455
1819
|
const { log, logError, logWarn } = logger;
|
|
1456
|
-
|
|
1457
|
-
log(
|
|
1820
|
+
const agentSession = createAgentSession();
|
|
1821
|
+
log(`${icons.start} Agent started (polling ${platformUrl})`);
|
|
1458
1822
|
log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
|
|
1459
1823
|
if (!reviewDeps) {
|
|
1460
|
-
logError(
|
|
1824
|
+
logError(`${icons.error} No review command configured. Set command in config.yml`);
|
|
1461
1825
|
return;
|
|
1462
1826
|
}
|
|
1463
1827
|
if (reviewDeps.commandTemplate && !options?.routerRelay) {
|
|
1464
1828
|
log("Testing command...");
|
|
1465
1829
|
const result = await testCommand(reviewDeps.commandTemplate);
|
|
1466
1830
|
if (result.ok) {
|
|
1467
|
-
log(
|
|
1831
|
+
log(`${icons.success} Command test ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
|
|
1468
1832
|
} else {
|
|
1469
|
-
logWarn(
|
|
1833
|
+
logWarn(`${icons.warn} Command test failed (${result.error}). Reviews may fail.`);
|
|
1470
1834
|
}
|
|
1471
1835
|
}
|
|
1472
1836
|
const abortController = new AbortController();
|
|
1473
1837
|
process.on("SIGINT", () => {
|
|
1474
|
-
log("\nShutting down...");
|
|
1475
1838
|
abortController.abort();
|
|
1476
1839
|
});
|
|
1477
1840
|
process.on("SIGTERM", () => {
|
|
1478
1841
|
abortController.abort();
|
|
1479
1842
|
});
|
|
1480
|
-
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
|
|
1843
|
+
await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, agentSession, {
|
|
1481
1844
|
pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
1482
1845
|
maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
|
|
1483
1846
|
routerRelay: options?.routerRelay,
|
|
@@ -1485,7 +1848,7 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
|
|
|
1485
1848
|
repoConfig: options?.repoConfig,
|
|
1486
1849
|
signal: abortController.signal
|
|
1487
1850
|
});
|
|
1488
|
-
log(
|
|
1851
|
+
log(formatExitSummary(agentSession));
|
|
1489
1852
|
}
|
|
1490
1853
|
async function startAgentRouter() {
|
|
1491
1854
|
const config = loadConfig();
|
|
@@ -1661,7 +2024,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
|
|
|
1661
2024
|
});
|
|
1662
2025
|
|
|
1663
2026
|
// src/index.ts
|
|
1664
|
-
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.
|
|
2027
|
+
var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.12.1");
|
|
1665
2028
|
program.addCommand(agentCommand);
|
|
1666
2029
|
program.action(() => {
|
|
1667
2030
|
startAgentRouter();
|
package/package.json
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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
|
}
|