kodevu 0.1.28 → 0.1.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
@@ -0,0 +1,95 @@
1
+ import { countLines } from "./utils.js";
2
+
3
+ export const DIFF_LIMITS = {
4
+ review: {
5
+ maxLines: 4000,
6
+ maxChars: 120000
7
+ },
8
+ report: {
9
+ maxLines: 1500,
10
+ maxChars: 40000
11
+ },
12
+ tailLines: 200
13
+ };
14
+
15
+ export function trimBlockToChars(text, maxChars, keepTail = false) {
16
+ if (text.length <= maxChars) {
17
+ return text;
18
+ }
19
+
20
+ if (maxChars <= 3) {
21
+ return ".".repeat(Math.max(maxChars, 0));
22
+ }
23
+
24
+ return keepTail ? `...${text.slice(-(maxChars - 3))}` : `${text.slice(0, maxChars - 3)}...`;
25
+ }
26
+
27
+ export function truncateDiffText(diffText, maxLines, maxChars, tailLines, purposeLabel) {
28
+ const normalizedDiff = diffText.replace(/\r\n/g, "\n");
29
+ const originalLineCount = countLines(normalizedDiff);
30
+ const originalCharCount = normalizedDiff.length;
31
+
32
+ if (originalLineCount <= maxLines && originalCharCount <= maxChars) {
33
+ return {
34
+ text: diffText,
35
+ wasTruncated: false,
36
+ originalLineCount,
37
+ originalCharCount,
38
+ outputLineCount: originalLineCount,
39
+ outputCharCount: originalCharCount
40
+ };
41
+ }
42
+
43
+ const lines = normalizedDiff.split("\n");
44
+ const safeTailLines = Math.min(Math.max(tailLines, 0), Math.max(maxLines - 2, 0));
45
+ const headLineCount = Math.max(maxLines - safeTailLines - 1, 1);
46
+ let headBlock = lines.slice(0, headLineCount).join("\n");
47
+ let tailBlock = safeTailLines > 0 ? lines.slice(-safeTailLines).join("\n") : "";
48
+ const omittedLineCount = Math.max(originalLineCount - headLineCount - safeTailLines, 0);
49
+ const markerBlock = [
50
+ `... diff truncated for ${purposeLabel} ...`,
51
+ `original lines: ${originalLineCount}, original chars: ${originalCharCount}`,
52
+ `omitted lines: ${omittedLineCount}`
53
+ ].join("\n");
54
+
55
+ let truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
56
+
57
+ if (truncatedText.length > maxChars) {
58
+ const reservedChars = markerBlock.length + (tailBlock ? 2 : 1);
59
+ const remainingChars = Math.max(maxChars - reservedChars, 0);
60
+ const headBudget = tailBlock ? Math.floor(remainingChars * 0.7) : remainingChars;
61
+ const tailBudget = tailBlock ? Math.max(remainingChars - headBudget, 0) : 0;
62
+ headBlock = trimBlockToChars(headBlock, headBudget, false);
63
+ tailBlock = trimBlockToChars(tailBlock, tailBudget, true);
64
+ truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
65
+ }
66
+
67
+ return {
68
+ text: truncatedText,
69
+ wasTruncated: true,
70
+ originalLineCount,
71
+ originalCharCount,
72
+ outputLineCount: countLines(truncatedText),
73
+ outputCharCount: truncatedText.length
74
+ };
75
+ }
76
+
77
+ export function prepareDiffPayloads(config, diffText) {
78
+ return {
79
+ review: truncateDiffText(
80
+ diffText,
81
+ DIFF_LIMITS.review.maxLines,
82
+ DIFF_LIMITS.review.maxChars,
83
+ DIFF_LIMITS.tailLines,
84
+ "reviewer input"
85
+ ),
86
+ report: truncateDiffText(
87
+ diffText,
88
+ DIFF_LIMITS.report.maxLines,
89
+ DIFF_LIMITS.report.maxChars,
90
+ Math.min(DIFF_LIMITS.tailLines, DIFF_LIMITS.report.maxLines),
91
+ "report output"
92
+ )
93
+ };
94
+ }
95
+
@@ -0,0 +1,185 @@
1
+ export const CORE_REVIEW_INSTRUCTION =
2
+ "Please strictly review the current changes, prioritizing bugs, regression risks, compatibility issues, security concerns, boundary condition flaws, and missing tests. Please use Markdown for your response. If no clear flaws are found, write \"No clear flaws found\" and supplement with residual risks.";
3
+
4
+ export function getReviewWorkspaceRoot(config, backend, targetInfo) {
5
+ if (backend.kind === "git" && targetInfo.repoRootPath) {
6
+ return targetInfo.repoRootPath;
7
+ }
8
+
9
+ if (backend.kind === "svn" && targetInfo.workingCopyPath) {
10
+ return targetInfo.workingCopyPath;
11
+ }
12
+
13
+ return config.baseDir;
14
+ }
15
+
16
+ export function buildPrompt(config, backend, targetInfo, details, reviewDiffPayload) {
17
+ const fileList = details.changedPaths.map((item) => `${item.action} ${item.relativePath}`).join("\n");
18
+ const workspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
19
+ const canReadRelatedFiles = backend.kind === "git" || Boolean(targetInfo.workingCopyPath);
20
+
21
+ const langInstruction = config.resolvedLang === "zh"
22
+ ? "Please use Simplified Chinese for your response."
23
+ : `Please use ${config.resolvedLang || "English"} for your response.`;
24
+
25
+ return [
26
+ CORE_REVIEW_INSTRUCTION,
27
+ langInstruction,
28
+ config.prompt,
29
+ canReadRelatedFiles
30
+ ? `You are running inside a read-only workspace rooted at: ${workspaceRoot}`
31
+ : "No local repository workspace is available for this review run.",
32
+ canReadRelatedFiles
33
+ ? "Besides the diff below, you may read other related files in the workspace when needed to understand call sites, shared utilities, configuration, tests, or data flow. Do not modify files or rely on shell commands."
34
+ : "Review primarily from the diff below. Do not assume access to other local files, shell commands, or repository history.",
35
+ "Use plain text file references like path/to/file.js:123. Do not emit clickable workspace links.",
36
+ `Repository Type: ${backend.displayName}`,
37
+ `Change ID: ${details.displayId}`,
38
+ `Author: ${details.author}`,
39
+ `Date: ${details.date || "unknown"}`,
40
+ `Changed files:\n${fileList || "(none)"}`,
41
+ `Commit message:\n${details.message || "(empty)"}`,
42
+ reviewDiffPayload.wasTruncated
43
+ ? `Diff delivery note: the diff was truncated before being sent to the reviewer to stay within configured size limits. Original diff size was ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars, and the included excerpt is ${reviewDiffPayload.outputLineCount} lines / ${reviewDiffPayload.outputCharCount} chars. Use the changed file list and inspect related workspace files when needed.`
44
+ : `Diff delivery note: the full diff is included. Size is ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars.`
45
+ ].filter(Boolean).join("\n\n");
46
+ }
47
+
48
+ export function formatTokenUsage(tokenUsage) {
49
+ const sourceLabel = tokenUsage.source === "reviewer" ? "reviewer reported" : "estimated (~4 chars/token)";
50
+ return [
51
+ `- Input Tokens: \`${tokenUsage.inputTokens}\``,
52
+ `- Output Tokens: \`${tokenUsage.outputTokens}\``,
53
+ `- Total Tokens: \`${tokenUsage.totalTokens}\``,
54
+ `- Token Source: \`${sourceLabel}\``
55
+ ].join("\n");
56
+ }
57
+
58
+ export function formatDiffHandling(diffPayload, label) {
59
+ return [
60
+ `- ${label} Original Lines: \`${diffPayload.originalLineCount}\``,
61
+ `- ${label} Original Chars: \`${diffPayload.originalCharCount}\``,
62
+ `- ${label} Included Lines: \`${diffPayload.outputLineCount}\``,
63
+ `- ${label} Included Chars: \`${diffPayload.outputCharCount}\``,
64
+ `- ${label} Truncated: \`${diffPayload.wasTruncated ? "yes" : "no"}\``
65
+ ].join("\n");
66
+ }
67
+
68
+ export function formatChangedPaths(changedPaths) {
69
+ if (changedPaths.length === 0) {
70
+ return "_No changed files captured._";
71
+ }
72
+
73
+ return changedPaths
74
+ .map((item) => {
75
+ const renameSuffix = item.previousPath ? ` (from ${item.previousPath})` : "";
76
+ return `- \`${item.action}\` ${item.relativePath}${renameSuffix}`;
77
+ })
78
+ .join("\n");
79
+ }
80
+
81
+ export function formatChangeList(backend, changeIds) {
82
+ return changeIds.map((changeId) => backend.formatChangeId(changeId)).join(", ");
83
+ }
84
+
85
+ export function shouldWriteFormat(config, format) {
86
+ return Array.isArray(config.outputFormats) && config.outputFormats.includes(format);
87
+ }
88
+
89
+ export function buildReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
90
+ const lines = [
91
+ `# ${backend.displayName} Review Report: ${details.displayId}`,
92
+ "",
93
+ `- Repository Type: \`${backend.displayName}\``,
94
+ `- Target: \`${targetInfo.targetDisplay || config.target}\``,
95
+ `- Change ID: \`${details.displayId}\``,
96
+ `- Author: \`${details.author}\``,
97
+ `- Commit Date: \`${details.date || "unknown"}\``,
98
+ `- Generated At: \`${new Date().toISOString()}\``,
99
+ `- Reviewer: \`${reviewer.displayName}\``,
100
+ `- Reviewer Exit Code: \`${reviewerResult.code}\``,
101
+ `- Reviewer Timed Out: \`${reviewerResult.timedOut ? "yes" : "no"}\``,
102
+ "",
103
+ "## Token Usage",
104
+ "",
105
+ formatTokenUsage(tokenUsage),
106
+ "",
107
+ "## Changed Files",
108
+ "",
109
+ formatChangedPaths(details.changedPaths),
110
+ "",
111
+ "## Commit Message",
112
+ "",
113
+ details.message ? "```text\n" + details.message + "\n```" : "_Empty_",
114
+ "",
115
+ "## Review Context",
116
+ "",
117
+ "```text",
118
+ buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
119
+ "```",
120
+ "",
121
+ "## Diff Handling",
122
+ "",
123
+ formatDiffHandling(diffPayloads.review, "Reviewer Input"),
124
+ formatDiffHandling(diffPayloads.report, "Report Diff"),
125
+ "",
126
+ "## Diff",
127
+ "",
128
+ "```diff",
129
+ diffPayloads.report.text.trim() || "(empty diff)",
130
+ "```",
131
+ "",
132
+ `## ${reviewer.responseSectionTitle}`,
133
+ "",
134
+ reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
135
+ ];
136
+
137
+ return `${lines.join("\n")}\n`;
138
+ }
139
+
140
+ export function buildJsonReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
141
+ return {
142
+ repositoryType: backend.displayName,
143
+ target: targetInfo.targetDisplay || config.target,
144
+ changeId: details.displayId,
145
+ author: details.author,
146
+ commitDate: details.date || "unknown",
147
+ generatedAt: new Date().toISOString(),
148
+ reviewer: {
149
+ name: reviewer.displayName,
150
+ exitCode: reviewerResult.code,
151
+ timedOut: Boolean(reviewerResult.timedOut)
152
+ },
153
+ tokenUsage: {
154
+ inputTokens: tokenUsage.inputTokens,
155
+ outputTokens: tokenUsage.outputTokens,
156
+ totalTokens: tokenUsage.totalTokens,
157
+ source: tokenUsage.source
158
+ },
159
+ changedFiles: details.changedPaths.map((item) => ({
160
+ action: item.action,
161
+ path: item.relativePath,
162
+ previousPath: item.previousPath || null
163
+ })),
164
+ commitMessage: details.message || "",
165
+ reviewContext: buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
166
+ diffHandling: {
167
+ reviewerInput: {
168
+ originalLines: diffPayloads.review.originalLineCount,
169
+ originalChars: diffPayloads.review.originalCharCount,
170
+ includedLines: diffPayloads.review.outputLineCount,
171
+ includedChars: diffPayloads.review.outputCharCount,
172
+ truncated: diffPayloads.review.wasTruncated
173
+ },
174
+ reportDiff: {
175
+ originalLines: diffPayloads.report.originalLineCount,
176
+ originalChars: diffPayloads.report.originalCharCount,
177
+ includedLines: diffPayloads.report.outputLineCount,
178
+ includedChars: diffPayloads.report.outputCharCount,
179
+ truncated: diffPayloads.report.wasTruncated
180
+ }
181
+ },
182
+ diff: diffPayloads.report.text.trim(),
183
+ reviewerResponse: reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
184
+ };
185
+ }
@@ -1,594 +1,27 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
1
  import path from "node:path";
