ronds_ai 0.1.20 → 0.1.22
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 +89 -8
- package/bin/ronds_ai.js +52 -10
- package/lib/analyze_claude.js +471 -0
- package/lib/analyze_config.js +68 -0
- package/lib/analyze_langfuse.js +736 -0
- package/lib/analyze_transcript.js +803 -0
- package/lib/hooks_auto_sync.js +397 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
10
|
-
- Node.js `>=16`
|
|
11
|
-
- `git`
|
|
12
|
-
- `unzip`
|
|
13
|
-
|
|
14
|
-
`git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
|
|
10
|
+
- Node.js `>=16`
|
|
11
|
+
- `git`
|
|
12
|
+
- `unzip`
|
|
13
|
+
|
|
14
|
+
`git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
|
|
15
|
+
可选的 `analyze` 命令单独要求 Node.js `>=20`,不会改变 `record` 等现有命令的 Node 版本要求。
|
|
15
16
|
|
|
16
17
|
## Install
|
|
17
18
|
|
|
@@ -169,9 +170,65 @@ Codex 输出示例:
|
|
|
169
170
|
{"continue":true,"hookSpecificOutput":{"hookEventName":"PostToolUse"}}
|
|
170
171
|
```
|
|
171
172
|
|
|
172
|
-
**Hermes** 使用 [Shell hooks](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks#shell-hooks),通过 `~/.hermes/config.yaml` 中的 `hooks.post_tool_call` 配置监听 `write_file` 和 `patch` 工具的调用。Hermes Shell hook 不要求特定 stdout 格式,成功/失败信息直接输出 JSON 结果行。
|
|
173
|
-
|
|
174
|
-
### `
|
|
173
|
+
**Hermes** 使用 [Shell hooks](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks#shell-hooks),通过 `~/.hermes/config.yaml` 中的 `hooks.post_tool_call` 配置监听 `write_file` 和 `patch` 工具的调用。Hermes Shell hook 不要求特定 stdout 格式,成功/失败信息直接输出 JSON 结果行。
|
|
174
|
+
|
|
175
|
+
### `analyze`
|
|
176
|
+
|
|
177
|
+
从 Claude Code 的会话 transcript 增量生成 Langfuse 轨迹:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
ronds_ai analyze claude
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
该命令用于 `Stop` 和 `SessionEnd` Hook,不是必选功能,也不会由 `hooks deploy` 自动安装。它会在 Langfuse 中记录:
|
|
184
|
+
|
|
185
|
+
- 每个对话 Turn 的根 Span
|
|
186
|
+
- 每次 Assistant 消息对应的 LLM Generation 和 Token usage
|
|
187
|
+
- Tool 调用的输入、输出和时间
|
|
188
|
+
- Skill 标签及可选的 Skill 注入内容
|
|
189
|
+
- 一层 Subagent 的 Generation 和 Tool 轨迹
|
|
190
|
+
|
|
191
|
+
行为与 Langfuse Claude Observability Plugin 一致,只采集 transcript 中的 `text`、`tool_use` 和 `tool_result`,不上传 `thinking` block。状态和日志保存在 `~/.ronds_ai/analyze/`。
|
|
192
|
+
|
|
193
|
+
Langfuse 连接信息已内置,Trace 的 `userId` 使用当前操作系统用户名。可选配置:
|
|
194
|
+
|
|
195
|
+
- `CC_LANGFUSE_DEBUG=true`:启用详细日志
|
|
196
|
+
- `CC_LANGFUSE_MAX_CHARS=<正整数>`:单个文本字段的最大字符数,默认 `20000`
|
|
197
|
+
- `CC_LANGFUSE_SKILL_TAGS=false`:关闭 `skill:<name>` 标签
|
|
198
|
+
- `CC_LANGFUSE_CAPTURE_SKILL_CONTENT=true`:把 Skill 注入内容写入 Tool 输出
|
|
199
|
+
|
|
200
|
+
用户需要自行把以下条目合并到 Claude Code 的 hooks 配置,并停用其他 Langfuse Claude 插件以避免重复上传:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"hooks": {
|
|
205
|
+
"Stop": [
|
|
206
|
+
{
|
|
207
|
+
"hooks": [
|
|
208
|
+
{
|
|
209
|
+
"type": "command",
|
|
210
|
+
"command": "npx ronds_ai@latest analyze claude"
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
"SessionEnd": [
|
|
216
|
+
{
|
|
217
|
+
"hooks": [
|
|
218
|
+
{
|
|
219
|
+
"type": "command",
|
|
220
|
+
"command": "npx ronds_ai@latest analyze claude"
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Node.js 低于 20 或安装时使用了 `--omit=optional` 时,只有 `analyze` 会返回明确错误;`record` 等其他命令仍可运行。Hook payload 或上传处理失败时,`analyze` 保持 fail-open 并把诊断信息写入本地日志。
|
|
230
|
+
|
|
231
|
+
### `doctor`
|
|
175
232
|
|
|
176
233
|
检查当前目录下的 hook 配置和最近错误日志,方便排查接入问题。
|
|
177
234
|
|
|
@@ -286,6 +343,30 @@ hooks_auto_accept: true
|
|
|
286
343
|
- `removedFiles`
|
|
287
344
|
- `skipped`: 被跳过的工具及原因(仅在 project scope 下出现)
|
|
288
345
|
|
|
346
|
+
### Hooks Auto Sync(自动后台同步)
|
|
347
|
+
|
|
348
|
+
CLI 会**自动在后台检查并更新用户级 hooks 配置**,无需用户每次手动执行 `hooks deploy --scope user`。
|
|
349
|
+
|
|
350
|
+
工作方式:
|
|
351
|
+
|
|
352
|
+
1. 运行任意 CLI 命令时,进行轻量检查(多数情况下只读取一次 sentinel 文件的修改时间)
|
|
353
|
+
2. 如果距离上次检查超过 24 小时,判断 hooks schema 版本是否落后
|
|
354
|
+
3. 版本落后时,后台启动子进程自动执行 `hooks deploy --scope user` 和 `deploy hermes`
|
|
355
|
+
4. 当前命令不等待后台同步完成,也不受后台同步失败的影响
|
|
356
|
+
5. `record` 高频路径使用 sentinel fast path,额外成本约等于一次文件 stat
|
|
357
|
+
|
|
358
|
+
可通过环境变量禁用:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
RONDS_AI_DISABLE_HOOKS_AUTO_SYNC=1
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
注意:
|
|
365
|
+
|
|
366
|
+
- auto-sync 只维护**用户级** hooks,不影响项目级配置
|
|
367
|
+
- 后台同步失败时,错误记录到 `~/.ronds_ai/hooks_auto_sync_error.log`,不污染当前命令输出
|
|
368
|
+
- 首次安装 hooks 仍建议执行 `ronds_ai hooks deploy --scope user`
|
|
369
|
+
|
|
289
370
|
### `skills install`
|
|
290
371
|
|
|
291
372
|
下载一个技能包并安装到 Claude / Codex / Cursor 对应的技能目录。
|
package/bin/ronds_ai.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
} = require('../lib/check_record');
|
|
12
12
|
const { runDoctor } = require('../lib/doctor');
|
|
13
13
|
const { deployHooks, deployHermesHook } = require('../lib/hooks_deploy');
|
|
14
|
+
const { maybeStartHooksAutoSync, runHooksAutoSync } = require('../lib/hooks_auto_sync');
|
|
14
15
|
const { promptForText, promptYesNo } = require('../lib/skills_prompt');
|
|
15
16
|
|
|
16
17
|
function writeWorkerSummary(result) {
|
|
@@ -61,9 +62,10 @@ const SUPPORTED_SOURCES = new Set(['claude', 'cursor', 'codex', 'hermes']);
|
|
|
61
62
|
|
|
62
63
|
function printUsage() {
|
|
63
64
|
process.stderr.write([
|
|
64
|
-
'Usage:',
|
|
65
|
-
' ronds_ai record <tool>',
|
|
66
|
-
' ronds_ai
|
|
65
|
+
'Usage:',
|
|
66
|
+
' ronds_ai record <tool>',
|
|
67
|
+
' ronds_ai analyze claude',
|
|
68
|
+
' ronds_ai check record',
|
|
67
69
|
' ronds_ai doctor <tool>',
|
|
68
70
|
' ronds_ai hooks deploy [--scope project|user] [--tool cursor|claude|codex]',
|
|
69
71
|
' ronds_ai hooks deploy hermes',
|
|
@@ -76,7 +78,8 @@ function printUsage() {
|
|
|
76
78
|
' hermes',
|
|
77
79
|
'',
|
|
78
80
|
'Examples:',
|
|
79
|
-
' npx ronds_ai@latest record claude',
|
|
81
|
+
' npx ronds_ai@latest record claude',
|
|
82
|
+
' npx ronds_ai@latest analyze claude',
|
|
80
83
|
' npx ronds_ai@latest record cursor',
|
|
81
84
|
' npx ronds_ai@latest check record',
|
|
82
85
|
' npx ronds_ai@latest doctor claude',
|
|
@@ -260,10 +263,26 @@ async function runSkillsCommand(args) {
|
|
|
260
263
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 内部命令分发。当前只支持 hooks auto-sync。
|
|
268
|
+
*/
|
|
269
|
+
async function runInternalCommand(args) {
|
|
270
|
+
const [subcommand, ...subargs] = args;
|
|
271
|
+
|
|
272
|
+
if (subcommand === 'hooks' && subargs[0] === 'auto-sync') {
|
|
273
|
+
runHooksAutoSync();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw new Error('Unsupported internal command');
|
|
278
|
+
}
|
|
279
|
+
|
|
263
280
|
async function run() {
|
|
264
281
|
const [, , command, ...args] = process.argv;
|
|
265
282
|
|
|
266
|
-
|
|
283
|
+
maybeStartHooksAutoSync({ command, args });
|
|
284
|
+
|
|
285
|
+
if (command === 'record') {
|
|
267
286
|
const [source] = args;
|
|
268
287
|
const normalizedSource = String(source || '').trim().toLowerCase();
|
|
269
288
|
|
|
@@ -272,9 +291,21 @@ async function run() {
|
|
|
272
291
|
}
|
|
273
292
|
|
|
274
293
|
await runCodeRecord(normalizedSource);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
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
|
+
|
|
278
309
|
if (command === 'doctor') {
|
|
279
310
|
const [tool] = args;
|
|
280
311
|
const normalizedTool = String(tool || '').trim().toLowerCase();
|
|
@@ -303,11 +334,22 @@ async function run() {
|
|
|
303
334
|
return;
|
|
304
335
|
}
|
|
305
336
|
|
|
337
|
+
if (command === 'internal') {
|
|
338
|
+
await runInternalCommand(args);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
306
342
|
throw new Error(`Unsupported command: ${command || ''}`);
|
|
307
343
|
}
|
|
308
344
|
|
|
309
|
-
run().catch((error) => {
|
|
310
|
-
|
|
345
|
+
run().catch((error) => {
|
|
346
|
+
if (error && error.code === 'ANALYZE_PREFLIGHT') {
|
|
347
|
+
process.stderr.write(`${error.message}\n`);
|
|
348
|
+
process.exitCode = 1;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const [, , command, ...args] = process.argv;
|
|
311
353
|
let savedPath = '';
|
|
312
354
|
|
|
313
355
|
try {
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getAnalyzeConfig } = require('./analyze_config');
|
|
6
|
+
const { emitTurn } = require('./analyze_langfuse');
|
|
7
|
+
const {
|
|
8
|
+
createSessionState,
|
|
9
|
+
getNewTurnsFromTranscript,
|
|
10
|
+
getSubagentTranscriptsByToolUseId,
|
|
11
|
+
getTurnsToEmit,
|
|
12
|
+
} = require('./analyze_transcript');
|
|
13
|
+
|
|
14
|
+
const ANALYZE_DIR = path.join(os.homedir(), '.ronds_ai', 'analyze');
|
|
15
|
+
const STATE_FILE = path.join(ANALYZE_DIR, 'langfuse_state.json');
|
|
16
|
+
const LOCK_FILE = path.join(ANALYZE_DIR, 'langfuse_state.lock');
|
|
17
|
+
const LOG_FILE = path.join(ANALYZE_DIR, 'langfuse_hook.log');
|
|
18
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
19
|
+
const STALE_LOCK_MS = 30000;
|
|
20
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
21
|
+
const STATE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const MAX_LOG_BYTES = 5_000_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 表示 analyze 启动前即可确定的环境错误。
|
|
26
|
+
*/
|
|
27
|
+
class AnalyzePreflightError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'AnalyzePreflightError';
|
|
31
|
+
this.code = 'ANALYZE_PREFLIGHT';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 检查 analyze 所需的 Node.js 主版本。
|
|
37
|
+
*/
|
|
38
|
+
function assertAnalyzeNodeVersion(version = process.versions.node) {
|
|
39
|
+
const major = Number(String(version || '').split('.')[0]);
|
|
40
|
+
if (!Number.isInteger(major) || major < 20) {
|
|
41
|
+
throw new AnalyzePreflightError(
|
|
42
|
+
`ronds_ai analyze requires Node.js 20 or newer; current version is ${version || 'unknown'}.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 懒加载 analyze 专用的可选 Langfuse 依赖。
|
|
49
|
+
*/
|
|
50
|
+
function loadLangfuseSdk() {
|
|
51
|
+
try {
|
|
52
|
+
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
|
53
|
+
const { LangfuseSpanProcessor } = require('@langfuse/otel');
|
|
54
|
+
const tracing = require('@langfuse/tracing');
|
|
55
|
+
return {
|
|
56
|
+
NodeSDK,
|
|
57
|
+
LangfuseSpanProcessor,
|
|
58
|
+
startObservation: tracing.startObservation,
|
|
59
|
+
propagateAttributes: tracing.propagateAttributes,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
throw new AnalyzePreflightError(
|
|
63
|
+
'ronds_ai analyze dependencies are unavailable. Reinstall without omitting optional dependencies.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 从 stdin 读取 Claude Code Hook JSON。
|
|
70
|
+
*/
|
|
71
|
+
function readStdin() {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
let data = '';
|
|
74
|
+
process.stdin.setEncoding('utf8');
|
|
75
|
+
process.stdin.on('data', (chunk) => {
|
|
76
|
+
data += chunk;
|
|
77
|
+
});
|
|
78
|
+
process.stdin.on('end', () => resolve(data));
|
|
79
|
+
process.stdin.on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 从 Hook payload 中解析 session 和 transcript。
|
|
85
|
+
*/
|
|
86
|
+
function extractHookContext(payload) {
|
|
87
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const sessionId = payload.sessionId
|
|
91
|
+
|| payload.session_id
|
|
92
|
+
|| (payload.session && payload.session.id);
|
|
93
|
+
const transcriptPath = payload.transcriptPath
|
|
94
|
+
|| payload.transcript_path
|
|
95
|
+
|| (payload.transcript && payload.transcript.path);
|
|
96
|
+
if (typeof sessionId !== 'string' || !sessionId
|
|
97
|
+
|| typeof transcriptPath !== 'string' || !transcriptPath) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const resolvedPath = path.resolve(transcriptPath);
|
|
101
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const hookEventName = payload.hook_event_name || payload.hookEventName || '';
|
|
105
|
+
return {
|
|
106
|
+
sessionId,
|
|
107
|
+
transcriptPath: resolvedPath,
|
|
108
|
+
flushDeferredAgentTurns: hookEventName === 'SessionEnd',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 构造稳定的会话状态键。
|
|
114
|
+
*/
|
|
115
|
+
function getSessionStateKey(sessionId, transcriptPath) {
|
|
116
|
+
return crypto
|
|
117
|
+
.createHash('sha256')
|
|
118
|
+
.update(`${sessionId}::${transcriptPath}`, 'utf8')
|
|
119
|
+
.digest('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 轮转超出上限的 analyze 日志。
|
|
124
|
+
*/
|
|
125
|
+
function rotateLogIfNeeded() {
|
|
126
|
+
if (!fs.existsSync(LOG_FILE) || fs.statSync(LOG_FILE).size < MAX_LOG_BYTES) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (let index = 3; index >= 1; index -= 1) {
|
|
130
|
+
const source = index === 1 ? LOG_FILE : `${LOG_FILE}.${index - 1}`;
|
|
131
|
+
const target = `${LOG_FILE}.${index}`;
|
|
132
|
+
if (!fs.existsSync(source)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (fs.existsSync(target)) {
|
|
136
|
+
fs.unlinkSync(target);
|
|
137
|
+
}
|
|
138
|
+
fs.renameSync(source, target);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 从日志文本中移除固定连接凭据。
|
|
144
|
+
*/
|
|
145
|
+
function redactLogMessage(message, config) {
|
|
146
|
+
let result = String(message || '');
|
|
147
|
+
for (const value of [config.secretKey, config.publicKey]) {
|
|
148
|
+
if (typeof value === 'string' && value) {
|
|
149
|
+
result = result.split(value).join('[REDACTED]');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 写入不影响 Hook 的本地诊断日志。
|
|
157
|
+
*/
|
|
158
|
+
function writeAnalyzeLog(level, message, config) {
|
|
159
|
+
try {
|
|
160
|
+
fs.mkdirSync(ANALYZE_DIR, { recursive: true });
|
|
161
|
+
rotateLogIfNeeded();
|
|
162
|
+
const safeMessage = redactLogMessage(message, config);
|
|
163
|
+
fs.appendFileSync(
|
|
164
|
+
LOG_FILE,
|
|
165
|
+
`${new Date().toISOString()} [${level}] ${safeMessage}\n`,
|
|
166
|
+
'utf8',
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
// 日志失败不能影响 Claude Code Hook。
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 仅在调试选项开启时写入详细日志。
|
|
175
|
+
*/
|
|
176
|
+
function writeAnalyzeDebug(message, config) {
|
|
177
|
+
if (config.debug) {
|
|
178
|
+
writeAnalyzeLog('DEBUG', message, config);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 加载全局 analyze 状态。
|
|
184
|
+
*/
|
|
185
|
+
function loadState() {
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
188
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
189
|
+
? parsed
|
|
190
|
+
: {};
|
|
191
|
+
} catch {
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 清理过期状态并原子写入状态文件。
|
|
198
|
+
*/
|
|
199
|
+
function saveState(state) {
|
|
200
|
+
const cutoff = Date.now() - STATE_RETENTION_MS;
|
|
201
|
+
for (const [key, value] of Object.entries(state)) {
|
|
202
|
+
const updatedAt = value && Date.parse(value.updatedAt || value.updated || '');
|
|
203
|
+
if (Number.isFinite(updatedAt) && updatedAt < cutoff) {
|
|
204
|
+
delete state[key];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
fs.mkdirSync(ANALYZE_DIR, { recursive: true });
|
|
208
|
+
const tempPath = `${STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
209
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
210
|
+
fs.renameSync(tempPath, STATE_FILE);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 将会话状态转换为可序列化结构。
|
|
215
|
+
*/
|
|
216
|
+
function serializeSessionState(sessionState) {
|
|
217
|
+
return {
|
|
218
|
+
offset: sessionState.offset,
|
|
219
|
+
buffer: sessionState.buffer,
|
|
220
|
+
turnCount: sessionState.turnCount,
|
|
221
|
+
pendingAgentTurns: sessionState.pendingAgentTurns,
|
|
222
|
+
pendingTaskNotifications: sessionState.pendingTaskNotifications,
|
|
223
|
+
updatedAt: new Date().toISOString(),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 等待指定毫秒数,计时器不阻止进程退出。
|
|
229
|
+
*/
|
|
230
|
+
function delay(milliseconds) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
233
|
+
timer.unref();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 判断已有锁是否超过安全保留时间。
|
|
239
|
+
*/
|
|
240
|
+
function isLockStale() {
|
|
241
|
+
try {
|
|
242
|
+
return Date.now() - fs.statSync(LOCK_FILE).mtimeMs > STALE_LOCK_MS;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 使用 wx 文件创建跨平台独占锁。
|
|
250
|
+
*/
|
|
251
|
+
async function acquireLock() {
|
|
252
|
+
fs.mkdirSync(ANALYZE_DIR, { recursive: true });
|
|
253
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
254
|
+
while (Date.now() <= deadline) {
|
|
255
|
+
try {
|
|
256
|
+
const descriptor = fs.openSync(LOCK_FILE, 'wx');
|
|
257
|
+
fs.writeFileSync(descriptor, `${process.pid}\n`, 'utf8');
|
|
258
|
+
return descriptor;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (!error || error.code !== 'EEXIST') {
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (isLockStale()) {
|
|
264
|
+
try {
|
|
265
|
+
fs.unlinkSync(LOCK_FILE);
|
|
266
|
+
continue;
|
|
267
|
+
} catch {
|
|
268
|
+
// 其他进程可能刚刚释放或替换了锁,继续重试。
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await delay(50);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 释放当前进程持有的 analyze 锁。
|
|
279
|
+
*/
|
|
280
|
+
function releaseLock(descriptor) {
|
|
281
|
+
if (descriptor == null) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
fs.closeSync(descriptor);
|
|
286
|
+
} catch {
|
|
287
|
+
// 继续尝试移除锁文件。
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(LOCK_FILE);
|
|
291
|
+
} catch {
|
|
292
|
+
// 锁文件可能已被系统或其他恢复逻辑清理。
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 为异步操作增加最大等待时间。
|
|
298
|
+
*/
|
|
299
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const timer = setTimeout(() => {
|
|
302
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
303
|
+
}, timeoutMs);
|
|
304
|
+
timer.unref();
|
|
305
|
+
Promise.resolve(promise).then(
|
|
306
|
+
(value) => {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
resolve(value);
|
|
309
|
+
},
|
|
310
|
+
(error) => {
|
|
311
|
+
clearTimeout(timer);
|
|
312
|
+
reject(error);
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 初始化短生命周期 Langfuse OpenTelemetry SDK。
|
|
320
|
+
*/
|
|
321
|
+
function createLangfuseRuntime(sdkModules, config) {
|
|
322
|
+
const processor = new sdkModules.LangfuseSpanProcessor({
|
|
323
|
+
publicKey: config.publicKey,
|
|
324
|
+
secretKey: config.secretKey,
|
|
325
|
+
baseUrl: config.baseUrl,
|
|
326
|
+
exportMode: 'immediate',
|
|
327
|
+
timeout: 5,
|
|
328
|
+
});
|
|
329
|
+
const sdk = new sdkModules.NodeSDK({
|
|
330
|
+
spanProcessors: [processor],
|
|
331
|
+
});
|
|
332
|
+
sdk.start();
|
|
333
|
+
return {
|
|
334
|
+
sdk,
|
|
335
|
+
api: {
|
|
336
|
+
startObservation: sdkModules.startObservation,
|
|
337
|
+
propagateAttributes: sdkModules.propagateAttributes,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 在持锁状态下处理一次 Hook payload。
|
|
344
|
+
*/
|
|
345
|
+
async function processHookContext(context, config, sdkModules) {
|
|
346
|
+
const lock = await acquireLock();
|
|
347
|
+
if (lock == null) {
|
|
348
|
+
writeAnalyzeLog('INFO', 'analyze lock timeout; hook skipped', config);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const state = loadState();
|
|
354
|
+
const stateKey = getSessionStateKey(context.sessionId, context.transcriptPath);
|
|
355
|
+
const sessionState = createSessionState(state[stateKey]);
|
|
356
|
+
const subagents = getSubagentTranscriptsByToolUseId(context.transcriptPath);
|
|
357
|
+
writeAnalyzeDebug(
|
|
358
|
+
`discovered ${Object.keys(subagents).length} subagent transcript(s)`,
|
|
359
|
+
config,
|
|
360
|
+
);
|
|
361
|
+
const parsed = getNewTurnsFromTranscript(
|
|
362
|
+
context.transcriptPath,
|
|
363
|
+
sessionState,
|
|
364
|
+
subagents,
|
|
365
|
+
context.flushDeferredAgentTurns,
|
|
366
|
+
);
|
|
367
|
+
const turns = getTurnsToEmit(
|
|
368
|
+
parsed.turns,
|
|
369
|
+
parsed.sessionState,
|
|
370
|
+
context.flushDeferredAgentTurns,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (turns.length === 0) {
|
|
374
|
+
state[stateKey] = serializeSessionState(parsed.sessionState);
|
|
375
|
+
saveState(state);
|
|
376
|
+
writeAnalyzeDebug(
|
|
377
|
+
`no new turn for session ${context.sessionId}`,
|
|
378
|
+
config,
|
|
379
|
+
);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const runtime = createLangfuseRuntime(sdkModules, config);
|
|
384
|
+
try {
|
|
385
|
+
for (let index = 0; index < turns.length; index += 1) {
|
|
386
|
+
emitTurn(
|
|
387
|
+
runtime.api,
|
|
388
|
+
context.sessionId,
|
|
389
|
+
parsed.sessionState.turnCount + index + 1,
|
|
390
|
+
turns[index],
|
|
391
|
+
context.transcriptPath,
|
|
392
|
+
subagents,
|
|
393
|
+
config,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
await withTimeout(
|
|
397
|
+
runtime.sdk.shutdown(),
|
|
398
|
+
SHUTDOWN_TIMEOUT_MS,
|
|
399
|
+
'Langfuse shutdown',
|
|
400
|
+
);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
try {
|
|
403
|
+
await withTimeout(runtime.sdk.shutdown(), 1000, 'Langfuse cleanup');
|
|
404
|
+
} catch {
|
|
405
|
+
// 原始错误优先,清理错误不覆盖它。
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
parsed.sessionState.turnCount += turns.length;
|
|
411
|
+
state[stateKey] = serializeSessionState(parsed.sessionState);
|
|
412
|
+
saveState(state);
|
|
413
|
+
writeAnalyzeLog(
|
|
414
|
+
'INFO',
|
|
415
|
+
`processed ${turns.length} turn(s) for session ${context.sessionId}`,
|
|
416
|
+
config,
|
|
417
|
+
);
|
|
418
|
+
} finally {
|
|
419
|
+
releaseLock(lock);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 执行 Claude Code analyze Hook 命令。
|
|
425
|
+
*/
|
|
426
|
+
async function runClaudeAnalyze() {
|
|
427
|
+
assertAnalyzeNodeVersion();
|
|
428
|
+
const sdkModules = loadLangfuseSdk();
|
|
429
|
+
const config = getAnalyzeConfig();
|
|
430
|
+
writeAnalyzeDebug('analyze hook started', config);
|
|
431
|
+
const raw = await readStdin();
|
|
432
|
+
writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
|
|
433
|
+
if (!raw.trim()) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let payload;
|
|
438
|
+
try {
|
|
439
|
+
payload = JSON.parse(raw);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
writeAnalyzeLog('INFO', `invalid hook payload: ${error.message}`, config);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
writeAnalyzeDebug(
|
|
445
|
+
`payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
|
|
446
|
+
config,
|
|
447
|
+
);
|
|
448
|
+
const context = extractHookContext(payload);
|
|
449
|
+
if (!context) {
|
|
450
|
+
writeAnalyzeLog('INFO', 'hook payload has no usable session or transcript', config);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
await processHookContext(context, config, sdkModules);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const detail = error instanceof Error ? error.stack || error.message : String(error);
|
|
458
|
+
writeAnalyzeLog('INFO', `analyze hook failed: ${detail}`, config);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
ANALYZE_DIR,
|
|
464
|
+
AnalyzePreflightError,
|
|
465
|
+
assertAnalyzeNodeVersion,
|
|
466
|
+
extractHookContext,
|
|
467
|
+
getSessionStateKey,
|
|
468
|
+
processHookContext,
|
|
469
|
+
redactLogMessage,
|
|
470
|
+
runClaudeAnalyze,
|
|
471
|
+
};
|