xibecode 0.6.2 → 0.7.3
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 +11 -2
- package/dist/commands/chat.d.ts +1 -0
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +2 -1
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +15 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/run-pr.d.ts +3 -0
- package/dist/commands/run-pr.d.ts.map +1 -1
- package/dist/commands/run-pr.js +470 -69
- package/dist/commands/run-pr.js.map +1 -1
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +36 -5
- package/dist/commands/run.js.map +1 -1
- package/dist/core/agent.d.ts +27 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +90 -3
- package/dist/core/agent.js.map +1 -1
- package/dist/core/context-pruner.d.ts +19 -0
- package/dist/core/context-pruner.d.ts.map +1 -0
- package/dist/core/context-pruner.js +103 -0
- package/dist/core/context-pruner.js.map +1 -0
- package/dist/core/modes.d.ts.map +1 -1
- package/dist/core/modes.js +1 -0
- package/dist/core/modes.js.map +1 -1
- package/dist/core/session-memory.d.ts +45 -0
- package/dist/core/session-memory.d.ts.map +1 -0
- package/dist/core/session-memory.js +103 -0
- package/dist/core/session-memory.js.map +1 -0
- package/dist/core/tools.d.ts +3 -0
- package/dist/core/tools.d.ts.map +1 -1
- package/dist/core/tools.js +415 -357
- package/dist/core/tools.js.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/config.d.ts +26 -2
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +43 -2
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/safety.d.ts +21 -0
- package/dist/utils/safety.d.ts.map +1 -1
- package/dist/utils/safety.js +36 -0
- package/dist/utils/safety.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/run-pr.js
CHANGED
|
@@ -1,21 +1,58 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
|
-
import { exec } from 'child_process';
|
|
2
|
+
import { exec, spawn } from 'child_process';
|
|
3
3
|
import { promisify } from 'util';
|
|
4
4
|
import { EnhancedAgent } from '../core/agent.js';
|
|
5
5
|
import { CodingToolExecutor } from '../core/tools.js';
|
|
6
6
|
import { PluginManager } from '../core/plugins.js';
|
|
7
7
|
import { MCPClientManager } from '../core/mcp-client.js';
|
|
8
8
|
import { EnhancedUI } from '../ui/enhanced-tui.js';
|
|
9
|
-
import { ConfigManager } from '../utils/config.js';
|
|
9
|
+
import { ConfigManager, PROVIDER_CONFIGS } from '../utils/config.js';
|
|
10
10
|
import { NeuralMemory } from '../core/memory.js';
|
|
11
|
+
import { SessionMemory } from '../core/session-memory.js';
|
|
12
|
+
import { pruneContext } from '../core/context-pruner.js';
|
|
11
13
|
import { SkillManager } from '../core/skills.js';
|
|
12
14
|
import chalk from 'chalk';
|
|
15
|
+
import fetch from 'node-fetch';
|
|
16
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
13
17
|
const execAsync = promisify(exec);
|
|
14
18
|
// ── Git / GitHub helpers ─────────────────────────────────────────────────────
|
|
15
19
|
async function exec$(cmd, cwd) {
|
|
16
20
|
const { stdout } = await execAsync(cmd, { cwd, timeout: 60_000 });
|
|
17
21
|
return stdout.trim();
|
|
18
22
|
}
|
|
23
|
+
async function spawnCapture(cmd, args, cwd, timeoutMs = 120_000, allowedExitCodes = [0]) {
|
|
24
|
+
return await new Promise((resolve, reject) => {
|
|
25
|
+
const child = spawn(cmd, args, {
|
|
26
|
+
cwd,
|
|
27
|
+
env: { ...process.env },
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
child.kill('SIGTERM');
|
|
34
|
+
reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
child.stdout.setEncoding('utf8');
|
|
37
|
+
child.stderr.setEncoding('utf8');
|
|
38
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
39
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
40
|
+
child.on('error', (err) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
const combined = (stdout + stderr).trim();
|
|
47
|
+
if (code !== null && code !== undefined && !allowedExitCodes.includes(code)) {
|
|
48
|
+
reject(new Error(`${cmd} exited with code ${code}: ${combined}`));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
resolve(combined);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
19
56
|
async function assertGitRepo(cwd) {
|
|
20
57
|
try {
|
|
21
58
|
await exec$('git rev-parse --git-dir', cwd);
|
|
@@ -144,12 +181,21 @@ async function createBranchAndPR(opts) {
|
|
|
144
181
|
log(`Pushing branch ${branch} to origin...`);
|
|
145
182
|
await exec$(`git push -u origin "${branch}"`, cwd);
|
|
146
183
|
// Build gh pr create args
|
|
147
|
-
const escapedTitle = prTitle.replace(/"/g, '\\"');
|
|
148
|
-
const escapedBody = prBody.replace(/"/g, '\\"');
|
|
149
|
-
const draftFlag = draft ? '--draft' : '';
|
|
150
|
-
const ghCmd = `gh pr create --base "${baseBranch}" --head "${branch}" --title "${escapedTitle}" --body "${escapedBody}" ${draftFlag}`.trim();
|
|
151
184
|
log('Creating PR...');
|
|
152
|
-
const
|
|
185
|
+
const ghArgs = [
|
|
186
|
+
'pr',
|
|
187
|
+
'create',
|
|
188
|
+
'--base',
|
|
189
|
+
baseBranch,
|
|
190
|
+
'--head',
|
|
191
|
+
branch,
|
|
192
|
+
'--title',
|
|
193
|
+
prTitle,
|
|
194
|
+
'--body',
|
|
195
|
+
prBody,
|
|
196
|
+
...(draft ? ['--draft'] : []),
|
|
197
|
+
];
|
|
198
|
+
const prOutput = await spawnCapture('gh', ghArgs, cwd, 120_000);
|
|
153
199
|
// gh pr create prints the URL as its last line
|
|
154
200
|
const lines = prOutput.split('\n').filter(Boolean);
|
|
155
201
|
const prUrl = lines[lines.length - 1];
|
|
@@ -163,7 +209,7 @@ export async function runPrCommand(prompt, options) {
|
|
|
163
209
|
const ui = new EnhancedUI(options.verbose);
|
|
164
210
|
const config = new ConfigManager();
|
|
165
211
|
const cwd = process.cwd();
|
|
166
|
-
ui.header('0.
|
|
212
|
+
ui.header('0.7.3');
|
|
167
213
|
// ── Pre-flight checks ────────────────────────────────────────────────────
|
|
168
214
|
try {
|
|
169
215
|
await assertGitRepo(cwd);
|
|
@@ -204,12 +250,28 @@ export async function runPrCommand(prompt, options) {
|
|
|
204
250
|
process.exit(1);
|
|
205
251
|
}
|
|
206
252
|
// ── Config ───────────────────────────────────────────────────────────────
|
|
207
|
-
const
|
|
253
|
+
const costMode = (options.costMode || config.getCostMode());
|
|
254
|
+
const useEconomy = costMode === 'economy';
|
|
255
|
+
const model = options.model || config.getModel(useEconomy);
|
|
208
256
|
const baseUrl = options.baseUrl || config.getBaseUrl();
|
|
209
257
|
const provider = options.provider || config.get('provider');
|
|
210
|
-
|
|
211
|
-
|
|
258
|
+
let parsedIterations = parseInt(options.maxIterations);
|
|
259
|
+
if (parsedIterations <= 0)
|
|
260
|
+
parsedIterations = 150;
|
|
261
|
+
const maxIterations = useEconomy
|
|
262
|
+
? Math.min(parsedIterations, config.getEconomyMaxIterations())
|
|
263
|
+
: parsedIterations;
|
|
212
264
|
const testCommandOverride = config.get('testCommandOverride');
|
|
265
|
+
// Diagnostic — always print resolved config so misconfiguration is obvious
|
|
266
|
+
const maskedKey = apiKey
|
|
267
|
+
? apiKey.slice(0, 8) + '...' + apiKey.slice(-4)
|
|
268
|
+
: 'NOT SET';
|
|
269
|
+
console.log(chalk.dim(' cost mode ') + chalk.cyan(useEconomy ? 'economy' : 'normal'));
|
|
270
|
+
console.log(chalk.dim(' provider ') + chalk.cyan(provider ?? 'auto-detect'));
|
|
271
|
+
console.log(chalk.dim(' model ') + chalk.cyan(model));
|
|
272
|
+
console.log(chalk.dim(' base url ') + chalk.cyan(baseUrl ?? 'provider default'));
|
|
273
|
+
console.log(chalk.dim(' api key ') + chalk.cyan(maskedKey));
|
|
274
|
+
console.log('');
|
|
213
275
|
// ── Connect MCP servers ───────────────────────────────────────────────────
|
|
214
276
|
const mcpClientManager = new MCPClientManager();
|
|
215
277
|
const mcpServers = await config.getMCPServers();
|
|
@@ -253,6 +315,12 @@ export async function runPrCommand(prompt, options) {
|
|
|
253
315
|
memory,
|
|
254
316
|
skillManager,
|
|
255
317
|
});
|
|
318
|
+
const sessionMemory = new SessionMemory(cwd);
|
|
319
|
+
await sessionMemory.loadPreviousLearnings().catch(() => { });
|
|
320
|
+
const maxContextFiles = config.getMaxContextFiles();
|
|
321
|
+
const contextHintFiles = maxContextFiles > 0
|
|
322
|
+
? await pruneContext(cwd, finalPrompt, { maxFiles: maxContextFiles, usePkgStyleContext: config.getUsePkgStyleContext() }).catch(() => [])
|
|
323
|
+
: [];
|
|
256
324
|
const agent = new EnhancedAgent({
|
|
257
325
|
apiKey,
|
|
258
326
|
baseUrl,
|
|
@@ -262,6 +330,12 @@ export async function runPrCommand(prompt, options) {
|
|
|
262
330
|
mode: 'agent',
|
|
263
331
|
provider: provider,
|
|
264
332
|
customProviderFormat: config.get('customProviderFormat'),
|
|
333
|
+
planFirst: options.planFirst ?? false,
|
|
334
|
+
mindsetAdaptive: options.mindsetAdaptive ?? false,
|
|
335
|
+
sessionMemory,
|
|
336
|
+
contextHintFiles,
|
|
337
|
+
planningModel: config.getPlanningModel(),
|
|
338
|
+
executionModel: config.getExecutionModel(),
|
|
265
339
|
}, provider);
|
|
266
340
|
agent.memory = memory;
|
|
267
341
|
const startTime = Date.now();
|
|
@@ -310,71 +384,161 @@ export async function runPrCommand(prompt, options) {
|
|
|
310
384
|
break;
|
|
311
385
|
}
|
|
312
386
|
});
|
|
313
|
-
// ──
|
|
387
|
+
// ── Self-correction loop: run agent, then verify; on test failure retry up to 2 times ──
|
|
388
|
+
const maxSelfCorrectRetries = 2;
|
|
389
|
+
let attempt = 0;
|
|
390
|
+
let testPassed = false;
|
|
391
|
+
let lastTestError = '';
|
|
392
|
+
let verification = {
|
|
393
|
+
skipped: false,
|
|
394
|
+
testCommand: null,
|
|
395
|
+
passed: false,
|
|
396
|
+
durationMs: 0,
|
|
397
|
+
summary: '',
|
|
398
|
+
runs: 0,
|
|
399
|
+
};
|
|
400
|
+
let stats = { iterations: 0, filesChanged: 0, toolCalls: 0, changedFiles: [] };
|
|
401
|
+
let currentAgent = agent;
|
|
314
402
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
403
|
+
while (attempt <= maxSelfCorrectRetries) {
|
|
404
|
+
const isRetry = attempt > 0;
|
|
405
|
+
const prompt = isRetry
|
|
406
|
+
? `[Self-correction] The previous run's test suite failed. Fix the failures and ensure tests pass.\n\nTest output:\n${lastTestError.slice(0, 2000)}\n\nOriginal task: ${finalPrompt}`
|
|
407
|
+
: finalPrompt;
|
|
408
|
+
if (isRetry) {
|
|
409
|
+
sessionMemory.recordLearning(`Tests failed (attempt ${attempt}): ${lastTestError.slice(0, 200)}`);
|
|
410
|
+
const retryContextHintFiles = maxContextFiles > 0
|
|
411
|
+
? await pruneContext(cwd, 'fix failing tests ' + finalPrompt, { maxFiles: maxContextFiles, usePkgStyleContext: config.getUsePkgStyleContext() }).catch(() => [])
|
|
412
|
+
: [];
|
|
413
|
+
currentAgent = new EnhancedAgent({
|
|
414
|
+
apiKey,
|
|
415
|
+
baseUrl,
|
|
416
|
+
model,
|
|
417
|
+
maxIterations,
|
|
418
|
+
verbose: options.verbose,
|
|
419
|
+
mode: 'agent',
|
|
420
|
+
provider: provider,
|
|
421
|
+
customProviderFormat: config.get('customProviderFormat'),
|
|
422
|
+
planFirst: false,
|
|
423
|
+
mindsetAdaptive: options.mindsetAdaptive ?? false,
|
|
424
|
+
sessionMemory,
|
|
425
|
+
contextHintFiles: retryContextHintFiles,
|
|
426
|
+
planningModel: config.getPlanningModel(),
|
|
427
|
+
executionModel: config.getExecutionModel(),
|
|
428
|
+
}, provider);
|
|
429
|
+
currentAgent.memory = memory;
|
|
430
|
+
ui.warning(`Self-correction retry ${attempt}/${maxSelfCorrectRetries} — re-running agent with test failure context.`);
|
|
431
|
+
}
|
|
432
|
+
await currentAgent.run(prompt, toolExecutor.getTools(), toolExecutor);
|
|
433
|
+
await sessionMemory.persist();
|
|
434
|
+
stats = currentAgent.getStats();
|
|
435
|
+
const duration = Date.now() - startTime;
|
|
436
|
+
ui.completionSummary({
|
|
437
|
+
iterations: stats.iterations,
|
|
438
|
+
duration,
|
|
439
|
+
filesChanged: stats.filesChanged,
|
|
440
|
+
toolCalls: stats.toolCalls,
|
|
328
441
|
});
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
442
|
+
if (stats.changedFiles.length > 0) {
|
|
443
|
+
console.log(chalk.white(' 📝 Files modified:\n'));
|
|
444
|
+
stats.changedFiles.forEach((file) => {
|
|
445
|
+
console.log(chalk.gray(' • ') + chalk.white(file));
|
|
446
|
+
});
|
|
447
|
+
console.log('');
|
|
448
|
+
}
|
|
449
|
+
// ── Check for actual git changes ─────────────────────────────────────
|
|
450
|
+
const changedFiles = await getChangedFiles(cwd);
|
|
451
|
+
if (changedFiles.length === 0 && !isRetry) {
|
|
452
|
+
ui.warning('No git changes detected after the agent run. Skipping branch/PR creation.');
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
if (changedFiles.length === 0 && isRetry) {
|
|
456
|
+
ui.warning('No git changes on retry. Aborting.');
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
// ── Run tests / verification ─────────────────────────────────────────
|
|
460
|
+
if (options.skipTests) {
|
|
461
|
+
testPassed = true;
|
|
462
|
+
verification.skipped = true;
|
|
463
|
+
verification.passed = true;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
339
466
|
const testCmd = testCommandOverride || await detectTestCommand(cwd);
|
|
340
|
-
if (testCmd) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
467
|
+
if (!testCmd) {
|
|
468
|
+
ui.info('No test command detected, skipping verification.');
|
|
469
|
+
testPassed = true;
|
|
470
|
+
verification.skipped = true;
|
|
471
|
+
verification.passed = true;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
verification.testCommand = testCmd;
|
|
475
|
+
verification.runs++;
|
|
476
|
+
console.log(chalk.cyan(`\n Running verification: ${testCmd}\n`));
|
|
477
|
+
try {
|
|
478
|
+
const verificationStart = Date.now();
|
|
479
|
+
const { stdout: testOut, stderr: testErr } = await execAsync(testCmd, {
|
|
480
|
+
cwd,
|
|
481
|
+
timeout: 300_000,
|
|
482
|
+
});
|
|
483
|
+
const verificationDurationMs = Date.now() - verificationStart;
|
|
484
|
+
if (options.verbose) {
|
|
485
|
+
if (testOut)
|
|
486
|
+
console.log(chalk.dim(testOut));
|
|
487
|
+
if (testErr)
|
|
488
|
+
console.log(chalk.dim(testErr));
|
|
354
489
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
490
|
+
ui.info('Verification passed.');
|
|
491
|
+
testPassed = true;
|
|
492
|
+
verification.passed = true;
|
|
493
|
+
verification.durationMs = verificationDurationMs;
|
|
494
|
+
verification.summary = 'Tests passed.';
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
lastTestError = [err.stdout, err.stderr].filter(Boolean).join('\n') || err.message;
|
|
499
|
+
const errFirstLine = String(lastTestError).split('\n').find(Boolean) || lastTestError;
|
|
500
|
+
verification.passed = false;
|
|
501
|
+
verification.summary = `Tests failed: ${errFirstLine.slice(0, 240)}`;
|
|
502
|
+
if (options.verbose && err.stdout)
|
|
503
|
+
console.log(chalk.dim(err.stdout));
|
|
504
|
+
if (options.verbose && err.stderr)
|
|
505
|
+
console.log(chalk.dim(err.stderr));
|
|
506
|
+
attempt++;
|
|
507
|
+
if (attempt > maxSelfCorrectRetries) {
|
|
508
|
+
ui.error(`Verification failed after ${maxSelfCorrectRetries} retry(ies). Aborting PR creation.\n ${err.message}`);
|
|
361
509
|
process.exit(1);
|
|
362
510
|
}
|
|
363
|
-
|
|
364
|
-
else {
|
|
365
|
-
ui.info('No test command detected, skipping verification.');
|
|
511
|
+
ui.warning(`Verification failed. Starting self-correction retry ${attempt}/${maxSelfCorrectRetries}...`);
|
|
366
512
|
}
|
|
367
513
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
// ── Detect base branch ───────────────────────────────────────────────
|
|
514
|
+
const changedFiles = await getChangedFiles(cwd);
|
|
515
|
+
const duration = Date.now() - startTime;
|
|
372
516
|
const baseBranch = await detectDefaultBase(cwd);
|
|
373
517
|
ui.info(`Base branch: ${baseBranch}`);
|
|
518
|
+
// Diff + per-file rationale (optional in economy mode)
|
|
519
|
+
const diffByFile = await getDiffForChangedFiles(cwd, baseBranch, changedFiles, useEconomy).catch(() => ({}));
|
|
520
|
+
const changesRationaleMarkdown = await explainDiffsWithModel({
|
|
521
|
+
prompt: finalPrompt,
|
|
522
|
+
diffByFile,
|
|
523
|
+
fileOrder: changedFiles,
|
|
524
|
+
cwd,
|
|
525
|
+
apiKey,
|
|
526
|
+
baseUrl,
|
|
527
|
+
provider: provider,
|
|
528
|
+
model,
|
|
529
|
+
useEconomy,
|
|
530
|
+
baseBranch,
|
|
531
|
+
}).catch(() => '');
|
|
374
532
|
// ── Build branch, commit, PR metadata ────────────────────────────────
|
|
375
533
|
const branch = buildBranchName(finalPrompt, options.branch);
|
|
376
534
|
const prTitle = options.title || buildPrTitle(finalPrompt, stats);
|
|
377
|
-
const
|
|
535
|
+
const selfCorrectionRetriesUsed = options.skipTests ? 0 : attempt;
|
|
536
|
+
const prBody = buildPrBody(finalPrompt, stats, changedFiles, duration, {
|
|
537
|
+
verification,
|
|
538
|
+
selfCorrectionRetriesUsed,
|
|
539
|
+
maxSelfCorrectRetries,
|
|
540
|
+
changesRationaleMarkdown,
|
|
541
|
+
});
|
|
378
542
|
const commitMessage = prTitle;
|
|
379
543
|
console.log('');
|
|
380
544
|
ui.info(`Creating branch: ${chalk.cyan(branch)}`);
|
|
@@ -447,29 +611,266 @@ function buildPrTitle(prompt, stats) {
|
|
|
447
611
|
return shortened;
|
|
448
612
|
return shortened.slice(0, 67) + '...';
|
|
449
613
|
}
|
|
450
|
-
function buildPrBody(prompt, stats, changedFiles, durationMs) {
|
|
614
|
+
function buildPrBody(prompt, stats, changedFiles, durationMs, opts) {
|
|
451
615
|
const seconds = (durationMs / 1000).toFixed(1);
|
|
452
616
|
const fileList = changedFiles.map(f => `- \`${f}\``).join('\n');
|
|
617
|
+
const inputTokens = stats?.inputTokens;
|
|
618
|
+
const outputTokens = stats?.outputTokens;
|
|
619
|
+
const totalTokens = stats?.totalTokens;
|
|
620
|
+
const costLabel = stats?.costLabel;
|
|
621
|
+
const verificationLines = [
|
|
622
|
+
`- Skipped: ${opts.verification.skipped ? 'yes' : 'no'}`,
|
|
623
|
+
`- Test command: \`${opts.verification.testCommand ?? 'none'}\``,
|
|
624
|
+
`- Result: ${opts.verification.passed ? '✅ Passed' : '❌ Failed'}`,
|
|
625
|
+
`- Duration: ${opts.verification.durationMs ? `${(opts.verification.durationMs / 1000).toFixed(1)}s` : 'n/a'}`,
|
|
626
|
+
`- Runs: ${opts.verification.runs}`,
|
|
627
|
+
];
|
|
628
|
+
const selfCorrectionLines = [
|
|
629
|
+
`- Retries used: ${opts.selfCorrectionRetriesUsed}/${opts.maxSelfCorrectRetries}`,
|
|
630
|
+
opts.verification.summary ? `- Last verification summary: ${opts.verification.summary}` : '',
|
|
631
|
+
].filter(Boolean);
|
|
632
|
+
const rationale = opts.changesRationaleMarkdown?.trim()
|
|
633
|
+
? opts.changesRationaleMarkdown.trim()
|
|
634
|
+
: `- ${changedFiles.length ? 'Updated files:' : 'No files changed.'}\n${fileList || ''}`;
|
|
453
635
|
return [
|
|
454
636
|
'## Summary',
|
|
455
637
|
'',
|
|
456
638
|
`> Task: ${prompt.trim()}`,
|
|
457
639
|
'',
|
|
458
|
-
'## Changes',
|
|
640
|
+
'## Changes (rationale)',
|
|
459
641
|
'',
|
|
460
|
-
|
|
642
|
+
rationale,
|
|
461
643
|
'',
|
|
462
644
|
'## Run stats',
|
|
463
645
|
'',
|
|
464
646
|
`| Metric | Value |`,
|
|
465
647
|
`|--------|-------|`,
|
|
466
|
-
`| Iterations | ${stats
|
|
467
|
-
`| Tool calls | ${stats
|
|
468
|
-
`| Files changed | ${stats
|
|
648
|
+
`| Iterations | ${stats?.iterations ?? 0} |`,
|
|
649
|
+
`| Tool calls | ${stats?.toolCalls ?? 0} |`,
|
|
650
|
+
`| Files changed | ${stats?.filesChanged ?? stats?.changedFiles?.length ?? 0} |`,
|
|
469
651
|
`| Duration | ${seconds}s |`,
|
|
652
|
+
`| Input tokens | ${inputTokens ?? 'n/a'} |`,
|
|
653
|
+
`| Output tokens | ${outputTokens ?? 'n/a'} |`,
|
|
654
|
+
`| Total tokens | ${totalTokens ?? 'n/a'} |`,
|
|
655
|
+
`| Cost | ${costLabel ?? 'n/a'} |`,
|
|
656
|
+
'',
|
|
657
|
+
'## Verification',
|
|
658
|
+
'',
|
|
659
|
+
...verificationLines,
|
|
660
|
+
'',
|
|
661
|
+
'## Self-correction',
|
|
662
|
+
'',
|
|
663
|
+
...selfCorrectionLines,
|
|
470
664
|
'',
|
|
471
665
|
'---',
|
|
472
666
|
'_Generated automatically by [XibeCode](https://github.com/iotserver24/xibecode) `run-pr`_',
|
|
473
667
|
].join('\n');
|
|
474
668
|
}
|
|
669
|
+
function redactSecrets(input) {
|
|
670
|
+
if (!input)
|
|
671
|
+
return input;
|
|
672
|
+
// Mask API keys/tokens; this is best-effort and intended to prevent obvious leakage.
|
|
673
|
+
return input
|
|
674
|
+
// Anthropic/OpenAI/others style keys
|
|
675
|
+
.replace(/\b(sk-[A-Za-z0-9]{8,})\b/g, '[REDACTED_API_KEY]')
|
|
676
|
+
.replace(/\b(AAIza|AIza)\w{10,}\b/g, '[REDACTED_GOOGLE_KEY]')
|
|
677
|
+
// Common env var formats
|
|
678
|
+
.replace(/\b(OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|GH_TOKEN|XIBECODE_API_KEY)\s*=\s*['"]?[^'"\n\r]+['"]?/gi, '$1=[REDACTED]')
|
|
679
|
+
// Bearer tokens
|
|
680
|
+
.replace(/\bBearer\s+[A-Za-z0-9\-_\.]{20,}\b/g, 'Bearer [REDACTED]')
|
|
681
|
+
// Private key blocks
|
|
682
|
+
.replace(/-----BEGIN [A-Z0-9 _-]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 _-]*PRIVATE KEY-----/g, '[REDACTED_PEM]')
|
|
683
|
+
// Long hex / hashes
|
|
684
|
+
.replace(/\b[0-9a-f]{32,}\b/gi, '[REDACTED_HEX]');
|
|
685
|
+
}
|
|
686
|
+
async function getDiffForChangedFiles(cwd, baseBranch, changedFiles, useEconomy) {
|
|
687
|
+
const perFileMaxChars = useEconomy ? 1800 : 3500;
|
|
688
|
+
const totalMaxChars = useEconomy ? 18_000 : 45_000;
|
|
689
|
+
const diffFiles = changedFiles.slice(0, 200);
|
|
690
|
+
if (diffFiles.length === 0)
|
|
691
|
+
return {};
|
|
692
|
+
const args = [
|
|
693
|
+
'diff',
|
|
694
|
+
'-U3',
|
|
695
|
+
'--no-color',
|
|
696
|
+
`origin/${baseBranch}...HEAD`,
|
|
697
|
+
'--',
|
|
698
|
+
...diffFiles,
|
|
699
|
+
];
|
|
700
|
+
const diffText = await spawnCapture('git', args, cwd, 60_000, [0, 1]).catch(() => '');
|
|
701
|
+
if (!diffText) {
|
|
702
|
+
return Object.fromEntries(diffFiles.map((f) => [f, '']));
|
|
703
|
+
}
|
|
704
|
+
const diffByFile = {};
|
|
705
|
+
const parts = diffText.split(/^diff --git /m);
|
|
706
|
+
for (const part of parts) {
|
|
707
|
+
// Skip preamble (before first "diff --git")
|
|
708
|
+
if (!part.trim())
|
|
709
|
+
continue;
|
|
710
|
+
const headerMatch = part.match(/^a\/(.+?) b\/(.+?)\n/);
|
|
711
|
+
if (!headerMatch)
|
|
712
|
+
continue;
|
|
713
|
+
const filePath = headerMatch[2];
|
|
714
|
+
const fullBlock = `diff --git ${part}`.trim();
|
|
715
|
+
const excerpt = fullBlock.slice(0, perFileMaxChars);
|
|
716
|
+
diffByFile[filePath] = excerpt;
|
|
717
|
+
}
|
|
718
|
+
// Ensure all changed files exist in the map (use empty string when no diff block found).
|
|
719
|
+
const ordered = {};
|
|
720
|
+
let totalChars = 0;
|
|
721
|
+
for (const f of diffFiles) {
|
|
722
|
+
const excerpt = diffByFile[f] ?? '';
|
|
723
|
+
if (excerpt && totalChars < totalMaxChars) {
|
|
724
|
+
const remaining = totalMaxChars - totalChars;
|
|
725
|
+
const clipped = excerpt.slice(0, remaining);
|
|
726
|
+
ordered[f] = clipped;
|
|
727
|
+
totalChars += clipped.length;
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
ordered[f] = '';
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return ordered;
|
|
734
|
+
}
|
|
735
|
+
async function getNumstatForChangedFiles(cwd, baseBranch, changedFiles, useEconomy) {
|
|
736
|
+
const diffFiles = changedFiles.slice(0, useEconomy ? 120 : 260);
|
|
737
|
+
if (diffFiles.length === 0)
|
|
738
|
+
return {};
|
|
739
|
+
const args = [
|
|
740
|
+
'diff',
|
|
741
|
+
'--numstat',
|
|
742
|
+
`origin/${baseBranch}...HEAD`,
|
|
743
|
+
'--',
|
|
744
|
+
...diffFiles,
|
|
745
|
+
];
|
|
746
|
+
const out = await spawnCapture('git', args, cwd, 60_000, [0, 1]).catch(() => '');
|
|
747
|
+
const result = {};
|
|
748
|
+
for (const line of out.split('\n').filter(Boolean)) {
|
|
749
|
+
const cols = line.split('\t');
|
|
750
|
+
if (cols.length < 3)
|
|
751
|
+
continue;
|
|
752
|
+
const insRaw = cols[0];
|
|
753
|
+
const delRaw = cols[1];
|
|
754
|
+
const file = cols.slice(2).join('\t').trim();
|
|
755
|
+
const ins = insRaw === '-' ? null : Number(insRaw);
|
|
756
|
+
const del = delRaw === '-' ? null : Number(delRaw);
|
|
757
|
+
if (file)
|
|
758
|
+
result[file] = { ins, del };
|
|
759
|
+
}
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
async function explainDiffsWithModel(opts) {
|
|
763
|
+
const { prompt, diffByFile, fileOrder, cwd, apiKey, baseUrl, provider, model, useEconomy, baseBranch, } = opts;
|
|
764
|
+
const providerKey = provider;
|
|
765
|
+
const format = PROVIDER_CONFIGS[providerKey]?.format ?? 'openai';
|
|
766
|
+
if (!baseUrl)
|
|
767
|
+
throw new Error('Missing baseUrl for LLM explainer.');
|
|
768
|
+
const maxFilesForLLM = useEconomy ? 6 : 10;
|
|
769
|
+
const maxTokens = useEconomy ? 650 : 1150;
|
|
770
|
+
const maxFilesForBudgetFallback = maxFilesForLLM;
|
|
771
|
+
const entries = fileOrder
|
|
772
|
+
.map((f) => ({ file: f, diff: diffByFile[f] ?? '' }))
|
|
773
|
+
.filter((e) => e.diff && e.diff.trim().length > 0);
|
|
774
|
+
if (entries.length === 0)
|
|
775
|
+
return '';
|
|
776
|
+
// If too many files, fall back to numstat counts to keep PR generation cheap.
|
|
777
|
+
if (entries.length > maxFilesForBudgetFallback) {
|
|
778
|
+
const numstats = await getNumstatForChangedFiles(cwd, baseBranch, fileOrder, useEconomy).catch(() => ({}));
|
|
779
|
+
const limited = fileOrder.slice(0, useEconomy ? 120 : 260);
|
|
780
|
+
const lines = limited.map((f) => {
|
|
781
|
+
const s = numstats[f];
|
|
782
|
+
if (!s)
|
|
783
|
+
return `- \`${f}\``;
|
|
784
|
+
const ins = s.ins === null ? 'n/a' : s.ins;
|
|
785
|
+
const del = s.del === null ? 'n/a' : s.del;
|
|
786
|
+
return `- \`${f}\`: +${ins} -${del}`;
|
|
787
|
+
});
|
|
788
|
+
return [
|
|
789
|
+
`> Rationale omitted (too many files to summarize in ${useEconomy ? 'economy' : 'normal'} mode).`,
|
|
790
|
+
...lines,
|
|
791
|
+
].join('\n');
|
|
792
|
+
}
|
|
793
|
+
// Build prompt budget by total excerpt size.
|
|
794
|
+
const totalExcerptChars = entries.reduce((acc, e) => acc + e.diff.length, 0);
|
|
795
|
+
const maxExcerptChars = useEconomy ? 18_000 : 40_000;
|
|
796
|
+
if (totalExcerptChars > maxExcerptChars) {
|
|
797
|
+
const numstats = await getNumstatForChangedFiles(cwd, baseBranch, fileOrder, useEconomy).catch(() => ({}));
|
|
798
|
+
const limited = fileOrder.slice(0, useEconomy ? 120 : 260);
|
|
799
|
+
const lines = limited.map((f) => {
|
|
800
|
+
const s = numstats[f];
|
|
801
|
+
if (!s)
|
|
802
|
+
return `- \`${f}\``;
|
|
803
|
+
const ins = s.ins === null ? 'n/a' : s.ins;
|
|
804
|
+
const del = s.del === null ? 'n/a' : s.del;
|
|
805
|
+
return `- \`${f}\`: +${ins} -${del}`;
|
|
806
|
+
});
|
|
807
|
+
return [
|
|
808
|
+
`> Rationale omitted (diff budget exceeded in ${useEconomy ? 'economy' : 'normal'} mode).`,
|
|
809
|
+
...lines,
|
|
810
|
+
].join('\n');
|
|
811
|
+
}
|
|
812
|
+
const redactedFilesText = entries
|
|
813
|
+
.slice(0, maxFilesForLLM)
|
|
814
|
+
.map((e) => {
|
|
815
|
+
const redacted = redactSecrets(e.diff);
|
|
816
|
+
return `File: \`${e.file}\`\n\`\`\`\n${redacted}\n\`\`\``;
|
|
817
|
+
})
|
|
818
|
+
.join('\n\n');
|
|
819
|
+
const systemPrompt = 'You write PR descriptions. Produce concise per-file rationale based strictly on the diff excerpts. Avoid speculation. Do not include secrets.';
|
|
820
|
+
const userPrompt = [
|
|
821
|
+
`Task: ${prompt.trim()}`,
|
|
822
|
+
'',
|
|
823
|
+
'Diff excerpts (redacted):',
|
|
824
|
+
redactedFilesText,
|
|
825
|
+
'',
|
|
826
|
+
'Write markdown with this format:',
|
|
827
|
+
'- For each file: start with a heading `### path/to/file`',
|
|
828
|
+
'- Under it: 2-4 bullets:',
|
|
829
|
+
' - What changed (grounded in the excerpt)',
|
|
830
|
+
' - Why it was needed for the task',
|
|
831
|
+
' - Any risk/verification notes if evident',
|
|
832
|
+
].join('\n');
|
|
833
|
+
if (format === 'anthropic') {
|
|
834
|
+
const client = new Anthropic({ apiKey, baseURL: baseUrl });
|
|
835
|
+
const message = await client.messages.create({
|
|
836
|
+
model,
|
|
837
|
+
max_tokens: maxTokens,
|
|
838
|
+
temperature: 0.2,
|
|
839
|
+
system: systemPrompt,
|
|
840
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
841
|
+
});
|
|
842
|
+
const content = message.content;
|
|
843
|
+
const text = Array.isArray(content)
|
|
844
|
+
? content.map((b) => (b?.type === 'text' ? b.text : '')).join('')
|
|
845
|
+
: String(message?.content ?? '');
|
|
846
|
+
return text.trim();
|
|
847
|
+
}
|
|
848
|
+
// OpenAI-compatible path
|
|
849
|
+
const endpoint = baseUrl.endsWith('/v1')
|
|
850
|
+
? `${baseUrl}/chat/completions`
|
|
851
|
+
: `${baseUrl}/chat/completions`;
|
|
852
|
+
const resp = await fetch(endpoint, {
|
|
853
|
+
method: 'POST',
|
|
854
|
+
headers: {
|
|
855
|
+
'Content-Type': 'application/json',
|
|
856
|
+
Authorization: `Bearer ${apiKey}`,
|
|
857
|
+
},
|
|
858
|
+
body: JSON.stringify({
|
|
859
|
+
model,
|
|
860
|
+
messages: [
|
|
861
|
+
{ role: 'system', content: systemPrompt },
|
|
862
|
+
{ role: 'user', content: userPrompt },
|
|
863
|
+
],
|
|
864
|
+
max_tokens: maxTokens,
|
|
865
|
+
temperature: 0.2,
|
|
866
|
+
}),
|
|
867
|
+
});
|
|
868
|
+
if (!resp.ok) {
|
|
869
|
+
const t = await resp.text().catch(() => '');
|
|
870
|
+
throw new Error(`LLM explainer failed: HTTP ${resp.status} ${resp.statusText}. ${t}`.trim());
|
|
871
|
+
}
|
|
872
|
+
const data = await resp.json();
|
|
873
|
+
const text = data?.choices?.[0]?.message?.content ?? '';
|
|
874
|
+
return String(text).trim();
|
|
875
|
+
}
|
|
475
876
|
//# sourceMappingURL=run-pr.js.map
|