xibecode 0.6.3 → 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.
Files changed (47) hide show
  1. package/README.md +11 -2
  2. package/dist/commands/chat.d.ts +1 -0
  3. package/dist/commands/chat.d.ts.map +1 -1
  4. package/dist/commands/chat.js +2 -1
  5. package/dist/commands/chat.js.map +1 -1
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.d.ts.map +1 -1
  8. package/dist/commands/config.js +15 -0
  9. package/dist/commands/config.js.map +1 -1
  10. package/dist/commands/run-pr.d.ts +3 -0
  11. package/dist/commands/run-pr.d.ts.map +1 -1
  12. package/dist/commands/run-pr.js +461 -69
  13. package/dist/commands/run-pr.js.map +1 -1
  14. package/dist/commands/run.d.ts +3 -0
  15. package/dist/commands/run.d.ts.map +1 -1
  16. package/dist/commands/run.js +27 -5
  17. package/dist/commands/run.js.map +1 -1
  18. package/dist/core/agent.d.ts +27 -0
  19. package/dist/core/agent.d.ts.map +1 -1
  20. package/dist/core/agent.js +90 -3
  21. package/dist/core/agent.js.map +1 -1
  22. package/dist/core/context-pruner.d.ts +19 -0
  23. package/dist/core/context-pruner.d.ts.map +1 -0
  24. package/dist/core/context-pruner.js +103 -0
  25. package/dist/core/context-pruner.js.map +1 -0
  26. package/dist/core/modes.d.ts.map +1 -1
  27. package/dist/core/modes.js +1 -0
  28. package/dist/core/modes.js.map +1 -1
  29. package/dist/core/session-memory.d.ts +45 -0
  30. package/dist/core/session-memory.d.ts.map +1 -0
  31. package/dist/core/session-memory.js +103 -0
  32. package/dist/core/session-memory.js.map +1 -0
  33. package/dist/core/tools.d.ts +3 -0
  34. package/dist/core/tools.d.ts.map +1 -1
  35. package/dist/core/tools.js +415 -357
  36. package/dist/core/tools.js.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/utils/config.d.ts +26 -2
  40. package/dist/utils/config.d.ts.map +1 -1
  41. package/dist/utils/config.js +43 -2
  42. package/dist/utils/config.js.map +1 -1
  43. package/dist/utils/safety.d.ts +21 -0
  44. package/dist/utils/safety.d.ts.map +1 -1
  45. package/dist/utils/safety.js +36 -0
  46. package/dist/utils/safety.js.map +1 -1
  47. package/package.json +2 -2
@@ -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 prOutput = await exec$(ghCmd, cwd);
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.6.3');
212
+ ui.header('0.7.3');
167
213
  // ── Pre-flight checks ────────────────────────────────────────────────────
