ronds_ai 0.1.13 → 0.1.17

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.
@@ -1,25 +1,30 @@
1
- const fs = require('fs');
1
+ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const http = require('http');
5
5
  const https = require('https');
6
- const { execFileSync } = require('child_process');
7
6
  const { runGit } = require('./git');
8
- const { createHash, randomUUID } = require('crypto');
7
+ const { randomUUID } = require('crypto');
9
8
 
10
9
  const DEFAULT_TIMEOUT_MS = 10000;
11
10
  const MAX_ADDED_CONTENT_CHARS = 20000;
12
11
  const DEFAULT_HOOK_REPORT_URL = 'https://aihub.ronds.com/api/api/v1/ai-code-events';
13
12
  const CLAUDE_SUPPORTED_HOOK_EVENT = 'PostToolUse';
14
13
  const CLAUDE_SUPPORTED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
15
- const CURSOR_SUPPORTED_HOOK_EVENT = 'afterFileEdit';
16
- const CODEX_SUPPORTED_HOOK_EVENTS = new Set(['UserPromptSubmit', 'Stop']);
17
- const CODEX_SNAPSHOT_DIR = path.join(os.homedir(), '.ronds_ai', 'codex-snapshots');
18
- const CODEX_SNAPSHOT_TTL_SEC = 86400;
19
- const GIT_STATUS_ARGS = ['status', '--porcelain=v1', '--untracked-files=all'];
20
- const CODEX_STOP_OUTPUT = {
21
- continue: false,
22
- };
14
+ const CURSOR_SUPPORTED_HOOK_EVENT = 'afterFileEdit';
15
+ const CODEX_SUPPORTED_HOOK_EVENT = 'PostToolUse';
16
+ const CODEX_SUPPORTED_TOOL = 'apply_patch';
17
+
18
+ // 构造 Codex PostToolUse hook 要求的标准输出,避免 stdout 出现 schema 之外的字段。
19
+ function buildCodexPostToolUseOutput() {
20
+ return {
21
+ continue: true,
22
+ suppressOutput: true,
23
+ hookSpecificOutput: {
24
+ hookEventName: CODEX_SUPPORTED_HOOK_EVENT,
25
+ },
26
+ };
27
+ }
23
28
 
