ronds_ai 0.1.23 → 0.1.25
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 +1 -1
- package/bin/ronds_ai.js +44 -30
- package/lib/analyze_claude.js +111 -15
- package/lib/analyze_config.js +22 -2
- package/lib/hooks_auto_sync.js +33 -7
- package/lib/hooks_deploy.js +130 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ ronds_ai analyze claude
|
|
|
190
190
|
|
|
191
191
|
行为与 Langfuse Claude Observability Plugin 一致,只采集 transcript 中的 `text`、`tool_use` 和 `tool_result`,不上传 `thinking` block。状态和日志保存在 `~/.ronds_ai/analyze/`。
|
|
192
192
|
|
|
193
|
-
Langfuse 连接信息已内置,Trace 的 `userId`
|
|
193
|
+
Langfuse 连接信息已内置,Trace 的 `userId` 优先使用当前项目的 `git user.email`,读取不到时回退到当前操作系统用户名。可选配置:
|
|
194
194
|
|
|
195
195
|
- `CC_LANGFUSE_DEBUG=true`:启用详细日志
|
|
196
196
|
- `CC_LANGFUSE_MAX_CHARS=<正整数>`:单个文本字段的最大字符数,默认 `20000`
|
package/bin/ronds_ai.js
CHANGED
|
@@ -62,10 +62,10 @@ const SUPPORTED_SOURCES = new Set(['claude', 'cursor', 'codex', 'hermes']);
|
|
|
62
62
|
|
|
63
63
|
function printUsage() {
|
|
64
64
|
process.stderr.write([
|
|
65
|
-
'Usage:',
|
|
66
|
-
' ronds_ai record <tool>',
|
|
67
|
-
' ronds_ai analyze claude',
|
|
68
|
-
' ronds_ai check record',
|
|
65
|
+
'Usage:',
|
|
66
|
+
' ronds_ai record <tool>',
|
|
67
|
+
' ronds_ai analyze claude',
|
|
68
|
+
' ronds_ai check record',
|
|
69
69
|
' ronds_ai doctor <tool>',
|
|
70
70
|
' ronds_ai hooks deploy [--scope project|user] [--tool cursor|claude|codex]',
|
|
71
71
|
' ronds_ai hooks deploy hermes',
|
|
@@ -78,8 +78,8 @@ function printUsage() {
|
|
|
78
78
|
' hermes',
|
|
79
79
|
'',
|
|
80
80
|
'Examples:',
|
|
81
|
-
' npx ronds_ai@latest record claude',
|
|
82
|
-
' npx ronds_ai@latest analyze claude',
|
|
81
|
+
' npx ronds_ai@latest record claude',
|
|
82
|
+
' npx ronds_ai@latest analyze claude',
|
|
83
83
|
' npx ronds_ai@latest record cursor',
|
|
84
84
|
' npx ronds_ai@latest check record',
|
|
85
85
|
' npx ronds_ai@latest doctor claude',
|
|
@@ -282,7 +282,7 @@ async function run() {
|
|
|
282
282
|
|
|
283
283
|
maybeStartHooksAutoSync({ command, args });
|
|
284
284
|
|
|
285
|
-
if (command === 'record') {
|
|
285
|
+
if (command === 'record') {
|
|
286
286
|
const [source] = args;
|
|
287
287
|
const normalizedSource = String(source || '').trim().toLowerCase();
|
|
288
288
|
|
|
@@ -291,21 +291,21 @@ async function run() {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
await runCodeRecord(normalizedSource);
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (command === 'analyze') {
|
|
298
|
-
const [source] = args;
|
|
299
|
-
if (String(source || '').trim().toLowerCase() !== 'claude') {
|
|
300
|
-
throw new Error(`Unsupported analyze tool: ${source || ''}`);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// 仅在 analyze 分支中加载 Node 20+ 的可选 Langfuse 依赖,避免影响旧版 record。
|
|
304
|
-
const { runClaudeAnalyze } = require('../lib/analyze_claude');
|
|
305
|
-
await runClaudeAnalyze();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (command === 'analyze') {
|
|
298
|
+
const [source] = args;
|
|
299
|
+
if (String(source || '').trim().toLowerCase() !== 'claude') {
|
|
300
|
+
throw new Error(`Unsupported analyze tool: ${source || ''}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 仅在 analyze 分支中加载 Node 20+ 的可选 Langfuse 依赖,避免影响旧版 record。
|
|
304
|
+
const { runClaudeAnalyze } = require('../lib/analyze_claude');
|
|
305
|
+
await runClaudeAnalyze();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
309
|
if (command === 'doctor') {
|
|
310
310
|
const [tool] = args;
|
|
311
311
|
const normalizedTool = String(tool || '').trim().toLowerCase();
|
|
@@ -342,14 +342,28 @@ async function run() {
|
|
|
342
342
|
throw new Error(`Unsupported command: ${command || ''}`);
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
run().catch((error) => {
|
|
346
|
-
if (error && error.code === 'ANALYZE_PREFLIGHT') {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
345
|
+
run().catch((error) => {
|
|
346
|
+
if (error && error.code === 'ANALYZE_PREFLIGHT') {
|
|
347
|
+
// 旁路 analyze 预检未通过时静默跳过(如 Node 版本不满足或依赖缺失),避免干扰主流程或弹出警告
|
|
348
|
+
try {
|
|
349
|
+
const fs = require('fs');
|
|
350
|
+
const os = require('os');
|
|
351
|
+
const path = require('path');
|
|
352
|
+
const analyzeDir = path.join(os.homedir(), '.ronds_ai', 'analyze');
|
|
353
|
+
fs.mkdirSync(analyzeDir, { recursive: true });
|
|
354
|
+
fs.appendFileSync(
|
|
355
|
+
path.join(analyzeDir, 'langfuse_hook.log'),
|
|
356
|
+
`${new Date().toISOString()} [INFO] analyze preflight skipped: ${error.message}\n`,
|
|
357
|
+
'utf8',
|
|
358
|
+
);
|
|
359
|
+
} catch {
|
|
360
|
+
// 忽略日志写入错误
|
|
361
|
+
}
|
|
362
|
+
process.exitCode = 0;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const [, , command, ...args] = process.argv;
|
|
353
367
|
let savedPath = '';
|
|
354
368
|
|
|
355
369
|
try {
|
package/lib/analyze_claude.js
CHANGED
|
@@ -44,19 +44,99 @@ function assertAnalyzeNodeVersion(version = process.versions.node) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 尝试解析并 require 指定的模块,支持常规 require 和 ~/.ronds_ai/analyze/node_modules 路径。
|
|
49
|
+
*/
|
|
50
|
+
function resolveAnalyzeModule(moduleName) {
|
|
51
|
+
try {
|
|
52
|
+
return require(moduleName);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code !== 'MODULE_NOT_FOUND') {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return resolveIsolatedAnalyzeModule(moduleName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 只从 analyze 隔离目录解析模块,避免把 npx 临时目录中的依赖误判为已完成预装。
|
|
64
|
+
* @param {string} moduleName - Node.js 模块名
|
|
65
|
+
* @param {string} analyzeDir - analyze 隔离目录
|
|
66
|
+
* @returns {object|null}
|
|
67
|
+
*/
|
|
68
|
+
function resolveIsolatedAnalyzeModule(moduleName, analyzeDir = ANALYZE_DIR) {
|
|
69
|
+
const customPath = path.join(analyzeDir, 'node_modules', moduleName);
|
|
70
|
+
try {
|
|
71
|
+
return require(customPath);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error && error.code !== 'MODULE_NOT_FOUND') {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 检查 analyze 必需的依赖是否已就绪。
|
|
83
|
+
*/
|
|
84
|
+
function areAnalyzeDependenciesInstalled() {
|
|
85
|
+
try {
|
|
86
|
+
const otelSdk = resolveAnalyzeModule('@opentelemetry/sdk-node');
|
|
87
|
+
const langfuseOtel = resolveAnalyzeModule('@langfuse/otel');
|
|
88
|
+
const langfuseTracing = resolveAnalyzeModule('@langfuse/tracing');
|
|
89
|
+
return Boolean(
|
|
90
|
+
otelSdk && otelSdk.NodeSDK
|
|
91
|
+
&& langfuseOtel && langfuseOtel.LangfuseSpanProcessor
|
|
92
|
+
&& langfuseTracing && typeof langfuseTracing.startObservation === 'function',
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 检查 analyze 隔离目录中的必需依赖是否已就绪。
|
|
101
|
+
* deploy 只能使用此检查,运行时仍可使用普通依赖优先、隔离目录兜底的策略。
|
|
102
|
+
* @param {string} analyzeDir - analyze 隔离目录
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function areIsolatedAnalyzeDependenciesInstalled(analyzeDir = ANALYZE_DIR) {
|
|
106
|
+
try {
|
|
107
|
+
const otelSdk = resolveIsolatedAnalyzeModule('@opentelemetry/sdk-node', analyzeDir);
|
|
108
|
+
const langfuseOtel = resolveIsolatedAnalyzeModule('@langfuse/otel', analyzeDir);
|
|
109
|
+
const langfuseTracing = resolveIsolatedAnalyzeModule('@langfuse/tracing', analyzeDir);
|
|
110
|
+
return Boolean(
|
|
111
|
+
otelSdk && otelSdk.NodeSDK
|
|
112
|
+
&& langfuseOtel && langfuseOtel.LangfuseSpanProcessor
|
|
113
|
+
&& langfuseTracing && typeof langfuseTracing.startObservation === 'function',
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
47
120
|
/**
|
|
48
121
|
* 懒加载 analyze 专用的可选 Langfuse 依赖。
|
|
49
122
|
*/
|
|
50
123
|
function loadLangfuseSdk() {
|
|
51
124
|
try {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
125
|
+
const otelSdk = resolveAnalyzeModule('@opentelemetry/sdk-node');
|
|
126
|
+
const langfuseOtel = resolveAnalyzeModule('@langfuse/otel');
|
|
127
|
+
const langfuseTracing = resolveAnalyzeModule('@langfuse/tracing');
|
|
128
|
+
if (
|
|
129
|
+
!otelSdk || !otelSdk.NodeSDK
|
|
130
|
+
|| !langfuseOtel || !langfuseOtel.LangfuseSpanProcessor
|
|
131
|
+
|| !langfuseTracing || typeof langfuseTracing.startObservation !== 'function'
|
|
132
|
+
) {
|
|
133
|
+
throw new Error('Dependencies missing');
|
|
134
|
+
}
|
|
55
135
|
return {
|
|
56
|
-
NodeSDK,
|
|
57
|
-
LangfuseSpanProcessor,
|
|
58
|
-
startObservation:
|
|
59
|
-
propagateAttributes:
|
|
136
|
+
NodeSDK: otelSdk.NodeSDK,
|
|
137
|
+
LangfuseSpanProcessor: langfuseOtel.LangfuseSpanProcessor,
|
|
138
|
+
startObservation: langfuseTracing.startObservation,
|
|
139
|
+
propagateAttributes: langfuseTracing.propagateAttributes,
|
|
60
140
|
};
|
|
61
141
|
} catch {
|
|
62
142
|
throw new AnalyzePreflightError(
|
|
@@ -102,11 +182,20 @@ function extractHookContext(payload) {
|
|
|
102
182
|
return null;
|
|
103
183
|
}
|
|
104
184
|
const hookEventName = payload.hook_event_name || payload.hookEventName || '';
|
|
105
|
-
|
|
185
|
+
const context = {
|
|
106
186
|
sessionId,
|
|
107
187
|
transcriptPath: resolvedPath,
|
|
108
188
|
flushDeferredAgentTurns: hookEventName === 'SessionEnd',
|
|
109
189
|
};
|
|
190
|
+
const projectDir = [
|
|
191
|
+
payload.cwd,
|
|
192
|
+
payload.workspace && payload.workspace.current_dir,
|
|
193
|
+
payload.workspace && payload.workspace.project_dir,
|
|
194
|
+
].find((value) => typeof value === 'string' && value.trim());
|
|
195
|
+
if (projectDir) {
|
|
196
|
+
context.projectDir = path.resolve(projectDir);
|
|
197
|
+
}
|
|
198
|
+
return context;
|
|
110
199
|
}
|
|
111
200
|
|
|
112
201
|
/**
|
|
@@ -426,10 +515,7 @@ async function processHookContext(context, config, sdkModules) {
|
|
|
426
515
|
async function runClaudeAnalyze() {
|
|
427
516
|
assertAnalyzeNodeVersion();
|
|
428
517
|
const sdkModules = loadLangfuseSdk();
|
|
429
|
-
const config = getAnalyzeConfig();
|
|
430
|
-
writeAnalyzeDebug('analyze hook started', config);
|
|
431
518
|
const raw = await readStdin();
|
|
432
|
-
writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
|
|
433
519
|
if (!raw.trim()) {
|
|
434
520
|
return;
|
|
435
521
|
}
|
|
@@ -438,18 +524,23 @@ async function runClaudeAnalyze() {
|
|
|
438
524
|
try {
|
|
439
525
|
payload = JSON.parse(raw);
|
|
440
526
|
} catch (error) {
|
|
527
|
+
const config = getAnalyzeConfig();
|
|
441
528
|
writeAnalyzeLog('INFO', `invalid hook payload: ${error.message}`, config);
|
|
442
529
|
return;
|
|
443
530
|
}
|
|
444
|
-
writeAnalyzeDebug(
|
|
445
|
-
`payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
|
|
446
|
-
config,
|
|
447
|
-
);
|
|
448
531
|
const context = extractHookContext(payload);
|
|
449
532
|
if (!context) {
|
|
533
|
+
const config = getAnalyzeConfig();
|
|
450
534
|
writeAnalyzeLog('INFO', 'hook payload has no usable session or transcript', config);
|
|
451
535
|
return;
|
|
452
536
|
}
|
|
537
|
+
const config = getAnalyzeConfig(context.projectDir);
|
|
538
|
+
writeAnalyzeDebug('analyze hook started', config);
|
|
539
|
+
writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
|
|
540
|
+
writeAnalyzeDebug(
|
|
541
|
+
`payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
|
|
542
|
+
config,
|
|
543
|
+
);
|
|
453
544
|
|
|
454
545
|
try {
|
|
455
546
|
await processHookContext(context, config, sdkModules);
|
|
@@ -462,10 +553,15 @@ async function runClaudeAnalyze() {
|
|
|
462
553
|
module.exports = {
|
|
463
554
|
ANALYZE_DIR,
|
|
464
555
|
AnalyzePreflightError,
|
|
556
|
+
areAnalyzeDependenciesInstalled,
|
|
557
|
+
areIsolatedAnalyzeDependenciesInstalled,
|
|
465
558
|
assertAnalyzeNodeVersion,
|
|
466
559
|
extractHookContext,
|
|
467
560
|
getSessionStateKey,
|
|
561
|
+
loadLangfuseSdk,
|
|
468
562
|
processHookContext,
|
|
469
563
|
redactLogMessage,
|
|
564
|
+
resolveAnalyzeModule,
|
|
565
|
+
resolveIsolatedAnalyzeModule,
|
|
470
566
|
runClaudeAnalyze,
|
|
471
567
|
};
|
package/lib/analyze_config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const os = require('os');
|
|
2
|
+
const { runGit } = require('./git');
|
|
2
3
|
|
|
3
4
|
// Langfuse 连接信息按产品要求从当前 Claude 配置写入源码。
|
|
4
5
|
const LANGFUSE_PUBLIC_KEY = 'pk-lf-default-001';
|
|
@@ -46,15 +47,32 @@ function resolveSystemUsername() {
|
|
|
46
47
|
return process.env.USERNAME || process.env.USER || 'unknown';
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 读取当前项目的 Git 邮箱,与 record 上报的 git.user.email 保持一致。
|
|
52
|
+
*/
|
|
53
|
+
function resolveGitUserEmail(projectDir) {
|
|
54
|
+
const workingDirectory = typeof projectDir === 'string' && projectDir.trim()
|
|
55
|
+
? projectDir.trim()
|
|
56
|
+
: process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
57
|
+
return runGit(workingDirectory, ['config', 'user.email'], false).toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析 Langfuse 用户标识,优先使用 Git 邮箱并保留系统用户名兜底。
|
|
62
|
+
*/
|
|
63
|
+
function resolveAnalyzeUserId(projectDir) {
|
|
64
|
+
return resolveGitUserEmail(projectDir) || resolveSystemUsername();
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
/**
|
|
50
68
|
* 返回 analyze 命令使用的固定 Langfuse 配置。
|
|
51
69
|
*/
|
|
52
|
-
function getAnalyzeConfig() {
|
|
70
|
+
function getAnalyzeConfig(projectDir) {
|
|
53
71
|
return {
|
|
54
72
|
publicKey: LANGFUSE_PUBLIC_KEY,
|
|
55
73
|
secretKey: LANGFUSE_SECRET_KEY,
|
|
56
74
|
baseUrl: LANGFUSE_BASE_URL,
|
|
57
|
-
userId:
|
|
75
|
+
userId: resolveAnalyzeUserId(projectDir),
|
|
58
76
|
debug: readBooleanOption('CC_LANGFUSE_DEBUG', false),
|
|
59
77
|
maxChars: readPositiveIntegerOption('CC_LANGFUSE_MAX_CHARS', 20000),
|
|
60
78
|
skillTags: readBooleanOption('CC_LANGFUSE_SKILL_TAGS', true),
|
|
@@ -64,5 +82,7 @@ function getAnalyzeConfig() {
|
|
|
64
82
|
|
|
65
83
|
module.exports = {
|
|
66
84
|
getAnalyzeConfig,
|
|
85
|
+
resolveAnalyzeUserId,
|
|
86
|
+
resolveGitUserEmail,
|
|
67
87
|
resolveSystemUsername,
|
|
68
88
|
};
|
package/lib/hooks_auto_sync.js
CHANGED
|
@@ -20,11 +20,15 @@ const { spawn } = require('child_process');
|
|
|
20
20
|
|
|
21
21
|
/** 当前 hooks schema 版本。当用户级 hooks deploy 输出发生变化时 +1。 */
|
|
22
22
|
// v2: 用户级 Claude settings 新增 Stop/SessionEnd analyze hook。
|
|
23
|
-
|
|
23
|
+
// v3: 优化 analyze 依赖预装到独立目录并守门。
|
|
24
|
+
const HOOKS_SCHEMA_VERSION = 3;
|
|
24
25
|
|
|
25
26
|
/** sentinel 有效时长:24 小时 */
|
|
26
27
|
const HOOKS_AUTO_SYNC_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
28
|
|
|
29
|
+
/** analyze 依赖安装失败后的重试间隔:24 小时 */
|
|
30
|
+
const ANALYZE_DEPENDENCY_RETRY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
28
32
|
/** lock 文件视为 stale 的超时时长:10 分钟 */
|
|
29
33
|
const STALE_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
30
34
|
|
|
@@ -119,12 +123,14 @@ function readHooksAutoSyncState() {
|
|
|
119
123
|
|
|
120
124
|
/**
|
|
121
125
|
* 写入 hooks_state.json。
|
|
122
|
-
* @param {{ schemaVersion?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
|
|
126
|
+
* @param {{ schemaVersion?: number, analyzeReady?: boolean, lastAnalyzeAttemptAt?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
|
|
123
127
|
*/
|
|
124
128
|
function writeHooksAutoSyncState(state) {
|
|
125
129
|
ensureAutoSyncDir();
|
|
126
130
|
const data = {
|
|
127
131
|
schemaVersion: Number(state.schemaVersion) || 0,
|
|
132
|
+
analyzeReady: state.analyzeReady === true,
|
|
133
|
+
lastAnalyzeAttemptAt: Number(state.lastAnalyzeAttemptAt) || 0,
|
|
128
134
|
lastCheckedAt: Number(state.lastCheckedAt) || 0,
|
|
129
135
|
lastSyncedAt: Number(state.lastSyncedAt) || 0,
|
|
130
136
|
};
|
|
@@ -156,9 +162,10 @@ function shouldSkipHooksAutoSync(command) {
|
|
|
156
162
|
*
|
|
157
163
|
* @param {object} state - 从状态文件读取的状态
|
|
158
164
|
* @param {number} now - Date.now()
|
|
165
|
+
* @param {string} nodeVersion - 当前 Node.js 版本
|
|
159
166
|
* @returns {'skip' | 'update-timestamp' | 'schedule'}
|
|
160
167
|
*/
|
|
161
|
-
function shouldScheduleHooksAutoSync(state, now) {
|
|
168
|
+
function shouldScheduleHooksAutoSync(state, now, nodeVersion = process.versions.node) {
|
|
162
169
|
const lastCheckedAt = Number(state.lastCheckedAt || 0);
|
|
163
170
|
|
|
164
171
|
// 仍在 TTL 窗口内,跳过
|
|
@@ -168,8 +175,15 @@ function shouldScheduleHooksAutoSync(state, now) {
|
|
|
168
175
|
|
|
169
176
|
const schemaVersion = Number(state.schemaVersion || 0);
|
|
170
177
|
|
|
171
|
-
//
|
|
178
|
+
// schema 已更新但 analyze 依赖未就绪时,按独立间隔重试安装。
|
|
172
179
|
if (schemaVersion >= HOOKS_SCHEMA_VERSION) {
|
|
180
|
+
const nodeMajor = Number(String(nodeVersion || '').replace(/^v/i, '').split('.')[0]);
|
|
181
|
+
if (nodeMajor >= 20 && state.analyzeReady !== true) {
|
|
182
|
+
const lastAnalyzeAttemptAt = Number(state.lastAnalyzeAttemptAt || 0);
|
|
183
|
+
if (now - lastAnalyzeAttemptAt >= ANALYZE_DEPENDENCY_RETRY_TTL_MS) {
|
|
184
|
+
return 'schedule';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
173
187
|
return 'update-timestamp';
|
|
174
188
|
}
|
|
175
189
|
|
|
@@ -232,6 +246,8 @@ function maybeStartHooksAutoSync({ command, args } = {}) {
|
|
|
232
246
|
if (decision === 'update-timestamp') {
|
|
233
247
|
writeHooksAutoSyncState({
|
|
234
248
|
schemaVersion: Number(state.schemaVersion || 0),
|
|
249
|
+
analyzeReady: state.analyzeReady === true,
|
|
250
|
+
lastAnalyzeAttemptAt: Number(state.lastAnalyzeAttemptAt || 0),
|
|
235
251
|
lastCheckedAt: now,
|
|
236
252
|
lastSyncedAt: Number(state.lastSyncedAt || 0),
|
|
237
253
|
});
|
|
@@ -324,7 +340,7 @@ function releaseHooksAutoSyncLock(fd) {
|
|
|
324
340
|
* 此函数由后台子进程(ronds_ai internal hooks auto-sync)调用。
|
|
325
341
|
* 不向 stdout 输出,错误写入本地日志文件。
|
|
326
342
|
*/
|
|
327
|
-
function runHooksAutoSync() {
|
|
343
|
+
function runHooksAutoSync(options = {}) {
|
|
328
344
|
const lock = acquireHooksAutoSyncLock();
|
|
329
345
|
if (!lock) return; // 已有进程在同步
|
|
330
346
|
|
|
@@ -332,14 +348,23 @@ function runHooksAutoSync() {
|
|
|
332
348
|
|
|
333
349
|
try {
|
|
334
350
|
const { deployHooks, deployHermesHook } = require('./hooks_deploy');
|
|
351
|
+
const previousState = readHooksAutoSyncState();
|
|
335
352
|
|
|
336
|
-
deployHooks(os.homedir(), { scope: 'user' });
|
|
353
|
+
const deployResult = deployHooks(os.homedir(), { scope: 'user' });
|
|
337
354
|
deployHermesHook();
|
|
338
355
|
|
|
356
|
+
const nodeVersion = options.nodeVersion || process.versions.node;
|
|
357
|
+
const nodeMajor = Number(String(nodeVersion || '').replace(/^v/i, '').split('.')[0]);
|
|
358
|
+
const analyzeRequired = nodeMajor >= 20;
|
|
359
|
+
const analyzeReady = Boolean(deployResult?.analyze?.installed);
|
|
360
|
+
const syncComplete = !analyzeRequired || analyzeReady;
|
|
361
|
+
|
|
339
362
|
writeHooksAutoSyncState({
|
|
340
363
|
schemaVersion: HOOKS_SCHEMA_VERSION,
|
|
364
|
+
analyzeReady,
|
|
365
|
+
lastAnalyzeAttemptAt: analyzeRequired ? now : Number(previousState.lastAnalyzeAttemptAt || 0),
|
|
341
366
|
lastCheckedAt: now,
|
|
342
|
-
lastSyncedAt: now,
|
|
367
|
+
lastSyncedAt: syncComplete ? now : Number(previousState.lastSyncedAt || 0),
|
|
343
368
|
});
|
|
344
369
|
touchAutoSyncSentinel(now);
|
|
345
370
|
} catch (error) {
|
|
@@ -382,6 +407,7 @@ function saveHooksAutoSyncError(error) {
|
|
|
382
407
|
// ---------------------------------------------------------------------------
|
|
383
408
|
|
|
384
409
|
module.exports = {
|
|
410
|
+
ANALYZE_DEPENDENCY_RETRY_TTL_MS,
|
|
385
411
|
HOOKS_SCHEMA_VERSION,
|
|
386
412
|
maybeStartHooksAutoSync,
|
|
387
413
|
runHooksAutoSync,
|
package/lib/hooks_deploy.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
const
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
2
3
|
const os = require('os');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const commentJson = require('comment-json');
|
|
5
6
|
const yaml = require('js-yaml');
|
|
7
|
+
const {
|
|
8
|
+
ANALYZE_DIR,
|
|
9
|
+
areIsolatedAnalyzeDependenciesInstalled,
|
|
10
|
+
} = require('./analyze_claude');
|
|
6
11
|
|
|
7
12
|
const CURSOR_COMMAND = 'npx ronds_ai@latest record cursor';
|
|
8
13
|
const CLAUDE_COMMAND = 'npx ronds_ai@latest record claude';
|
|
@@ -269,7 +274,7 @@ function splitClaudeAnalyzeEntries(entries) {
|
|
|
269
274
|
* 在 Claude settings 中确保 Stop/SessionEnd 的 analyze hook 条目。
|
|
270
275
|
* 只管理命令完全等于 CLAUDE_ANALYZE_COMMAND 的 hook,保留用户自定义条目。
|
|
271
276
|
*/
|
|
272
|
-
function ensureClaudeAnalyzeHooks(config) {
|
|
277
|
+
function ensureClaudeAnalyzeHooks(config) {
|
|
273
278
|
const next = isPlainObject(config) ? { ...config } : {};
|
|
274
279
|
const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
|
|
275
280
|
|
|
@@ -286,8 +291,59 @@ function ensureClaudeAnalyzeHooks(config) {
|
|
|
286
291
|
}
|
|
287
292
|
|
|
288
293
|
next.hooks = hooks;
|
|
289
|
-
return next;
|
|
290
|
-
}
|
|
294
|
+
return next;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 从 Claude settings 的 Stop/SessionEnd 中移除本工具管理的 analyze hook。
|
|
299
|
+
* 仅删除命令完全相等的 command hook,并保留用户自定义 entry 与命令。
|
|
300
|
+
* @param {object} config - Claude settings 配置
|
|
301
|
+
* @returns {object}
|
|
302
|
+
*/
|
|
303
|
+
function removeClaudeAnalyzeHooks(config) {
|
|
304
|
+
const next = isPlainObject(config) ? { ...config } : {};
|
|
305
|
+
if (!isPlainObject(next.hooks)) {
|
|
306
|
+
return next;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const hooks = { ...next.hooks };
|
|
310
|
+
for (const eventName of CLAUDE_ANALYZE_EVENTS) {
|
|
311
|
+
const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
|
|
312
|
+
const cleanedEntries = [];
|
|
313
|
+
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
|
|
316
|
+
cleanedEntries.push(entry);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const filteredHooks = entry.hooks.filter(
|
|
321
|
+
(hook) => !(isPlainObject(hook)
|
|
322
|
+
&& hook.type === 'command'
|
|
323
|
+
&& hook.command === CLAUDE_ANALYZE_COMMAND),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (filteredHooks.length === entry.hooks.length) {
|
|
327
|
+
cleanedEntries.push(entry);
|
|
328
|
+
} else if (filteredHooks.length > 0) {
|
|
329
|
+
cleanedEntries.push({ ...entry, hooks: filteredHooks });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (cleanedEntries.length > 0) {
|
|
334
|
+
hooks[eventName] = cleanedEntries;
|
|
335
|
+
} else {
|
|
336
|
+
delete hooks[eventName];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (Object.keys(hooks).length > 0) {
|
|
341
|
+
next.hooks = hooks;
|
|
342
|
+
} else {
|
|
343
|
+
delete next.hooks;
|
|
344
|
+
}
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
291
347
|
|
|
292
348
|
function ensureCodexHooksFeatureFlag(toml) {
|
|
293
349
|
const newline = toml.includes('\r\n') ? '\r\n' : '\n';
|
|
@@ -584,14 +640,70 @@ function deployCursorHook(cursorPath, result) {
|
|
|
584
640
|
recordWriteResult(cursorPath, cursorResult.exists, changed, result);
|
|
585
641
|
}
|
|
586
642
|
|
|
643
|
+
/**
|
|
644
|
+
* 确保 analyze 所需的依赖在 ~/.ronds_ai/analyze/ 中安装就绪。
|
|
645
|
+
* 成功或已存在返回 true;安装失败返回 false。
|
|
646
|
+
*/
|
|
647
|
+
function ensureAnalyzeDependencies(options = {}) {
|
|
648
|
+
const analyzeDir = options.analyzeDir || ANALYZE_DIR;
|
|
649
|
+
const areDependenciesInstalled = options.areDependenciesInstalled
|
|
650
|
+
|| areIsolatedAnalyzeDependenciesInstalled;
|
|
651
|
+
|
|
652
|
+
if (areDependenciesInstalled(analyzeDir)) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
ensureDir(analyzeDir);
|
|
658
|
+
if (typeof options.installDependencies === 'function') {
|
|
659
|
+
options.installDependencies(analyzeDir);
|
|
660
|
+
return areDependenciesInstalled(analyzeDir);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
664
|
+
const packages = [
|
|
665
|
+
'@langfuse/otel@^5.9.1',
|
|
666
|
+
'@langfuse/tracing@^5.9.1',
|
|
667
|
+
'@opentelemetry/sdk-node@^0.221.0',
|
|
668
|
+
];
|
|
669
|
+
execFileSync(
|
|
670
|
+
npmCommand,
|
|
671
|
+
[
|
|
672
|
+
'install',
|
|
673
|
+
'--no-audit',
|
|
674
|
+
'--no-fund',
|
|
675
|
+
'--prefix',
|
|
676
|
+
analyzeDir,
|
|
677
|
+
...packages,
|
|
678
|
+
],
|
|
679
|
+
{
|
|
680
|
+
stdio: 'ignore',
|
|
681
|
+
timeout: 60000,
|
|
682
|
+
windowsHide: true,
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
return areDependenciesInstalled(analyzeDir);
|
|
686
|
+
} catch {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
587
691
|
/**
|
|
588
692
|
* 判断本次部署是否写入 Claude analyze hook。
|
|
589
693
|
* 不满足条件时把原因写入 result.analyze.reason 并返回 false。
|
|
590
694
|
*/
|
|
591
|
-
function shouldDeployClaudeAnalyze(result) {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
695
|
+
function shouldDeployClaudeAnalyze(result, options = {}) {
|
|
696
|
+
const nodeVersion = options.nodeVersion || process.versions.node;
|
|
697
|
+
const nodeMajor = getNodeMajorVersion(nodeVersion);
|
|
698
|
+
if (nodeMajor < 20) {
|
|
699
|
+
result.analyze.reason = `analyze requires Node.js 20 or newer; current is ${nodeVersion || 'unknown'}`;
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const ensureDependencies = options.ensureAnalyzeDependencies || ensureAnalyzeDependencies;
|
|
704
|
+
const dependenciesReady = ensureDependencies(options.dependencyOptions);
|
|
705
|
+
if (!dependenciesReady) {
|
|
706
|
+
result.analyze.reason = 'analyze dependencies failed to install or are unavailable';
|
|
595
707
|
return false;
|
|
596
708
|
}
|
|
597
709
|
|
|
@@ -602,8 +714,10 @@ function deployClaudeSettings(claudeSettingsPath, result, analyzeDeployed) {
|
|
|
602
714
|
const claudeSettingsResult = readJsonFile(claudeSettingsPath, {});
|
|
603
715
|
let nextClaudeSettings = ensureClaudeSettings(claudeSettingsResult.data);
|
|
604
716
|
|
|
605
|
-
if (analyzeDeployed) {
|
|
606
|
-
nextClaudeSettings = ensureClaudeAnalyzeHooks(nextClaudeSettings);
|
|
717
|
+
if (analyzeDeployed) {
|
|
718
|
+
nextClaudeSettings = ensureClaudeAnalyzeHooks(nextClaudeSettings);
|
|
719
|
+
} else {
|
|
720
|
+
nextClaudeSettings = removeClaudeAnalyzeHooks(nextClaudeSettings);
|
|
607
721
|
}
|
|
608
722
|
|
|
609
723
|
const changed = writeJsonFile(claudeSettingsPath, nextClaudeSettings);
|
|
@@ -683,7 +797,7 @@ function deployHooks(targetDir = process.cwd(), options = {}) {
|
|
|
683
797
|
}
|
|
684
798
|
|
|
685
799
|
if (!options.tool || options.tool === 'claude') {
|
|
686
|
-
const analyzeDeployed = shouldDeployClaudeAnalyze(result);
|
|
800
|
+
const analyzeDeployed = shouldDeployClaudeAnalyze(result, options.analyzeRuntime);
|
|
687
801
|
deployClaudeSettings(paths.claudeSettingsPath, result, analyzeDeployed);
|
|
688
802
|
if (analyzeDeployed) {
|
|
689
803
|
result.analyze.installed = true;
|
|
@@ -787,6 +901,9 @@ function deployHermesHook() {
|
|
|
787
901
|
module.exports = {
|
|
788
902
|
deployHooks,
|
|
789
903
|
deployHermesHook,
|
|
790
|
-
|
|
791
|
-
|
|
904
|
+
ensureAnalyzeDependencies,
|
|
905
|
+
ensureClaudeAnalyzeHooks,
|
|
906
|
+
getNodeMajorVersion,
|
|
907
|
+
removeClaudeAnalyzeHooks,
|
|
908
|
+
shouldDeployClaudeAnalyze,
|
|
792
909
|
};
|