4
2
  import { ProgressDisplay } from "./progress-ui.js";
5
- import { runCommand } from "./shell.js";
6
3
  import { resolveRepositoryContext } from "./vcs-client.js";
7
4
  import { logger } from "./logger.js";
8
-
9
- const DIFF_LIMITS = {
10
- review: {
11
- maxLines: 4000,
12
- maxChars: 120000
13
- },
14
- report: {
15
- maxLines: 1500,
16
- maxChars: 40000
17
- },
18
- tailLines: 200
19
- };
20
-
21
- const CORE_REVIEW_INSTRUCTION =
22
- "Please strictly review the current changes, prioritizing bugs, regression risks, compatibility issues, security concerns, boundary condition flaws, and missing tests. Please use Markdown for your response. If no clear flaws are found, write \"No clear flaws found\" and supplement with residual risks.";
23
-
24
-
25
- function estimateTokenCount(text) {
26
- if (!text) {
27
- return 0;
28
- }
29
-
30
- return Math.ceil(text.length / 4);
31
- }
32
-
33
- function parseGeminiTokenUsage(stderr) {
34
- if (!stderr) {
35
- return null;
36
- }
37
-
38
- const patterns = [
39
- /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
40
- /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
41
- /total[_ ]tokens?\s*[:=]\s*(\d+)/i
42
- ];
43
-
44
- const inputMatch = stderr.match(patterns[0]);
45
- const outputMatch = stderr.match(patterns[1]);
46
- const totalMatch = stderr.match(patterns[2]);
47
-
48
- if (!inputMatch && !outputMatch && !totalMatch) {
49
- return null;
50
- }
51
-
52
- const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
53
- const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
54
- const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
55
-
56
- return { inputTokens, outputTokens, totalTokens };
57
- }
58
-
59
- function parseCodexTokenUsage(stderr) {
60
- if (!stderr) {
61
- return null;
62
- }
63
-
64
- const patterns = [
65
- /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
66
- /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
67
- /total[_ ]tokens?\s*[:=]\s*(\d+)/i
68
- ];
69
-
70
- const inputMatch = stderr.match(patterns[0]);
71
- const outputMatch = stderr.match(patterns[1]);
72
- const totalMatch = stderr.match(patterns[2]);
73
-
74
- if (!inputMatch && !outputMatch && !totalMatch) {
75
- return null;
76
- }
77
-
78
- const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
79
- const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
80
- const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
81
-
82
- return { inputTokens, outputTokens, totalTokens };
83
- }
84
-
85
- function parseCopilotTokenUsage(stderr) {
86
- if (!stderr) {
87
- return null;
88
- }
89
-
90
- const patterns = [
91
- /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
92
- /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
93
- /total[_ ]tokens?\s*[:=]\s*(\d+)/i
94
- ];
95
-
96
- const inputMatch = stderr.match(patterns[0]);
97
- const outputMatch = stderr.match(patterns[1]);
98
- const totalMatch = stderr.match(patterns[2]);
99
-
100
- if (!inputMatch && !outputMatch && !totalMatch) {
101
- return null;
102
- }
103
-
104
- const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
105
- const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
106
- const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
107
-
108
- return { inputTokens, outputTokens, totalTokens };
109
- }
110
-
111
- const TOKEN_PARSERS = {
112
- gemini: parseGeminiTokenUsage,
113
- codex: parseCodexTokenUsage,
114
- copilot: parseCopilotTokenUsage
115
- };
116
-
117
- function resolveTokenUsage(reviewerName, stderr, promptText, diffText, responseText) {
118
- const parseFn = TOKEN_PARSERS[reviewerName] || parseCopilotTokenUsage;
119
- const parsed = parseFn(stderr);
120
-
121
- if (parsed && parsed.totalTokens > 0) {
122
- return { ...parsed, source: "reviewer" };
123
- }
124
-
125
- const inputTokens = estimateTokenCount((promptText || "") + (diffText || ""));
126
- const outputTokens = estimateTokenCount(responseText || "");
127
-
128
- return {
129
- inputTokens,
130
- outputTokens,
131
- totalTokens: inputTokens + outputTokens,
132
- source: "estimate"
133
- };
134
- }
135
-
136
- const REVIEWERS = {
137
- codex: {
138
- displayName: "Codex",
139
- responseSectionTitle: "Codex Response",
140
- emptyResponseText: "_No final response returned from codex exec._",
141
- async run(config, workingDir, promptText, diffText) {
142
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kodevu-"));
143
- const outputFile = path.join(tempDir, "codex-last-message.md");
144
- const args = [
145
- "exec",
146
- "--skip-git-repo-check",
147
- "--sandbox",
148
- "read-only",
149
- "--color",
150
- "never",
151
- "--output-last-message",
152
- outputFile,
153
- "-"
154
- ];
155
-
156
- try {
157
- const execResult = await runCommand("codex", args, {
158
- cwd: workingDir,
159
- input: [promptText, "Unified diff:", diffText].join("\n\n"),
160
- allowFailure: true,
161
- timeoutMs: config.commandTimeoutMs,
162
- debug: config.debug
163
- });
164
-
165
- let message = "";
166
-
167
- try {
168
- message = await fs.readFile(outputFile, "utf8");
169
- } catch {
170
- message = execResult.stdout;
171
- }
172
-
173
- return {
174
- ...execResult,
175
- message
176
- };
177
- } finally {
178
- await fs.rm(tempDir, { recursive: true, force: true });
179
- }
180
- }
181
- },
182
- gemini: {
183
- displayName: "Gemini",
184
- responseSectionTitle: "Gemini Response",
185
- emptyResponseText: "_No final response returned from gemini._",
186
- async run(config, workingDir, promptText, diffText) {
187
- const execResult = await runCommand("gemini", ["-p", promptText], {
188
- cwd: workingDir,
189
- input: ["Unified diff:", diffText].join("\n\n"),
190
- allowFailure: true,
191
- timeoutMs: config.commandTimeoutMs,
192
- debug: config.debug
193
- });
194
-
195
- return {
196
- ...execResult,
197
- message: execResult.stdout
198
- };
199
- }
200
- },
201
- copilot: {
202
- displayName: "Copilot",
203
- responseSectionTitle: "Copilot Response",
204
- emptyResponseText: "_No final response returned from copilot._",
205
- async run(config, workingDir, promptText, diffText) {
206
- const execResult = await runCommand("copilot", ["-p", promptText], {
207
- cwd: workingDir,
208
- input: ["Unified diff:", diffText].join("\n\n"),
209
- allowFailure: true,
210
- timeoutMs: config.commandTimeoutMs,
211
- debug: config.debug
212
- });
213
-
214
- return {
215
- ...execResult,
216
- message: execResult.stdout
217
- };
218
- }
219
- }
220
- };
221
-
222
- async function ensureDir(targetPath) {
223
- await fs.mkdir(targetPath, { recursive: true });
224
- }
225
-
226
- async function pathExists(targetPath) {
227
- try {
228
- await fs.access(targetPath);
229
- return true;
230
- } catch {
231
- return false;
232
- }
233
- }
234
-
235
- async function loadState(stateFile) {
236
- if (!(await pathExists(stateFile))) {
237
- return { version: 2, projects: {} };
238
- }
239
-
240
- const raw = await fs.readFile(stateFile, "utf8");
241
- return normalizeStateFile(JSON.parse(raw));
242
- }
243
-
244
- async function saveState(stateFile, state) {
245
- await ensureDir(path.dirname(stateFile));
246
- await fs.writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
247
- }
248
-
249
- function normalizeStateFile(state) {
250
- if (!state || typeof state !== "object" || Array.isArray(state)) {
251
- throw new Error('State file must be a JSON object with shape {"version":2,"projects":{...}}.');
252
- }
253
-
254
- if (state.version !== 2) {
255
- throw new Error('State file version must be 2.');
256
- }
257
-
258
- if (!state.projects || typeof state.projects !== "object" || Array.isArray(state.projects)) {
259
- throw new Error('State file must contain a "projects" object.');
260
- }
261
-
262
- return {
263
- version: 2,
264
- projects: state.projects
265
- };
266
- }
267
-
268
- function getProjectState(stateFile, targetInfo) {
269
- return stateFile.projects?.[targetInfo.stateKey] ?? {};
270
- }
271
-
272
- function updateProjectState(stateFile, targetInfo, projectState) {
273
- return {
274
- version: 2,
275
- projects: {
276
- ...(stateFile.projects || {}),
277
- [targetInfo.stateKey]: projectState
278
- }
279
- };
280
- }
281
-
282
- async function writeTextFile(filePath, contents) {
283
- await ensureDir(path.dirname(filePath));
284
- await fs.writeFile(filePath, contents, "utf8");
285
- }
286
-
287
- async function writeJsonFile(filePath, payload) {
288
- await ensureDir(path.dirname(filePath));
289
- await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
290
- }
291
-
292
- function shouldWriteFormat(config, format) {
293
- return Array.isArray(config.outputFormats) && config.outputFormats.includes(format);
294
- }
295
-
296
- function formatChangedPaths(changedPaths) {
297
- if (changedPaths.length === 0) {
298
- return "_No changed files captured._";
299
- }
300
-
301
- return changedPaths
302
- .map((item) => {
303
- const renameSuffix = item.previousPath ? ` (from ${item.previousPath})` : "";
304
- return `- \`${item.action}\` ${item.relativePath}${renameSuffix}`;
305
- })
306
- .join("\n");
307
- }
308
-
309
- function getReviewWorkspaceRoot(config, backend, targetInfo) {
310
- if (backend.kind === "git" && targetInfo.repoRootPath) {
311
- return targetInfo.repoRootPath;
312
- }
313
-
314
- if (backend.kind === "svn" && targetInfo.workingCopyPath) {
315
- return targetInfo.workingCopyPath;
316
- }
317
-
318
- return config.baseDir;
319
- }
320
-
321
- function countLines(text) {
322
- if (!text) {
323
- return 0;
324
- }
325
-
326
- return text.split(/\r?\n/).length;
327
- }
328
-
329
- function trimBlockToChars(text, maxChars, keepTail = false) {
330
- if (text.length <= maxChars) {
331
- return text;
332
- }
333
-
334
- if (maxChars <= 3) {
335
- return ".".repeat(Math.max(maxChars, 0));
336
- }
337
-
338
- return keepTail ? `...${text.slice(-(maxChars - 3))}` : `${text.slice(0, maxChars - 3)}...`;
339
- }
340
-
341
- function truncateDiffText(diffText, maxLines, maxChars, tailLines, purposeLabel) {
342
- const normalizedDiff = diffText.replace(/\r\n/g, "\n");
343
- const originalLineCount = countLines(normalizedDiff);
344
- const originalCharCount = normalizedDiff.length;
345
-
346
- if (originalLineCount <= maxLines && originalCharCount <= maxChars) {
347
- return {
348
- text: diffText,
349
- wasTruncated: false,
350
- originalLineCount,
351
- originalCharCount,
352
- outputLineCount: originalLineCount,
353
- outputCharCount: originalCharCount
354
- };
355
- }
356
-
357
- const lines = normalizedDiff.split("\n");
358
- const safeTailLines = Math.min(Math.max(tailLines, 0), Math.max(maxLines - 2, 0));
359
- const headLineCount = Math.max(maxLines - safeTailLines - 1, 1);
360
- let headBlock = lines.slice(0, headLineCount).join("\n");
361
- let tailBlock = safeTailLines > 0 ? lines.slice(-safeTailLines).join("\n") : "";
362
- const omittedLineCount = Math.max(originalLineCount - headLineCount - safeTailLines, 0);
363
- const markerBlock = [
364
- `... diff truncated for ${purposeLabel} ...`,
365
- `original lines: ${originalLineCount}, original chars: ${originalCharCount}`,
366
- `omitted lines: ${omittedLineCount}`
367
- ].join("\n");
368
-
369
- let truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
370
-
371
- if (truncatedText.length > maxChars) {
372
- const reservedChars = markerBlock.length + (tailBlock ? 2 : 1);
373
- const remainingChars = Math.max(maxChars - reservedChars, 0);
374
- const headBudget = tailBlock ? Math.floor(remainingChars * 0.7) : remainingChars;
375
- const tailBudget = tailBlock ? Math.max(remainingChars - headBudget, 0) : 0;
376
- headBlock = trimBlockToChars(headBlock, headBudget, false);
377
- tailBlock = trimBlockToChars(tailBlock, tailBudget, true);
378
- truncatedText = [headBlock, markerBlock, tailBlock].filter(Boolean).join("\n");
379
- }
380
-
381
- return {
382
- text: truncatedText,
383
- wasTruncated: true,
384
- originalLineCount,
385
- originalCharCount,
386
- outputLineCount: countLines(truncatedText),
387
- outputCharCount: truncatedText.length
388
- };
389
- }
390
-
391
- function prepareDiffPayloads(config, diffText) {
392
- return {
393
- review: truncateDiffText(
394
- diffText,
395
- DIFF_LIMITS.review.maxLines,
396
- DIFF_LIMITS.review.maxChars,
397
- DIFF_LIMITS.tailLines,
398
- "reviewer input"
399
- ),
400
- report: truncateDiffText(
401
- diffText,
402
- DIFF_LIMITS.report.maxLines,
403
- DIFF_LIMITS.report.maxChars,
404
- Math.min(DIFF_LIMITS.tailLines, DIFF_LIMITS.report.maxLines),
405
- "report output"
406
- )
407
- };
408
- }
409
-
410
- function formatDiffHandling(diffPayload, label) {
411
- return [
412
- `- ${label} Original Lines: \`${diffPayload.originalLineCount}\``,
413
- `- ${label} Original Chars: \`${diffPayload.originalCharCount}\``,
414
- `- ${label} Included Lines: \`${diffPayload.outputLineCount}\``,
415
- `- ${label} Included Chars: \`${diffPayload.outputCharCount}\``,
416
- `- ${label} Truncated: \`${diffPayload.wasTruncated ? "yes" : "no"}\``
417
- ].join("\n");
418
- }
419
-
420
- function buildPrompt(config, backend, targetInfo, details, reviewDiffPayload) {
421
- const fileList = details.changedPaths.map((item) => `${item.action} ${item.relativePath}`).join("\n");
422
- const workspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
423
- const canReadRelatedFiles = backend.kind === "git" || Boolean(targetInfo.workingCopyPath);
424
-
425
- const langInstruction = config.resolvedLang === "zh"
426
- ? "Please use Simplified Chinese for your response."
427
- : `Please use ${config.resolvedLang || "English"} for your response.`;
428
-
429
- return [
430
- CORE_REVIEW_INSTRUCTION,
431
- langInstruction,
432
- config.prompt,
433
- canReadRelatedFiles
434
- ? `You are running inside a read-only workspace rooted at: ${workspaceRoot}`
435
- : "No local repository workspace is available for this review run.",
436
- canReadRelatedFiles
437
- ? "Besides the diff below, you may read other related files in the workspace when needed to understand call sites, shared utilities, configuration, tests, or data flow. Do not modify files or rely on shell commands."
438
- : "Review primarily from the diff below. Do not assume access to other local files, shell commands, or repository history.",
439
- "Use plain text file references like path/to/file.js:123. Do not emit clickable workspace links.",
440
- `Repository Type: ${backend.displayName}`,
441
- `Change ID: ${details.displayId}`,
442
- `Author: ${details.author}`,
443
- `Date: ${details.date || "unknown"}`,
444
- `Changed files:\n${fileList || "(none)"}`,
445
- `Commit message:\n${details.message || "(empty)"}`,
446
- reviewDiffPayload.wasTruncated
447
- ? `Diff delivery note: the diff was truncated before being sent to the reviewer to stay within configured size limits. Original diff size was ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars, and the included excerpt is ${reviewDiffPayload.outputLineCount} lines / ${reviewDiffPayload.outputCharCount} chars. Use the changed file list and inspect related workspace files when needed.`
448
- : `Diff delivery note: the full diff is included. Size is ${reviewDiffPayload.originalLineCount} lines / ${reviewDiffPayload.originalCharCount} chars.`
449
- ].filter(Boolean).join("\n\n");
450
- }
451
-
452
- function formatTokenUsage(tokenUsage) {
453
- const sourceLabel = tokenUsage.source === "reviewer" ? "reviewer reported" : "estimated (~4 chars/token)";
454
- return [
455
- `- Input Tokens: \`${tokenUsage.inputTokens}\``,
456
- `- Output Tokens: \`${tokenUsage.outputTokens}\``,
457
- `- Total Tokens: \`${tokenUsage.totalTokens}\``,
458
- `- Token Source: \`${sourceLabel}\``
459
- ].join("\n");
460
- }
461
-
462
- function buildReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
463
- const lines = [
464
- `# ${backend.displayName} Review Report: ${details.displayId}`,
465
- "",
466
- `- Repository Type: \`${backend.displayName}\``,
467
- `- Target: \`${targetInfo.targetDisplay || config.target}\``,
468
- `- Change ID: \`${details.displayId}\``,
469
- `- Author: \`${details.author}\``,
470
- `- Commit Date: \`${details.date || "unknown"}\``,
471
- `- Generated At: \`${new Date().toISOString()}\``,
472
- `- Reviewer: \`${reviewer.displayName}\``,
473
- `- Reviewer Exit Code: \`${reviewerResult.code}\``,
474
- `- Reviewer Timed Out: \`${reviewerResult.timedOut ? "yes" : "no"}\``,
475
- "",
476
- "## Token Usage",
477
- "",
478
- formatTokenUsage(tokenUsage),
479
- "",
480
- "## Changed Files",
481
- "",
482
- formatChangedPaths(details.changedPaths),
483
- "",
484
- "## Commit Message",
485
- "",
486
- details.message ? "```text\n" + details.message + "\n```" : "_Empty_",
487
- "",
488
- "## Review Context",
489
- "",
490
- "```text",
491
- buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
492
- "```",
493
- "",
494
- "## Diff Handling",
495
- "",
496
- formatDiffHandling(diffPayloads.review, "Reviewer Input"),
497
- formatDiffHandling(diffPayloads.report, "Report Diff"),
498
- "",
499
- "## Diff",
500
- "",
501
- "```diff",
502
- diffPayloads.report.text.trim() || "(empty diff)",
503
- "```",
504
- "",
505
- `## ${reviewer.responseSectionTitle}`,
506
- "",
507
- reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
508
- ];
509
-
510
- return `${lines.join("\n")}\n`;
511
- }
512
-
513
- function buildJsonReport(config, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult, tokenUsage) {
514
- return {
515
- repositoryType: backend.displayName,
516
- target: targetInfo.targetDisplay || config.target,
517
- changeId: details.displayId,
518
- author: details.author,
519
- commitDate: details.date || "unknown",
520
- generatedAt: new Date().toISOString(),
521
- reviewer: {
522
- name: reviewer.displayName,
523
- exitCode: reviewerResult.code,
524
- timedOut: Boolean(reviewerResult.timedOut)
525
- },
526
- tokenUsage: {
527
- inputTokens: tokenUsage.inputTokens,
528
- outputTokens: tokenUsage.outputTokens,
529
- totalTokens: tokenUsage.totalTokens,
530
- source: tokenUsage.source
531
- },
532
- changedFiles: details.changedPaths.map((item) => ({
533
- action: item.action,
534
- path: item.relativePath,
535
- previousPath: item.previousPath || null
536
- })),
537
- commitMessage: details.message || "",
538
- reviewContext: buildPrompt(config, backend, targetInfo, details, diffPayloads.review),
539
- diffHandling: {
540
- reviewerInput: {
541
- originalLines: diffPayloads.review.originalLineCount,
542
- originalChars: diffPayloads.review.originalCharCount,
543
- includedLines: diffPayloads.review.outputLineCount,
544
- includedChars: diffPayloads.review.outputCharCount,
545
- truncated: diffPayloads.review.wasTruncated
546
- },
547
- reportDiff: {
548
- originalLines: diffPayloads.report.originalLineCount,
549
- originalChars: diffPayloads.report.originalCharCount,
550
- includedLines: diffPayloads.report.outputLineCount,
551
- includedChars: diffPayloads.report.outputCharCount,
552
- truncated: diffPayloads.report.wasTruncated
553
- }
554
- },
555
- diff: diffPayloads.report.text.trim(),
556
- reviewerResponse: reviewerResult.message?.trim() ? reviewerResult.message.trim() : reviewer.emptyResponseText
557
- };
558
- }
559
-
560
- async function runReviewerPrompt(config, backend, targetInfo, details, diffText) {
561
- const reviewer = REVIEWERS[config.reviewer];
562
- const reviewWorkspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
563
- const diffPayloads = prepareDiffPayloads(config, diffText);
564
- const promptText = buildPrompt(config, backend, targetInfo, details, diffPayloads.review);
565
- const result = await reviewer.run(config, reviewWorkspaceRoot, promptText, diffPayloads.review.text);
566
- const tokenUsage = resolveTokenUsage(
567
- config.reviewer,
568
- result.stderr,
569
- promptText,
570
- diffPayloads.review.text,
571
- result.message
572
- );
573
-
574
- return {
575
- reviewer,
576
- diffPayloads,
577
- result,
578
- tokenUsage
579
- };
580
- }
581
-
582
- function readLastReviewedId(state, backend, targetInfo) {
583
- return backend.fromStateValue(state);
584
- }
585
-
586
- function buildStateSnapshot(backend, targetInfo, changeId) {
587
- return {
588
- lastReviewedId: backend.toStateValue(changeId),
589
- updatedAt: new Date().toISOString()
590
- };
591
- }
5
+ import {
6
+ ensureDir,
7
+ writeTextFile,
8
+ writeJsonFile
9
+ } from "./utils.js";
10
+ import {
11
+ loadState,
12
+ saveState,
13
+ getProjectState,
14
+ updateProjectState,
15
+ readLastReviewedId,
16
+ buildStateSnapshot
17
+ } from "./state-manager.js";
18
+ import {
19
+ shouldWriteFormat,
20
+ buildReport,
21
+ buildJsonReport,
22
+ formatChangeList
23
+ } from "./report-generator.js";
24
+ import { runReviewerPrompt } from "./reviewers.js";
592
25
 