24
29
  function readStdin() {
25
30
  return new Promise((resolve, reject) => {
@@ -38,7 +43,9 @@ function parseJson(raw, label) {
38
43
  return JSON.parse(raw);
39
44
  } catch (error) {
40
45
  const detail = error instanceof Error ? error.message : String(error);
41
- throw new Error(`${label} is not valid JSON: ${detail}`);
46
+ const wrappedError = new Error(`${label} is not valid JSON: ${detail}`);
47
+ wrappedError.rawPayload = raw;
48
+ throw wrappedError;
42
49
  }
43
50
  }
44
51
 
@@ -426,303 +433,134 @@ function buildCursorEvent(payload, source) {
426
433
  };
427
434
  }
428
435
 
429
- function getCodexSnapshotFilePath(payload, repoRoot) {
430
- const sessionId = String(payload.session_id || payload.sessionId || '').trim();
431
- const turnId = String(payload.turn_id || payload.turnId || '').trim();
432
- if (!sessionId || !turnId || !repoRoot) {
433
- return '';
434
- }
435
-
436
- const key = `${repoRoot}::${sessionId}::${turnId}`;
437
- const fileName = `${createHash('sha1').update(key).digest('hex')}.json`;
438
- return path.join(CODEX_SNAPSHOT_DIR, fileName);
439
- }
440
-
441
- function parseGitStatus(repoRoot) {
442
- const output = runGit(repoRoot, GIT_STATUS_ARGS, false);
443
- const entries = [];
444
-
445
- for (const rawLine of output.split('\n')) {
446
- if (!rawLine) {
447
- continue;
448
- }
449
-
450
- const line = rawLine.replace(/\r$/, '');
451
- const statusCode = line.slice(0, 2);
452
- const body = line.slice(3);
453
- if (!body) {
454
- continue;
455
- }
456
-
457
- const normalizedBody = body.includes(' -> ') ? body.split(' -> ').pop() : body;
458
- if (!normalizedBody) {
459
- continue;
460
- }
461
-
462
- const state = statusCode.includes('D') ? 'deleted' : 'present';
463
- entries.push({
464
- path: normalizedBody,
465
- state,
466
- });
467
- }
468
-
469
- return entries;
470
- }
471
-
472
- function readFileState(filePath) {
473
- try {
474
- const buffer = fs.readFileSync(filePath);
475
- if (buffer.includes(0)) {
476
- return {
477
- exists: true,
478
- isBinary: true,
479
- content: '',
480
- };
481
- }
482
-
483
- return {
484
- exists: true,
485
- isBinary: false,
486
- content: buffer.toString('utf8').replace(/\r\n/g, '\n'),
487
- };
488
- } catch {
489
- return {
490
- exists: false,
491
- isBinary: false,
492
- content: '',
493
- };
494
- }
495
- }
496
-
497
- function readGitRefFileState(repoRoot, ref, relativePath) {
498
- try {
499
- const buffer = execFileSync('git', ['show', `${ref}:${relativePath}`], {
500
- cwd: repoRoot,
501
- encoding: null,
502
- stdio: ['ignore', 'pipe', 'pipe'],
503
- });
504
-
505
- if (buffer.includes(0)) {
506
- return {
507
- exists: true,
508
- isBinary: true,
509
- content: '',
510
- };
511
- }
512
-
513
- return {
514
- exists: true,
515
- isBinary: false,
516
- content: buffer.toString('utf8').replace(/\r\n/g, '\n'),
517
- };
518
- } catch {
519
- return {
520
- exists: false,
521
- isBinary: false,
522
- content: '',
523
- };
524
- }
525
- }
526
-
527
- function readHeadFileState(repoRoot, relativePath) {
528
- return readGitRefFileState(repoRoot, 'HEAD', relativePath);
529
- }
530
-
531
- function readCurrentHead(repoRoot) {
532
- return runGit(repoRoot, ['rev-parse', 'HEAD'], false);
533
- }
534
-
535
- function parseGitDiffNameOnly(repoRoot, fromRef, toRef) {
536
- const normalizedFromRef = String(fromRef || '').trim();
537
- const normalizedToRef = String(toRef || '').trim();
538
- if (!normalizedFromRef || !normalizedToRef || normalizedFromRef === normalizedToRef) {
539
- return [];
540
- }
541
-
542
- const output = runGit(repoRoot, ['diff', '--name-only', `${normalizedFromRef}..${normalizedToRef}`], false);
543
- return output.split('\n')
544
- .map((line) => line.trim().replace(/\\/g, '/'))
545
- .filter(Boolean);
546
- }
547
-
548
- function statesEqual(beforeState, afterState) {
549
- return beforeState.exists === afterState.exists
550
- && beforeState.isBinary === afterState.isBinary
551
- && beforeState.content === afterState.content;
552
- }
553
-
554
- function buildCodexChange(beforeState, afterState) {
555
- if (afterState.isBinary || beforeState.isBinary) {
556
- const operation = beforeState.exists ? 'edit' : 'write';
557
- return {
558
- operation,
559
- changes: {
560
- added_content: '',
561
- added: 0,
562
- removed: 0,
563
- },
564
- };
565
- }
566
-
567
- const diff = diffLineStats(beforeState.content, afterState.content);
568
- return {
569
- operation: beforeState.exists ? 'edit' : 'write',
570
- changes: {
571
- added_content: diff.addedContent,
572
- added: diff.added,
573
- removed: diff.removed,
574
- },
575
- };
576
- }
577
-
578
- function cleanupStaleSnapshots() {
579
- let entries;
580
- try {
581
- if (!fs.existsSync(CODEX_SNAPSHOT_DIR)) {
582
- return;
583
- }
584
- entries = fs.readdirSync(CODEX_SNAPSHOT_DIR);
585
- } catch {
586
- return;
587
- }
588
-
589
- const nowSec = Date.now() / 1000;
590
- for (const entry of entries) {
591
- if (!entry.endsWith('.json')) {
592
- continue;
593
- }
594
-
595
- const filePath = path.join(CODEX_SNAPSHOT_DIR, entry);
596
- try {
597
- const raw = fs.readFileSync(filePath, 'utf8');
598
- const snapshot = JSON.parse(raw);
599
- const createdAt = String(snapshot.created_at || '');
600
- const createdSec = new Date(createdAt).getTime() / 1000;
601
- if (isNaN(createdSec)) {
602
- continue;
603
- }
604
- if (nowSec - createdSec > CODEX_SNAPSHOT_TTL_SEC) {
605
- fs.rmSync(filePath, { force: true });
606
- }
607
- } catch {
608
- continue;
609
- }
610
- }
611
- }
612
-
613
- function handleCodexUserPromptSubmit(payload) {
614
- const cwd = payload.cwd || process.cwd();
615
- const repoRoot = findGitRepoRoot(cwd);
616
- const snapshotPath = getCodexSnapshotFilePath(payload, repoRoot);
617
- if (!repoRoot || !snapshotPath) {
618
- return [];
619
- }
620
-
621
- const head = readCurrentHead(repoRoot);
622
- const baseline = {};
623
- for (const entry of parseGitStatus(repoRoot)) {
624
- const absolutePath = path.join(repoRoot, entry.path);
625
- baseline[entry.path] = entry.state === 'deleted'
626
- ? readHeadFileState(repoRoot, entry.path)
627
- : readFileState(absolutePath);
628
- }
629
-
630
- cleanupStaleSnapshots();
631
- fs.mkdirSync(CODEX_SNAPSHOT_DIR, { recursive: true });
632
- fs.writeFileSync(snapshotPath, JSON.stringify({
633
- version: 1,
634
- session_id: payload.session_id || payload.sessionId || '',
635
- turn_id: payload.turn_id || payload.turnId || '',
636
- repo_root: repoRoot,
637
- head,
638
- created_at: new Date().toISOString(),
639
- baseline,
640
- }), 'utf8');
641
-
642
- return [];
643
- }
644
-
645
- function buildCodexStopEvents(payload, source) {
646
- const cwd = payload.cwd || process.cwd();
647
- const repoRoot = findGitRepoRoot(cwd);
648
- const snapshotPath = getCodexSnapshotFilePath(payload, repoRoot);
649
- if (!repoRoot || !snapshotPath) {
650
- return [];
651
- }
652
-
653
- let snapshot = null;
654
- if (fs.existsSync(snapshotPath)) {
655
- try {
656
- snapshot = parseJson(fs.readFileSync(snapshotPath, 'utf8'), 'Codex snapshot');
657
- } catch {
658
- snapshot = null;
659
- }
660
- }
661
-
662
- const baseline = snapshot && snapshot.baseline && typeof snapshot.baseline === 'object'
663
- ? snapshot.baseline
664
- : {};
665
- const currentEntries = parseGitStatus(repoRoot);
666
- const currentPaths = new Set(currentEntries.map((entry) => entry.path));
667
- const baseRef = snapshot && typeof snapshot.head === 'string' && snapshot.head.trim()
668
- ? snapshot.head.trim()
669
- : 'HEAD';
670
- const currentHead = readCurrentHead(repoRoot) || 'HEAD';
671
- const committedPaths = new Set(parseGitDiffNameOnly(repoRoot, baseRef, currentHead));
672
- const allPaths = new Set([
673
- ...Object.keys(baseline),
674
- ...currentPaths,
675
- ...committedPaths,
676
- ]);
677
- const workerId = resolveWorkerId(repoRoot);
678
- const git = resolveGitMetadata(repoRoot);
679
- const events = [];
680
-
681
- for (const repoRelativePath of allPaths) {
682
- const absolutePath = path.join(repoRoot, repoRelativePath);
683
- const beforeState = baseline[repoRelativePath] || readGitRefFileState(repoRoot, baseRef, repoRelativePath);
684
- let afterState = readFileState(absolutePath);
685
- if (!afterState.exists && committedPaths.has(repoRelativePath)) {
686
- afterState = readGitRefFileState(repoRoot, currentHead, repoRelativePath);
687
- }
688
-
689
- if (statesEqual(beforeState, afterState)) {
690
- continue;
691
- }
692
-
693
- const changeInfo = buildCodexChange(beforeState, afterState);
694
- events.push({
695
- event_id: randomUUID(),
696
- timestamp: new Date().toISOString(),
697
- source,
698
- worker_id: workerId,
699
- git,
700
- file: {
701
- path: repoRelativePath,
702
- operation: changeInfo.operation,
703
- },
704
- changes: changeInfo.changes,
705
- });
706
- }
707
-
708
- if (fs.existsSync(snapshotPath)) {
709
- fs.rmSync(snapshotPath, { force: true });
710
- }
711
-
712
- return events;
713
- }
436
+ // 从 Codex PostToolUse 的 apply_patch 命令中提取补丁正文。
437
+ function extractApplyPatchText(command) {
438
+ const rawCommand = String(command || '').replace(/\r\n/g, '\n');
439
+ const beginIndex = rawCommand.indexOf('*** Begin Patch');
440
+ const endIndex = rawCommand.indexOf('*** End Patch', beginIndex);
441
+ if (beginIndex === -1 || endIndex === -1) {
442
+ return '';
443
+ }
444
+
445
+ return rawCommand.slice(beginIndex, endIndex + '*** End Patch'.length);
446
+ }
447
+
448
+ // 解析 apply_patch 文件头,返回变更类型和目标路径。
449
+ function parseApplyPatchHeader(line) {
450
+ const match = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
451
+ if (!match) {
452
+ return null;
453
+ }
454
+
455
+ return {
456
+ action: match[1],
457
+ path: match[2].trim(),
458
+ };
459
+ }
460
+
461
+ // 按文件聚合 apply_patch 中的新增/删除行,生成后续上报需要的变更摘要。
462
+ function parseApplyPatchFiles(patchText) {
463
+ const lines = String(patchText || '').replace(/\r\n/g, '\n').split('\n');
464
+ const files = [];
465
+ let current = null;
466
+
467
+ for (const line of lines) {
468
+ const header = parseApplyPatchHeader(line);
469
+ if (header) {
470
+ current = {
471
+ path: header.path,
472
+ action: header.action,
473
+ movedTo: '',
474
+ addedLines: [],
475
+ added: 0,
476
+ removed: 0,
477
+ };
478
+ files.push(current);
479
+ continue;
480
+ }
481
+
482
+ if (!current) {
483
+ continue;
484
+ }
485
+
486
+ if (line.startsWith('*** Move to: ')) {
487
+ current.movedTo = line.slice('*** Move to: '.length).trim();
488
+ continue;
489
+ }
490
+
491
+ if (line.startsWith('+++') || line.startsWith('---')) {
492
+ continue;
493
+ }
494
+
495
+ if (line.startsWith('+')) {
496
+ const addedLine = line.slice(1);
497
+ if (addedLine !== '') {
498
+ current.addedLines.push(addedLine);
499
+ current.added += 1;
500
+ }
501
+ continue;
502
+ }
503
+
504
+ if (line.startsWith('-')) {
505
+ if (line.slice(1) !== '') {
506
+ current.removed += 1;
507
+ }
508
+ }
509
+ }
510
+
511
+ return files;
512
+ }
513
+
514
+ // 将 Codex apply_patch 的文件级变更转换为统一代码事件。
515
+ function buildCodexApplyPatchEvents(payload, source, patchFiles) {
516
+ const cwd = payload.cwd || process.cwd();
517
+ const events = [];
518
+
519
+ for (const fileChange of patchFiles) {
520
+ const eventPath = fileChange.movedTo || fileChange.path;
521
+ const absoluteFilePath = path.resolve(cwd, eventPath);
522
+ const { repoRoot, git, repoRelativePath } = getGitInfo(absoluteFilePath);
523
+ const workerId = resolveWorkerId(repoRoot);
524
+ let addedContent = fileChange.addedLines.join('\n');
525
+ if (addedContent.length > MAX_ADDED_CONTENT_CHARS) {
526
+ addedContent = `${addedContent.slice(0, MAX_ADDED_CONTENT_CHARS)}\n... (truncated)`;
527
+ }
528
+
529
+ events.push({
530
+ event_id: randomUUID(),
531
+ timestamp: new Date().toISOString(),
532
+ source,
533
+ worker_id: workerId,
534
+ git,
535
+ file: {
536
+ path: repoRelativePath || eventPath.replace(/\\/g, '/'),
537
+ operation: fileChange.action === 'Add' ? 'write' : 'edit',
538
+ },
539
+ changes: {
540
+ added_content: addedContent,
541
+ added: fileChange.added,
542
+ removed: fileChange.removed,
543
+ },
544
+ });
545
+ }
546
+
547
+ return events;
548
+ }
714
549
 
715
550
  function buildCodexEvents(payload, source) {
716
551
  const hookEvent = payload.hook_event_name || payload.hookEvent;
717
- if (!CODEX_SUPPORTED_HOOK_EVENTS.has(hookEvent)) {
552
+ const toolName = payload.tool_name || payload.toolName;
553
+ if (hookEvent !== CODEX_SUPPORTED_HOOK_EVENT || toolName !== CODEX_SUPPORTED_TOOL) {
718
554
  return [];
719
555
  }
720
556
 
721
- if (hookEvent === 'UserPromptSubmit') {
722
- return handleCodexUserPromptSubmit(payload);
557
+ const toolInput = payload.tool_input || payload.toolInput || {};
558
+ const patchText = extractApplyPatchText(toolInput.command);
559
+ if (!patchText) {
560
+ return [];
723
561
  }
724
562
 
725
- return buildCodexStopEvents(payload, source);
563
+ return buildCodexApplyPatchEvents(payload, source, parseApplyPatchFiles(patchText));
726
564
  }
727
565
 
728
566
  function loadRequestConfig() {
@@ -790,14 +628,23 @@ function saveCliError(error, context = {}) {
790
628
  error: errorMessage,
791
629
  context,
792
630
  };
631
+
632
+ if (error && typeof error.rawPayload === 'string') {
633
+ record.raw_payload = error.rawPayload;
634
+ }
635
+
793
636
  return appendFailedRecord('cli', record, timestamp);
794
637
  }
795
638
 
796
- function sendJsonRequest(event, config) {
797
- if (process.env.HOOK_REQUEST_DRY_RUN === '1') {
798
- process.stdout.write(`${JSON.stringify(event, null, 2)}\n`);
799
- return Promise.resolve({ statusCode: 0, body: 'dry-run', dryRun: true });
800
- }
639
+ function sendJsonRequest(event, config) {
640
+ if (process.env.HOOK_REQUEST_DRY_RUN === '1') {
641
+ return Promise.resolve({
642
+ statusCode: 0,
643
+ body: 'dry-run',
644
+ dryRun: true,
645
+ event,
646
+ });
647
+ }
801
648
 
802
649
  if (!config.url) {
803
650
  return Promise.reject(new Error('Missing request URL'));
@@ -878,9 +725,19 @@ async function runCodeRecord(source) {
878
725
  return;
879
726
  }
880
727
 
881
- const payload = parseJson(raw, 'Hook payload');
728
+ let payload;
729
+ try {
730
+ payload = JSON.parse(raw);
731
+ } catch (parseError) {
732
+ const message = parseError instanceof Error ? parseError.message : String(parseError || '');
733
+ const dumpPath = path.join(os.homedir(), '.ronds_ai', `hook-payload-${Date.now()}.txt`);
734
+ try {
735
+ fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
736
+ fs.writeFileSync(dumpPath, raw, 'utf8');
737
+ } catch { /* best effort */ }
738
+ throw new Error(`Hook payload is not valid JSON: ${message}\nRaw payload dumped to ${dumpPath}`);
739
+ }
882
740
  const events = buildEvents(payload, source);
883
- const hookEvent = payload.hook_event_name || payload.hookEvent;
884
741
  const config = loadRequestConfig();
885
742
 
886
743
  const results = [];
@@ -902,15 +759,18 @@ async function runCodeRecord(source) {
902
759
  }
903
760
  }
904
761
 
905
- if (source === 'codex' && hookEvent === 'Stop') {
906
- process.stdout.write(`${JSON.stringify(CODEX_STOP_OUTPUT)}\n`);
907
- return;
908
- }
909
-
910
- for (const result of results) {
911
- process.stdout.write(`${JSON.stringify(result)}\n`);
912
- }
913
- }
762
+ for (const result of results) {
763
+ if (source === 'codex') {
764
+ continue;
765
+ }
766
+
767
+ process.stdout.write(`${JSON.stringify(result)}\n`);
768
+ }
769
+
770
+ if (source === 'codex') {
771
+ process.stdout.write(`${JSON.stringify(buildCodexPostToolUseOutput())}\n`);
772
+ }
773
+ }
914
774
 
915
775
  module.exports = {
916
776
  runCodeRecord,
package/lib/doctor.js CHANGED
@@ -36,8 +36,10 @@ function getConfigChecks(tool, baseDir) {
36
36
  if (tool === 'codex') {
37
37
  return [
38
38
  {
39
- label: '.codex/hooks.json',
40
- path: path.join(baseDir, '.codex', 'hooks.json'),
39
+ label: 'Codex requirements.toml',
40
+ path: process.platform === 'win32'
41
+ ? 'C:\\ProgramData\\OpenAI\\Codex\\requirements.toml'
42
+ : '/etc/codex/requirements.toml',
41
43
  },
42
44
  ];
43
45
  }