168
214
  try {
169
215
  await assertGitRepo(cwd);
@@ -204,16 +250,23 @@ export async function runPrCommand(prompt, options) {
204
250
  process.exit(1);
205
251
  }
206
252
  // ── Config ───────────────────────────────────────────────────────────────
207
- const model = options.model || config.getModel();
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
- const parsedIterations = parseInt(options.maxIterations);
211
- const maxIterations = parsedIterations > 0 ? parsedIterations : 150;
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');
213
265
  // Diagnostic — always print resolved config so misconfiguration is obvious
214
266
  const maskedKey = apiKey
215
267
  ? apiKey.slice(0, 8) + '...' + apiKey.slice(-4)
216
268
  : 'NOT SET';
269
+ console.log(chalk.dim(' cost mode ') + chalk.cyan(useEconomy ? 'economy' : 'normal'));
217
270
  console.log(chalk.dim(' provider ') + chalk.cyan(provider ?? 'auto-detect'));
218
271
  console.log(chalk.dim(' model ') + chalk.cyan(model));
219
272
  console.log(chalk.dim(' base url ') + chalk.cyan(baseUrl ?? 'provider default'));
@@ -262,6 +315,12 @@ export async function runPrCommand(prompt, options) {
262
315
  memory,
263
316
  skillManager,
264
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
+ : [];
265
324
  const agent = new EnhancedAgent({
266
325
  apiKey,
267
326
  baseUrl,
@@ -271,6 +330,12 @@ export async function runPrCommand(prompt, options) {
271
330
  mode: 'agent',
272
331
  provider: provider,
273
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(),
274
339
  }, provider);
275
340
  agent.memory = memory;
276
341
  const startTime = Date.now();
@@ -319,71 +384,161 @@ export async function runPrCommand(prompt, options) {
319
384
  break;
320
385
  }
321
386
  });
322
- // ── Run the agent ─────────────────────────────────────────────────────────
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;
323
402
  try {
324
- await agent.run(finalPrompt, toolExecutor.getTools(), toolExecutor);
325
- const stats = agent.getStats();
326
- const duration = Date.now() - startTime;
327
- ui.completionSummary({
328
- iterations: stats.iterations,
329
- duration,
330
- filesChanged: stats.filesChanged,
331
- toolCalls: stats.toolCalls,
332
- });
333
- if (stats.changedFiles.length > 0) {
334
- console.log(chalk.white(' 📝 Files modified:\n'));
335
- stats.changedFiles.forEach(file => {
336
- console.log(chalk.gray(' • ') + chalk.white(file));
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,
337
441
  });
338
- console.log('');
339
- }
340
- // ── Check for actual git changes ─────────────────────────────────────
341
- const changedFiles = await getChangedFiles(cwd);
342
- if (changedFiles.length === 0) {
343
- ui.warning('No git changes detected after the agent run. Skipping branch/PR creation.');
344
- process.exit(0);
345
- }
346
- // ── Run tests / verification ─────────────────────────────────────────
347
- if (!options.skipTests) {
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
+ }
348
466
  const testCmd = testCommandOverride || await detectTestCommand(cwd);
349
- if (testCmd) {
350
- console.log(chalk.cyan(`\n Running verification: ${testCmd}\n`));
351
- try {
352
- const { stdout: testOut, stderr: testErr } = await execAsync(testCmd, {
353
- cwd,
354
- timeout: 300_000,
355
- });
356
- if (options.verbose) {
357
- if (testOut)
358
- console.log(chalk.dim(testOut));
359
- if (testErr)
360
- console.log(chalk.dim(testErr));
361
- }
362
- ui.info('Verification passed.');
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));
363
489
  }
364
- catch (err) {
365
- ui.error(`Verification failed — tests did not pass. Aborting PR creation.\n ${err.message}`);
366
- if (options.verbose && err.stdout)
367
- console.log(chalk.dim(err.stdout));
368
- if (options.verbose && err.stderr)
369
- console.log(chalk.dim(err.stderr));
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}`);
370
509
  process.exit(1);
371
510
  }
372
- }
373
- else {
374
- ui.info('No test command detected, skipping verification.');
511
+ ui.warning(`Verification failed. Starting self-correction retry ${attempt}/${maxSelfCorrectRetries}...`);
375
512
  }
376
513
  }
377
- else {
378
- ui.info('Test verification skipped (--skip-tests).');
379
- }
380
- // ── Detect base branch ───────────────────────────────────────────────
514
+ const changedFiles = await getChangedFiles(cwd);
515
+ const duration = Date.now() - startTime;
381
516
  const baseBranch = await detectDefaultBase(cwd);
382
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(() => '');
383
532
  // ── Build branch, commit, PR metadata ────────────────────────────────
384
533
  const branch = buildBranchName(finalPrompt, options.branch);
385
534
  const prTitle = options.title || buildPrTitle(finalPrompt, stats);
386
- const prBody = buildPrBody(finalPrompt, stats, changedFiles, duration);
535
+ const selfCorrectionRetriesUsed = options.skipTests ? 0 : attempt;
536
+ const prBody = buildPrBody(finalPrompt, stats, changedFiles, duration, {
537
+ verification,
538
+ selfCorrectionRetriesUsed,
539
+ maxSelfCorrectRetries,
540
+ changesRationaleMarkdown,
541
+ });
387
542
  const commitMessage = prTitle;
388
543
  console.log('');
389
544
  ui.info(`Creating branch: ${chalk.cyan(branch)}`);
@@ -456,29 +611,266 @@ function buildPrTitle(prompt, stats) {
456
611
  return shortened;
457
612
  return shortened.slice(0, 67) + '...';
458
613
  }
459
- function buildPrBody(prompt, stats, changedFiles, durationMs) {
614
+ function buildPrBody(prompt, stats, changedFiles, durationMs, opts) {
460
615
  const seconds = (durationMs / 1000).toFixed(1);
461
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 || ''}`;
462
635
  return [
463
636
  '## Summary',
464
637
  '',
465
638
  `> Task: ${prompt.trim()}`,
466
639
  '',
467
- '## Changes',
640
+ '## Changes (rationale)',
468
641
  '',
469
- fileList || '_No files detected_',
642
+ rationale,
470
643
  '',
471
644
  '## Run stats',
472
645
  '',
473
646
  `| Metric | Value |`,
474
647
  `|--------|-------|`,
475
- `| Iterations | ${stats.iterations} |`,
476
- `| Tool calls | ${stats.toolCalls} |`,
477
- `| Files changed | ${stats.filesChanged} |`,
648
+ `| Iterations | ${stats?.iterations ?? 0} |`,
649
+ `| Tool calls | ${stats?.toolCalls ?? 0} |`,
650
+ `| Files changed | ${stats?.filesChanged ?? stats?.changedFiles?.length ?? 0} |`,
478
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,
479
664
  '',
480
665
  '---',
481
666
  '_Generated automatically by [XibeCode](https://github.com/iotserver24/xibecode) `run-pr`_',
482
667
  ].join('\n');
483
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
+ }
484
876
  //# sourceMappingURL=run-pr.js.map