593
26
  async function reviewChange(config, backend, targetInfo, changeId, progress) {
594
27
  const displayId = backend.formatChangeId(changeId);
@@ -644,24 +77,29 @@ async function reviewChange(config, backend, targetInfo, changeId, progress) {
644
77
  logger.debug(`Trying reviewer: ${reviewerName}`);
645
78
  progress?.update(0.45, `running reviewer ${reviewerName}`);
646
79
 
647
- const res = await runReviewerPrompt(
648
- currentReviewerConfig,
649
- backend,
650
- targetInfo,
651
- details,
652
- diffText
653
- );
654
- reviewer = res.reviewer;
655
- diffPayloads = res.diffPayloads;
656
- reviewerResult = res.result;
657
- tokenUsage = res.tokenUsage;
658
-
659
- if (reviewerResult.code === 0 && !reviewerResult.timedOut) {
660
- break;
80
+ try {
81
+ const res = await runReviewerPrompt(
82
+ currentReviewerConfig,
83
+ backend,
84
+ targetInfo,
85
+ details,
86
+ diffText
87
+ );
88
+ reviewer = res.reviewer;
89
+ diffPayloads = res.diffPayloads;
90
+ reviewerResult = res.result;
91
+ tokenUsage = res.tokenUsage;
92
+
93
+ if (reviewerResult.code === 0 && !reviewerResult.timedOut) {
94
+ break;
95
+ }
96
+ } catch (err) {
97
+ logger.error(`Reviewer prompt failed for ${reviewerName}: ${err.message}`);
98
+ // If it's the last one, it will throw below or break loop anyway
661
99
  }
662
100
 
663
101
  if (reviewerName !== reviewersToTry[reviewersToTry.length - 1]) {
664
- const msg = `${reviewer.displayName} failed for ${details.displayId}; trying next reviewer...`;
102
+ const msg = `${reviewer?.displayName || reviewerName} failed for ${details.displayId}; trying next reviewer...`;
665
103
  logger.warn(msg);
666
104
  }
667
105
  }
@@ -706,10 +144,6 @@ async function reviewChange(config, backend, targetInfo, changeId, progress) {
706
144
  };
707
145
  }
708
146
 
709
- function formatChangeList(backend, changeIds) {
710
- return changeIds.map((changeId) => backend.formatChangeId(changeId)).join(", ");
711
- }
712
-
713
147
  function updateOverallProgress(progress, completedCount, totalCount, currentFraction, stage) {
714
148
  if (!progress || totalCount <= 0) {
715
149
  return;
@@ -791,24 +225,10 @@ export async function runReviewCycle(config) {
791
225
  throw error;
792
226
  }
793
227
 
794
- const nextProjectState = buildStateSnapshot(backend, targetInfo, changeId);
795
- await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectState));
796
- stateFile.projects[targetInfo.stateKey] = nextProjectState;
228
+ const nextProjectSnapshot = buildStateSnapshot(backend, targetInfo, changeId);
229
+ await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectSnapshot));
230
+ stateFile.projects[targetInfo.stateKey] = nextProjectSnapshot;
797
231
  logger.debug(`Saved checkpoint for ${backend.formatChangeId(changeId)} to ${config.stateFilePath}.`);
