ronds_ai 0.1.21 → 0.1.23

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 CHANGED
@@ -12,6 +12,7 @@
12
12
  - `unzip`
13
13
 
14
14
  `git` 主要用于读取仓库信息和用户邮箱;`unzip` 用于 `skills install` 解压技能包。
15
+ 可选的 `analyze` 命令单独要求 Node.js `>=20`,不会改变 `record` 等现有命令的 Node 版本要求。
15
16
 
16
17
  ## Install
17
18
 
@@ -171,6 +172,62 @@ Codex 输出示例:
171
172
 
172
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 结果行。
173
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` 自动安装(project 或 user scope,未用 `--tool` 排除 claude 时)。它会在 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
+ `hooks deploy` 会自动把以下条目写入 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
+
174
231
  ### `doctor`
175
232
 
176
233
  检查当前目录下的 hook 配置和最近错误日志,方便排查接入问题。
@@ -240,14 +297,14 @@ npx ronds_ai@latest hooks deploy --scope user
240
297
  默认 scope 为 `project`(项目级部署),行为:
241
298
 
242
299
  - 写入或更新 `.cursor/hooks.json`
243
- - 写入或更新 `.claude/settings.json`
300
+ - 写入或更新 `.claude/settings.json`(`PostToolUse` 的 `record claude` 采集 hook,以及 `Stop`、`SessionEnd` 的 `analyze claude` Langfuse 轨迹 hook;Node 低于 20 时跳过 analyze 并在输出 `analyze` 字段说明原因)
244
301
  - 清理项目内旧版 Cursor、Claude、Codex hook 脚本
245
302
  - 跳过 Codex 项目级配置部署,并在输出中说明原因
246
303
 
247
304
  `--scope user`(用户级部署),行为:
248
305
 
249
306
  - 写入或更新用户级 Cursor hook 配置
250
- - 写入或更新用户级 Claude hook 配置
307
+ - 写入或更新用户级 Claude hook 配置(同上:`record claude` 采集 hook + `analyze claude` 轨迹 hook)
251
308
  - 写入或更新系统级 `requirements.toml`(Windows: `C:\ProgramData\OpenAI\Codex\requirements.toml`,Linux / macOS: `/etc/codex/requirements.toml`),包含 `[features] hooks = true`、`[hooks]` 托管目录配置以及 `PostToolUse` 的 `apply_patch` hook
252
309
  - 清理用户目录中的旧版 hook 脚本文件
253
310
 
@@ -285,6 +342,7 @@ hooks_auto_accept: true
285
342
  - `updatedFiles`
286
343
  - `removedFiles`
287
344
  - `skipped`: 被跳过的工具及原因(仅在 project scope 下出现)
345
+ - `analyze`: Claude Langfuse 轨迹 hook 的安装状态(`installed`、`events`、`command`;未安装时包含 `reason`)
288
346
 
289
347
  ### Hooks Auto Sync(自动后台同步)
290
348
 
@@ -307,6 +365,7 @@ RONDS_AI_DISABLE_HOOKS_AUTO_SYNC=1
307
365
  注意:
308
366
 
309
367
  - auto-sync 只维护**用户级** hooks,不影响项目级配置
368
+ - auto-sync 会同步维护用户级 Claude 的 `analyze claude` Langfuse 轨迹 hook
310
369
  - 后台同步失败时,错误记录到 `~/.ronds_ai/hooks_auto_sync_error.log`,不污染当前命令输出
311
370
  - 首次安装 hooks 仍建议执行 `ronds_ai hooks deploy --scope user`
312
371
 
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 check record',
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
- const [, , command, ...args] = process.argv;
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
+ };