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.
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 +470 -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 +36 -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.2');
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 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');
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
- // ── 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;
314
402
  try {
315
- await agent.run(finalPrompt, toolExecutor.getTools(), toolExecutor);
316
- const stats = agent.getStats();
317
- const duration = Date.now() - startTime;
318
- ui.completionSummary({
319
- iterations: stats.iterations,
320
- duration,
321
- filesChanged: stats.filesChanged,
322
- toolCalls: stats.toolCalls,
323
- });
324
- if (stats.changedFiles.length > 0) {
325
- console.log(chalk.white(' 📝 Files modified:\n'));
326
- stats.changedFiles.forEach(file => {
327
- 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,
328
441
  });
329
- console.log('');
330
- }
331
- // ── Check for actual git changes ─────────────────────────────────────
332
- const changedFiles = await getChangedFiles(cwd);
333
- if (changedFiles.length === 0) {
334
- ui.warning('No git changes detected after the agent run. Skipping branch/PR creation.');
335
- process.exit(0);
336
- }
337
- // ── Run tests / verification ─────────────────────────────────────────
338
- 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
+ }
339
466
  const testCmd = testCommandOverride || await detectTestCommand(cwd);
340
- if (testCmd) {
341
- console.log(chalk.cyan(`\n Running verification: ${testCmd}\n`));
342
- try {
343
- const { stdout: testOut, stderr: testErr } = await execAsync(testCmd, {
344
- cwd,
345
- timeout: 300_000,
346
- });
347
- if (options.verbose) {
348
- if (testOut)
349
- console.log(chalk.dim(testOut));
350
- if (testErr)
351
- console.log(chalk.dim(testErr));
352
- }
353
- 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));
354
489
  }
355
- catch (err) {
356
- ui.error(`Verification failed — tests did not pass. Aborting PR creation.\n ${err.message}`);
357
- if (options.verbose && err.stdout)
358
- console.log(chalk.dim(err.stdout));
359
- if (options.verbose && err.stderr)
360
- 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}`);
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
- else {
369
- ui.info('Test verification skipped (--skip-tests).');
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 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
+ });
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
- fileList || '_No files detected_',
642
+ rationale,
461
643
  '',
462
644
  '## Run stats',
463
645
  '',
464
646
  `| Metric | Value |`,
465
647
  `|--------|-------|`,
466
- `| Iterations | ${stats.iterations} |`,
467
- `| Tool calls | ${stats.toolCalls} |`,
468
- `| Files changed | ${stats.filesChanged} |`,
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