798
232
  updateOverallProgress(progress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
799
233
  }
800
-
801
- progress.succeed(`completed ${changeIdsToReview.length}/${changeIdsToReview.length}`);
802
-
803
- const remainingChanges = await backend.getPendingChangeIds(
804
- config,
805
- targetInfo,
806
- changeIdsToReview[changeIdsToReview.length - 1],
807
- latestChangeId,
808
- 1
809
- );
810
-
811
- if (remainingChanges.length > 0) {
812
- logger.info(`Backlog remains. Latest ${backend.changeName} is ${backend.formatChangeId(latestChangeId)}.`);
813
- }
814
234
  }
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { runCommand } from "./shell.js";
5
+ import { prepareDiffPayloads } from "./diff-processor.js";
6
+ import { buildPrompt, getReviewWorkspaceRoot } from "./report-generator.js";
7
+ import { resolveTokenUsage } from "./token-usage.js";
8
+
9
+ export const REVIEWERS = {
10
+ codex: {
11
+ displayName: "Codex",
12
+ responseSectionTitle: "Codex Response",
13
+ emptyResponseText: "_No final response returned from codex exec._",
14
+ async run(config, workingDir, promptText, diffText) {
15
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kodevu-"));
16
+ const outputFile = path.join(tempDir, "codex-last-message.md");
17
+ const args = [
18
+ "exec",
19
+ "--skip-git-repo-check",
20
+ "--sandbox",
21
+ "read-only",
22
+ "--color",
23
+ "never",
24
+ "--output-last-message",
25
+ outputFile,
26
+ "-"
27
+ ];
28
+
29
+ try {
30
+ const execResult = await runCommand("codex", args, {
31
+ cwd: workingDir,
32
+ input: [promptText, "Unified diff:", diffText].join("\n\n"),
33
+ allowFailure: true,
34
+ timeoutMs: config.commandTimeoutMs,
35
+ debug: config.debug
36
+ });
37
+
38
+ let message = "";
39
+
40
+ try {
41
+ message = await fs.readFile(outputFile, "utf8");
42
+ } catch {
43
+ message = execResult.stdout;
44
+ }
45
+
46
+ return {
47
+ ...execResult,
48
+ message
49
+ };
50
+ } finally {
51
+ await fs.rm(tempDir, { recursive: true, force: true });
52
+ }
53
+ }
54
+ },
55
+ gemini: {
56
+ displayName: "Gemini",
57
+ responseSectionTitle: "Gemini Response",
58
+ emptyResponseText: "_No final response returned from gemini._",
59
+ async run(config, workingDir, promptText, diffText) {
60
+ const execResult = await runCommand("gemini", ["-p", promptText], {
61
+ cwd: workingDir,
62
+ input: ["Unified diff:", diffText].join("\n\n"),
63
+ allowFailure: true,
64
+ timeoutMs: config.commandTimeoutMs,
65
+ debug: config.debug
66
+ });
67
+
68
+ return {
69
+ ...execResult,
70
+ message: execResult.stdout
71
+ };
72
+ }
73
+ },
74
+ copilot: {
75
+ displayName: "Copilot",
76
+ responseSectionTitle: "Copilot Response",
77
+ emptyResponseText: "_No final response returned from copilot._",
78
+ async run(config, workingDir, promptText, diffText) {
79
+ const execResult = await runCommand("copilot", ["-p", promptText], {
80
+ cwd: workingDir,
81
+ input: ["Unified diff:", diffText].join("\n\n"),
82
+ allowFailure: true,
83
+ timeoutMs: config.commandTimeoutMs,
84
+ debug: config.debug
85
+ });
86
+
87
+ return {
88
+ ...execResult,
89
+ message: execResult.stdout
90
+ };
91
+ }
92
+ }
93
+ };
94
+
95
+ export async function runReviewerPrompt(config, backend, targetInfo, details, diffText) {
96
+ const reviewer = REVIEWERS[config.reviewer];
97
+ const reviewWorkspaceRoot = getReviewWorkspaceRoot(config, backend, targetInfo);
98
+ const diffPayloads = prepareDiffPayloads(config, diffText);
99
+ const promptText = buildPrompt(config, backend, targetInfo, details, diffPayloads.review);
100
+ const result = await reviewer.run(config, reviewWorkspaceRoot, promptText, diffPayloads.review.text);
101
+ const tokenUsage = resolveTokenUsage(
102
+ config.reviewer,
103
+ result.stderr,
104
+ promptText,
105
+ diffPayloads.review.text,
106
+ result.message
107
+ );
108
+
109
+ return {
110
+ reviewer,
111
+ diffPayloads,
112
+ result,
113
+ tokenUsage
114
+ };
115
+ }
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathExists, ensureDir } from "./utils.js";
4
+
5
+ function normalizeStateFile(state) {
6
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
7
+ throw new Error('State file must be a JSON object with shape {"version":2,"projects":{...}}.');
8
+ }
9
+
10
+ if (state.version !== 2) {
11
+ throw new Error('State file version must be 2.');
12
+ }
13
+
14
+ if (!state.projects || typeof state.projects !== "object" || Array.isArray(state.projects)) {
15
+ throw new Error('State file must contain a "projects" object.');
16
+ }
17
+
18
+ return {
19
+ version: 2,
20
+ projects: state.projects
21
+ };
22
+ }
23
+
24
+ export async function loadState(stateFile) {
25
+ if (!(await pathExists(stateFile))) {
26
+ return { version: 2, projects: {} };
27
+ }
28
+
29
+ const raw = await fs.readFile(stateFile, "utf8");
30
+ return normalizeStateFile(JSON.parse(raw));
31
+ }
32
+
33
+ export async function saveState(stateFile, state) {
34
+ await ensureDir(path.dirname(stateFile));
35
+ await fs.writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
36
+ }
37
+
38
+ export function getProjectState(stateFile, targetInfo) {
39
+ return stateFile.projects?.[targetInfo.stateKey] ?? {};
40
+ }
41
+
42
+ export function updateProjectState(stateFile, targetInfo, projectState) {
43
+ return {
44
+ version: 2,
45
+ projects: {
46
+ ...(stateFile.projects || {}),
47
+ [targetInfo.stateKey]: projectState
48
+ }
49
+ };
50
+ }
51
+
52
+ export function readLastReviewedId(state, backend, targetInfo) {
53
+ return backend.fromStateValue(state);
54
+ }
55
+
56
+ export function buildStateSnapshot(backend, targetInfo, changeId) {
57
+ return {
58
+ lastReviewedId: backend.toStateValue(changeId),
59
+ updatedAt: new Date().toISOString()
60
+ };
61
+ }
@@ -0,0 +1,110 @@
1
+ export function estimateTokenCount(text) {
2
+ if (!text) {
3
+ return 0;
4
+ }
5
+
6
+ return Math.ceil(text.length / 4);
7
+ }
8
+
9
+ export function parseGeminiTokenUsage(stderr) {
10
+ if (!stderr) {
11
+ return null;
12
+ }
13
+
14
+ const patterns = [
15
+ /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
16
+ /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
17
+ /total[_ ]tokens?\s*[:=]\s*(\d+)/i
18
+ ];
19
+
20
+ const inputMatch = stderr.match(patterns[0]);
21
+ const outputMatch = stderr.match(patterns[1]);
22
+ const totalMatch = stderr.match(patterns[2]);
23
+
24
+ if (!inputMatch && !outputMatch && !totalMatch) {
25
+ return null;
26
+ }
27
+
28
+ const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
29
+ const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
30
+ const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
31
+
32
+ return { inputTokens, outputTokens, totalTokens };
33
+ }
34
+
35
+ export function parseCodexTokenUsage(stderr) {
36
+ if (!stderr) {
37
+ return null;
38
+ }
39
+
40
+ const patterns = [
41
+ /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
42
+ /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
43
+ /total[_ ]tokens?\s*[:=]\s*(\d+)/i
44
+ ];
45
+
46
+ const inputMatch = stderr.match(patterns[0]);
47
+ const outputMatch = stderr.match(patterns[1]);
48
+ const totalMatch = stderr.match(patterns[2]);
49
+
50
+ if (!inputMatch && !outputMatch && !totalMatch) {
51
+ return null;
52
+ }
53
+
54
+ const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
55
+ const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
56
+ const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
57
+
58
+ return { inputTokens, outputTokens, totalTokens };
59
+ }
60
+
61
+ export function parseCopilotTokenUsage(stderr) {
62
+ if (!stderr) {
63
+ return null;
64
+ }
65
+
66
+ const patterns = [
67
+ /input[_ ]tokens?\s*[:=]\s*(\d+)/i,
68
+ /output[_ ]tokens?\s*[:=]\s*(\d+)/i,
69
+ /total[_ ]tokens?\s*[:=]\s*(\d+)/i
70
+ ];
71
+
72
+ const inputMatch = stderr.match(patterns[0]);
73
+ const outputMatch = stderr.match(patterns[1]);
74
+ const totalMatch = stderr.match(patterns[2]);
75
+
76
+ if (!inputMatch && !outputMatch && !totalMatch) {
77
+ return null;
78
+ }
79
+
80
+ const inputTokens = inputMatch ? Number(inputMatch[1]) : 0;
81
+ const outputTokens = outputMatch ? Number(outputMatch[1]) : 0;
82
+ const totalTokens = totalMatch ? Number(totalMatch[1]) : inputTokens + outputTokens;
83
+
84
+ return { inputTokens, outputTokens, totalTokens };
85
+ }
86
+
87
+ export const TOKEN_PARSERS = {
88
+ gemini: parseGeminiTokenUsage,
89
+ codex: parseCodexTokenUsage,
90
+ copilot: parseCopilotTokenUsage
91
+ };
92
+
93
+ export function resolveTokenUsage(reviewerName, stderr, promptText, diffText, responseText) {
94
+ const parseFn = TOKEN_PARSERS[reviewerName] || parseCopilotTokenUsage;
95
+ const parsed = parseFn(stderr);
96
+
97
+ if (parsed && parsed.totalTokens > 0) {
98
+ return { ...parsed, source: "reviewer" };
99
+ }
100
+
101
+ const inputTokens = estimateTokenCount((promptText || "") + (diffText || ""));
102
+ const outputTokens = estimateTokenCount(responseText || "");
103
+
104
+ return {
105
+ inputTokens,
106
+ outputTokens,
107
+ totalTokens: inputTokens + outputTokens,
108
+ source: "estimate"
109
+ };
110
+ }
package/src/utils.js ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function ensureDir(targetPath) {
5
+ await fs.mkdir(targetPath, { recursive: true });
6
+ }
7
+
8
+ export async function pathExists(targetPath) {
9
+ try {
10
+ await fs.access(targetPath);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function writeTextFile(filePath, contents) {
18
+ const dir = path.dirname(filePath);
19
+ await ensureDir(dir);
20
+ await fs.writeFile(filePath, contents, "utf8");
21
+ }
22
+
23
+ export async function writeJsonFile(filePath, payload) {
24
+ const dir = path.dirname(filePath);
25
+ await ensureDir(dir);
26
+ await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
27
+ }
28
+
29
+ export function countLines(text) {
30
+ if (!text) {
31
+ return 0;
32
+ }
33
+ return text.split(/\r?\n/).length;
34
+ }