ronds_ai 0.1.21 → 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 +65 -8
- package/bin/ronds_ai.js +30 -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/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
|
|
package/bin/ronds_ai.js
CHANGED
|
@@ -62,9 +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
|
|
65
|
+
'Usage:',
|
|
66
|
+
' ronds_ai record <tool>',
|
|
67
|
+
' ronds_ai analyze claude',
|
|
68
|
+
' ronds_ai check record',
|
|
68
69
|
' ronds_ai doctor <tool>',
|
|
69
70
|
' ronds_ai hooks deploy [--scope project|user] [--tool cursor|claude|codex]',
|
|
70
71
|
' ronds_ai hooks deploy hermes',
|
|
@@ -77,7 +78,8 @@ function printUsage() {
|
|
|
77
78
|
' hermes',
|
|
78
79
|
'',
|
|
79
80
|
'Examples:',
|
|
80
|
-
' npx ronds_ai@latest record claude',
|
|
81
|
+
' npx ronds_ai@latest record claude',
|
|
82
|
+
' npx ronds_ai@latest analyze claude',
|
|
81
83
|
' npx ronds_ai@latest record cursor',
|
|
82
84
|
' npx ronds_ai@latest check record',
|
|
83
85
|
' npx ronds_ai@latest doctor claude',
|
|
@@ -280,7 +282,7 @@ async function run() {
|
|
|
280
282
|
|
|
281
283
|
maybeStartHooksAutoSync({ command, args });
|
|
282
284
|
|
|
283
|
-
if (command === 'record') {
|
|
285
|
+
if (command === 'record') {
|
|
284
286
|
const [source] = args;
|
|
285
287
|
const normalizedSource = String(source || '').trim().toLowerCase();
|
|
286
288
|
|
|
@@ -289,9 +291,21 @@ async function run() {
|
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
await runCodeRecord(normalizedSource);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
|
|
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
|
+
|
|
295
309
|
if (command === 'doctor') {
|
|
296
310
|
const [tool] = args;
|
|
297
311
|
const normalizedTool = String(tool || '').trim().toLowerCase();
|
|
@@ -328,8 +342,14 @@ async function run() {
|
|
|
328
342
|
throw new Error(`Unsupported command: ${command || ''}`);
|
|
329
343
|
}
|
|
330
344
|
|
|
331
|
-
run().catch((error) => {
|
|
332
|
-
|
|
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;
|
|
333
353
|
let savedPath = '';
|
|
334
354
|
|
|
335
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
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
|
|
3
|
+
// Langfuse 连接信息按产品要求从当前 Claude 配置写入源码。
|
|
4
|
+
const LANGFUSE_PUBLIC_KEY = 'pk-lf-default-001';
|
|
5
|
+
const LANGFUSE_SECRET_KEY = 'sk-lf-default-001';
|
|
6
|
+
const LANGFUSE_BASE_URL = 'http://192.168.3.25:3002';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 按原插件优先级读取用户选项。
|
|
10
|
+
*/
|
|
11
|
+
function readOption(name) {
|
|
12
|
+
return process.env[`CLAUDE_PLUGIN_OPTION_${name}`] || process.env[name] || '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 读取布尔型采集选项。
|
|
17
|
+
*/
|
|
18
|
+
function readBooleanOption(name, defaultValue) {
|
|
19
|
+
const raw = readOption(name);
|
|
20
|
+
if (!raw) {
|
|
21
|
+
return defaultValue;
|
|
22
|
+
}
|
|
23
|
+
return String(raw).toLowerCase() === 'true';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 读取正整数型采集选项。
|
|
28
|
+
*/
|
|
29
|
+
function readPositiveIntegerOption(name, defaultValue) {
|
|
30
|
+
const parsed = Number(readOption(name));
|
|
31
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 获取当前操作系统用户名,并提供跨平台回退。
|
|
36
|
+
*/
|
|
37
|
+
function resolveSystemUsername() {
|
|
38
|
+
try {
|
|
39
|
+
const username = os.userInfo().username;
|
|
40
|
+
if (typeof username === 'string' && username.trim()) {
|
|
41
|
+
return username.trim();
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// 继续使用环境变量回退。
|
|
45
|
+
}
|
|
46
|
+
return process.env.USERNAME || process.env.USER || 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 返回 analyze 命令使用的固定 Langfuse 配置。
|
|
51
|
+
*/
|
|
52
|
+
function getAnalyzeConfig() {
|
|
53
|
+
return {
|
|
54
|
+
publicKey: LANGFUSE_PUBLIC_KEY,
|
|
55
|
+
secretKey: LANGFUSE_SECRET_KEY,
|
|
56
|
+
baseUrl: LANGFUSE_BASE_URL,
|
|
57
|
+
userId: resolveSystemUsername(),
|
|
58
|
+
debug: readBooleanOption('CC_LANGFUSE_DEBUG', false),
|
|
59
|
+
maxChars: readPositiveIntegerOption('CC_LANGFUSE_MAX_CHARS', 20000),
|
|
60
|
+
skillTags: readBooleanOption('CC_LANGFUSE_SKILL_TAGS', true),
|
|
61
|
+
captureSkillContent: readBooleanOption('CC_LANGFUSE_CAPTURE_SKILL_CONTENT', false),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
getAnalyzeConfig,
|
|
67
|
+
resolveSystemUsername,
|
|
68
|
+
};
|