oh-langfuse 0.1.31 → 0.1.33

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  `oh-langfuse` 是用于给 Claude Code、OpenCode 和 Codex 配置 Langfuse 追踪的命令行工具。它提供交互式安装向导,也支持 `setup` / `check` 直接命令,方便在用户机器上安装、修复和校验配置。
4
4
 
5
- 当前 npm 版本:`0.1.25`
5
+ 当前 npm 版本:`0.1.33`
6
6
 
7
7
  ## 能做什么
8
8
 
@@ -34,6 +34,42 @@ npx oh-langfuse@latest check opencode
34
34
  npx oh-langfuse@latest check codex
35
35
  ```
36
36
 
37
+ ## 自动更新
38
+
39
+ 安装或更新 runtime 后,工具会在 `~/.config/oh-langfuse/runtime.json` 记录 Claude Code / OpenCode / Codex 当前写入本机 runtime 的 `oh-langfuse` 版本。
40
+
41
+ OpenCode 生成的 `launch-opencode-langfuse.*` 会在启动 agent 前执行一次更新检测:如果 npm 上的 `oh-langfuse@latest` 高于本机 runtime 版本,会提示是否更新;用户确认后才会运行 `npx oh-langfuse@latest update opencode`。Claude Code 和 Codex 也会生成对应 launcher:
42
+
43
+ - `~/.claude/launch-claude-langfuse.cmd` / `~/.claude/launch-claude-langfuse.sh`
44
+ - `~/.codex/launch-codex-langfuse.cmd` / `~/.codex/launch-codex-langfuse.sh`
45
+
46
+ 如果直接运行系统原始的 `claude` / `codex` 命令,工具不会强行替换该命令;需要使用上述 launcher 才能做到启动前检测并提示更新。也可以手动运行:
47
+
48
+ ```bash
49
+ npx oh-langfuse@latest auto-update opencode
50
+ npx oh-langfuse@latest auto-update claude
51
+ npx oh-langfuse@latest auto-update codex
52
+ ```
53
+
54
+ ## Skill 使用量统计
55
+
56
+ 工具会为每次识别到的 skill 使用额外写入一条 observation:
57
+
58
+ ```text
59
+ Skill Use / <skill_name>
60
+ ```
61
+
62
+ Dashboard 中统计各 skill 使用量可配置:
63
+
64
+ ```text
65
+ View: Observations
66
+ Metric: Count
67
+ Filter: Observation Name contains Skill Use /
68
+ Breakdown Dimension: Observation Name
69
+ ```
70
+
71
+ `AI Interaction` 仍然保留 `skill_use_count`、`skill_names`、`skill_names_json` 和 `skill_names_csv`,用于查看单次交互的效率汇总。为避免极端情况下产生过多 observation,OpenCode 每次交互最多记录 20 个去重后的 skill 使用事件。
72
+
37
73
  本地开发运行:
38
74
 
39
75
  ```bash
package/bin/cli.js CHANGED
@@ -796,7 +796,8 @@ function printHelp() {
796
796
  "oh-langfuse update all",
797
797
  "oh-langfuse update claude",
798
798
  "oh-langfuse update opencode",
799
- "oh-langfuse update codex"
799
+ "oh-langfuse update codex",
800
+ "oh-langfuse auto-update opencode"
800
801
  ]);
801
802
  renderSection("Options", [
802
803
  `${paint("--dry-run", t.gold)} Preview actions without writing files or installing packages.`,
@@ -856,6 +857,15 @@ async function main() {
856
857
  ];
857
858
  return runNodeScript("update-langfuse-runtime.mjs", updateArgs, options);
858
859
  }
860
+ if (cmd === "auto-update") {
861
+ return runNodeScript("auto-update-runtime.mjs", [
862
+ target || "all",
863
+ ...(hasValue(options.npmRegistry) ? [`--npmRegistry=${options.npmRegistry}`] : []),
864
+ ...(hasValue(options.pipIndexUrl) ? [`--pipIndexUrl=${options.pipIndexUrl}`] : []),
865
+ ...(options.skipCheck ? ["--skip-check"] : []),
866
+ ...(options.yes ? ["--yes"] : []),
867
+ ], options);
868
+ }
859
869
  if (cmd === "check" && target === "claude") return checkClaude(options);
860
870
  if (cmd === "check" && target === "opencode") return checkOpenCode(options);
861
871
  if (cmd === "check" && target === "codex") return checkCodex(options);
@@ -655,13 +655,14 @@ def emit_codex_turn(
655
655
 
656
656
  for skill in skill_usages:
657
657
  with langfuse.start_as_current_observation(
658
- name=f"Skill Use: {skill['name']}",
658
+ name=f"Skill Use / {skill['name']}",
659
659
  metadata={
660
660
  "source": "codex",
661
661
  "user_id": user_id or "",
662
662
  "session_id": session_id,
663
663
  "interaction_id": interaction_meta["interaction_id"],
664
664
  "skill_name": skill["name"],
665
+ "skill_use_count": 1,
665
666
  "skill_namespace": skill["skill_namespace"],
666
667
  "detected_by": skill["detected_by"],
667
668
  "turn_number": turn_num,
package/langfuse_hook.py CHANGED
@@ -689,13 +689,14 @@ def emit_turn(
689
689
 
690
690
  for skill in skill_usages:
691
691
  with langfuse.start_as_current_observation(
692
- name=f"Skill Use: {skill['name']}",
692
+ name=f"Skill Use / {skill['name']}",
693
693
  metadata={
694
694
  "source": "claude",
695
695
  "user_id": user_id or "",
696
696
  "session_id": session_id,
697
697
  "interaction_id": interaction_meta["interaction_id"],
698
698
  "skill_name": skill["name"],
699
+ "skill_use_count": 1,
699
700
  "skill_namespace": skill["skill_namespace"],
700
701
  "detected_by": skill["detected_by"],
701
702
  "turn_number": turn_num,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -12,7 +12,8 @@
12
12
  "code-tool-langfuse": "bin/cli.js"
13
13
  },
14
14
  "files": [
15
- "bin",
15
+ "bin",
16
+ "scripts/auto-update-runtime.mjs",
16
17
  "scripts/codex-langfuse-check.mjs",
17
18
  "scripts/codex-langfuse-setup.mjs",
18
19
  "scripts/json-utils.mjs",
@@ -25,6 +26,7 @@
25
26
  "scripts/real-self-verify.mjs",
26
27
  "scripts/log-filter-utils.mjs",
27
28
  "scripts/metrics-utils.mjs",
29
+ "scripts/runtime-state-utils.mjs",
28
30
  "scripts/update-langfuse-runtime.mjs",
29
31
  "scripts/update-utils.mjs",
30
32
  "langfuse_hook.py",
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { extractVersionFromNpmMetadata, isNewerVersion } from "./update-utils.mjs";
7
+ import { getRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
+
9
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
11
+ const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
12
+
13
+ function parseArgs(argv) {
14
+ const args = { _: [] };
15
+ for (const raw of argv) {
16
+ if (!raw.startsWith("--")) {
17
+ args._.push(raw);
18
+ continue;
19
+ }
20
+ const eq = raw.indexOf("=");
21
+ if (eq === -1) args[raw.slice(2)] = true;
22
+ else args[raw.slice(2, eq)] = raw.slice(eq + 1);
23
+ }
24
+ return args;
25
+ }
26
+
27
+ async function latestVersion(registry = "https://registry.npmjs.org") {
28
+ const base = registry.replace(/\/+$/, "");
29
+ const response = await fetch(`${base}/oh-langfuse`, { headers: { accept: "application/json" } });
30
+ if (!response.ok) throw new Error(`npm registry ${response.status} ${response.statusText}`);
31
+ return extractVersionFromNpmMetadata(await response.json());
32
+ }
33
+
34
+ function question(text) {
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
36
+ return new Promise((resolve) => {
37
+ rl.question(text, (answer) => {
38
+ rl.close();
39
+ resolve(String(answer || "").trim());
40
+ });
41
+ });
42
+ }
43
+
44
+ function npxCommand() {
45
+ return process.platform === "win32" ? "npx.cmd" : "npx";
46
+ }
47
+
48
+ function runUpdate(target, args) {
49
+ const updateArgs = ["-y", "oh-langfuse@latest", "update", target];
50
+ if (args["skip-check"]) updateArgs.push("--skip-check");
51
+ if (args.npmRegistry) updateArgs.push(`--npmRegistry=${args.npmRegistry}`);
52
+ if (args.pipIndexUrl) updateArgs.push(`--pipIndexUrl=${args.pipIndexUrl}`);
53
+ const command = npxCommand();
54
+ const result = spawnSync(command, updateArgs, {
55
+ stdio: "inherit",
56
+ shell: process.platform === "win32",
57
+ windowsHide: true,
58
+ timeout: 900000,
59
+ });
60
+ return result.status ?? (result.error ? 1 : 0);
61
+ }
62
+
63
+ async function main() {
64
+ const args = parseArgs(process.argv.slice(2));
65
+ if (/^(0|false|no|off)$/i.test(String(process.env.OH_LANGFUSE_AUTO_UPDATE || ""))) return 0;
66
+
67
+ const target = String(args._[0] || "").trim().toLowerCase();
68
+ if (!ALLOWED_TARGETS.has(target)) {
69
+ throw new Error("Usage: oh-langfuse auto-update <claude|opencode|codex>");
70
+ }
71
+
72
+ let latest;
73
+ try {
74
+ latest = await latestVersion(args.npmRegistry);
75
+ } catch (error) {
76
+ if (args.verbose) console.log(`[INFO] oh-langfuse update check skipped: ${error.message}`);
77
+ return 0;
78
+ }
79
+
80
+ const record = getRuntimeInstallRecord(target);
81
+ const installedVersion = record?.packageVersion || record?.version || "";
82
+ const needsUpdate = installedVersion ? isNewerVersion(latest, installedVersion) : isNewerVersion(latest, packageJson.version);
83
+ if (!needsUpdate && installedVersion) return 0;
84
+ if (!needsUpdate && !installedVersion) return 0;
85
+
86
+ const message = installedVersion
87
+ ? `oh-langfuse ${target} runtime update available: ${installedVersion} -> ${latest}.`
88
+ : `oh-langfuse ${target} runtime may need update. Latest package: ${latest}.`;
89
+
90
+ if (args.yes || args.y) {
91
+ console.log(`[INFO] ${message}`);
92
+ const code = runUpdate(target, args);
93
+ return args.strict ? code : 0;
94
+ }
95
+
96
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
97
+ console.log(`[INFO] ${message} Run: npx oh-langfuse@latest update ${target}`);
98
+ return 0;
99
+ }
100
+
101
+ const answer = await question(`${message}\nUpdate now? (y/N) `);
102
+ if (!/^(y|yes)$/i.test(answer)) return 0;
103
+ const code = runUpdate(target, args);
104
+ return args.strict ? code : 0;
105
+ }
106
+
107
+ main()
108
+ .then((code) => process.exit(code))
109
+ .catch((error) => {
110
+ console.error(`[WARN] oh-langfuse auto-update skipped: ${error?.message || String(error)}`);
111
+ process.exit(0);
112
+ });
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
- import os from "node:os";
4
+ import os from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
9
10
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
@@ -57,11 +58,46 @@ function tomlArray(items) {
57
58
  return `[${items.map(tomlString).join(", ")}]`;
58
59
  }
59
60
 
60
- function pythonExecutableInVenv(venvDir) {
61
- return process.platform === "win32"
62
- ? path.join(venvDir, "Scripts", "python.exe")
63
- : path.join(venvDir, "bin", "python");
64
- }
61
+ function pythonExecutableInVenv(venvDir) {
62
+ return process.platform === "win32"
63
+ ? path.join(venvDir, "Scripts", "python.exe")
64
+ : path.join(venvDir, "bin", "python");
65
+ }
66
+
67
+ function shQuote(s) {
68
+ return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
69
+ }
70
+
71
+ function createAgentLauncher({ baseDir, target, executable }) {
72
+ if (process.platform === "win32") {
73
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
74
+ const content = [
75
+ "@echo off",
76
+ "where npx >nul 2>nul",
77
+ "if %ERRORLEVEL% EQU 0 (",
78
+ ` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
79
+ ")",
80
+ `${executable} %*`,
81
+ ""
82
+ ].join(os.EOL);
83
+ fs.writeFileSync(launcher, content, "utf8");
84
+ return launcher;
85
+ }
86
+
87
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.sh`);
88
+ const content = [
89
+ "#!/usr/bin/env sh",
90
+ "set -eu",
91
+ "if command -v npx >/dev/null 2>&1; then",
92
+ ` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
93
+ "fi",
94
+ `exec ${shQuote(executable)} "$@"`,
95
+ ""
96
+ ].join("\n");
97
+ fs.writeFileSync(launcher, content, "utf8");
98
+ fs.chmodSync(launcher, 0o755);
99
+ return launcher;
100
+ }
65
101
 
66
102
  function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple" }) {
67
103
  const venvDir = path.join(baseDir, "langfuse-venv");
@@ -134,9 +170,10 @@ function updateCodexNotify(configPath, notifyCommand) {
134
170
  fs.writeFileSync(configPath, next.join(os.EOL), "utf8");
135
171
  }
136
172
 
137
- async function main() {
138
- const args = parseArgs(process.argv.slice(2));
139
- const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
173
+ async function main() {
174
+ const args = parseArgs(process.argv.slice(2));
175
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
176
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
140
177
 
141
178
  const publicKey =
142
179
  args.publicKey || process.env.LANGFUSE_PUBLIC_KEY || "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
@@ -188,14 +225,21 @@ async function main() {
188
225
  const notifyPython = args["skip-pip-install"]
189
226
  ? pythonCmd
190
227
  : createOrUpdateLangfuseVenv({ baseDir: codexHome, pipIndexUrl });
191
- updateCodexNotify(configPath, [notifyPython, destHook]);
192
-
193
- console.log(`Updated Codex notify hook: ${configPath}`);
194
- console.log(`Installed hook script: ${destHook}`);
195
- console.log(`Wrote Langfuse config: ${path.join(langfuseDir, "config.json")}`);
196
-
197
- console.log("Done. Restart Codex so the updated notify command is loaded.");
198
- }
228
+ updateCodexNotify(configPath, [notifyPython, destHook]);
229
+ const agentLauncher = createAgentLauncher({ baseDir: codexHome, target: "codex", executable: "codex" });
230
+
231
+ console.log(`Updated Codex notify hook: ${configPath}`);
232
+ console.log(`Installed hook script: ${destHook}`);
233
+ console.log(`Wrote Langfuse config: ${path.join(langfuseDir, "config.json")}`);
234
+ console.log(`Agent launcher with update check: ${agentLauncher}`);
235
+ const runtimeState = writeRuntimeInstallRecord("codex", {
236
+ packageName: packageJson.name,
237
+ packageVersion: packageJson.version,
238
+ });
239
+ console.log(`Runtime version recorded: ${runtimeState}`);
240
+
241
+ console.log("Done. Restart Codex so the updated notify command is loaded.");
242
+ }
199
243
 
200
244
  main().catch((err) => {
201
245
  console.error(err?.message || String(err));
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
9
11
 
10
12
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
11
13
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
@@ -147,6 +149,37 @@ function shQuote(s) {
147
149
  return `'${String(s).replace(/'/g, "'\\''")}'`;
148
150
  }
149
151
 
152
+ function createAgentLauncher({ baseDir, target, executable }) {
153
+ if (process.platform === "win32") {
154
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
155
+ const content = [
156
+ "@echo off",
157
+ "where npx >nul 2>nul",
158
+ "if %ERRORLEVEL% EQU 0 (",
159
+ ` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
160
+ ")",
161
+ `${executable} %*`,
162
+ ""
163
+ ].join(os.EOL);
164
+ fs.writeFileSync(launcher, content, "utf8");
165
+ return launcher;
166
+ }
167
+
168
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.sh`);
169
+ const content = [
170
+ "#!/usr/bin/env sh",
171
+ "set -eu",
172
+ "if command -v npx >/dev/null 2>&1; then",
173
+ ` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
174
+ "fi",
175
+ `exec ${shQuote(executable)} "$@"`,
176
+ ""
177
+ ].join("\n");
178
+ fs.writeFileSync(launcher, content, "utf8");
179
+ fs.chmodSync(launcher, 0o755);
180
+ return launcher;
181
+ }
182
+
150
183
  function pythonExecutableInVenv(venvDir) {
151
184
  return process.platform === "win32"
152
185
  ? path.join(venvDir, "Scripts", "python.exe")
@@ -288,6 +321,7 @@ async function main() {
288
321
  }
289
322
  const hookPython = createOrUpdateLangfuseVenv({ baseDir: claudeDir, pipIndexUrl });
290
323
  const hookLauncher = await createHookLauncher({ hooksDir, hookPython, pyPath });
324
+ const agentLauncher = createAgentLauncher({ baseDir: claudeDir, target: "claude", executable: "claude" });
291
325
 
292
326
  // 4) 合并写入 settings.json
293
327
  const settingsPath = path.join(claudeDir, "settings.json");
@@ -322,10 +356,16 @@ async function main() {
322
356
 
323
357
  const merged = deepMerge(existing, desired);
324
358
  ensureDir(claudeDir);
325
- writeJsonPretty(settingsPath, merged);
326
- console.log(`已更新:${settingsPath}`);
327
-
328
- }
359
+ writeJsonPretty(settingsPath, merged);
360
+ const runtimeState = writeRuntimeInstallRecord("claude", {
361
+ packageName: packageJson.name,
362
+ packageVersion: packageJson.version,
363
+ });
364
+ console.log(`Runtime version recorded: ${runtimeState}`);
365
+ console.log(`已更新:${settingsPath}`);
366
+ console.log(`Agent launcher with update check: ${agentLauncher}`);
367
+
368
+ }
329
369
 
330
370
  main().catch((err) => {
331
371
  console.error(err?.message || String(err));
@@ -1,12 +1,16 @@
1
- import fs from "node:fs";
1
+ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { parseJsonRelaxed, stripBom } from "./json-utils.mjs";
6
7
  import { shouldSuppressAgentLogLine } from "./log-filter-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
9
 
8
10
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
9
11
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
12
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
10
14
 
11
15
  function normalizeUserId(v) {
12
16
  return String(v || "").trim();
@@ -296,6 +300,7 @@ function getPatchedLangfuseDistIndexJs() {
296
300
  " }",
297
301
  " return out;",
298
302
  "};",
303
+ "const MAX_SKILL_USE_SPANS_PER_INTERACTION = 20;",
299
304
  "",
300
305
  "const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');",
301
306
  "",
@@ -456,6 +461,23 @@ function getPatchedLangfuseDistIndexJs() {
456
461
  " }",
457
462
  " set.add(activity.toolCallId || `${kind}:${set.size + 1}`);",
458
463
  " };",
464
+ " const emitSkillUseSpans = ({ skillNames, sessionId, messageId, interactionId }) => {",
465
+ " for (const skillName of skillNames.slice(0, MAX_SKILL_USE_SPANS_PER_INTERACTION)) {",
466
+ " const span = metricsTracer.startSpan(`Skill Use / ${skillName}`);",
467
+ ' span.setAttribute("oh.langfuse.source", "opencode");',
468
+ ' span.setAttribute("oh.langfuse.user_id", userId || "");',
469
+ ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.0");',
470
+ ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
471
+ ' span.setAttribute("langfuse.observation.metadata.user_id", userId || "");',
472
+ ' span.setAttribute("langfuse.observation.metadata.session_id", sessionId || "unknown");',
473
+ ' span.setAttribute("langfuse.observation.metadata.message_id", messageId || "unknown");',
474
+ ' span.setAttribute("langfuse.observation.metadata.interaction_id", interactionId);',
475
+ ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.0");',
476
+ ' span.setAttribute("langfuse.observation.metadata.skill_name", skillName);',
477
+ ' span.setAttribute("langfuse.observation.metadata.skill_use_count", 1);',
478
+ " span.end();",
479
+ " }",
480
+ " };",
459
481
  "",
460
482
  " const recordInteractionMetric = (event) => {",
461
483
  " const payload = eventPayload(event);",
@@ -492,7 +514,8 @@ function getPatchedLangfuseDistIndexJs() {
492
514
  ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
493
515
  ' span.setAttribute("langfuse.observation.metadata.user_id", userId || "");',
494
516
  ' span.setAttribute("langfuse.observation.metadata.session_id", sessionId || "unknown");',
495
- ' span.setAttribute("langfuse.observation.metadata.interaction_id", `opencode:${userId || "unknown"}:${sessionId || "unknown"}:${messageId}`);',
517
+ ' const interactionId = `opencode:${userId || "unknown"}:${sessionId || "unknown"}:${messageId}`;',
518
+ ' span.setAttribute("langfuse.observation.metadata.interaction_id", interactionId);',
496
519
  ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.0");',
497
520
  ' span.setAttribute("langfuse.observation.metadata.interaction_count", 1);',
498
521
  ' span.setAttribute("langfuse.observation.metadata.user_message_count", 1);',
@@ -511,6 +534,7 @@ function getPatchedLangfuseDistIndexJs() {
511
534
  ' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
512
535
  ' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
513
536
  " span.end();",
537
+ " emitSkillUseSpans({ skillNames, sessionId, messageId, interactionId });",
514
538
  " messageTextById.delete(messageId);",
515
539
  " skillNamesByMessageId.delete(messageId);",
516
540
  " skillNamesBySessionId.delete(sessionId);",
@@ -560,11 +584,15 @@ function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, u
560
584
  if (process.platform !== "win32") return null;
561
585
  const p = path.join(opencodeDir, "launch-opencode-langfuse.cmd");
562
586
  const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
563
- cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
564
- cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
565
- cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
566
- if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
567
- cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
587
+ cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
588
+ cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
589
+ cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
590
+ if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
591
+ cmd.push("where npx >nul 2>nul");
592
+ cmd.push("if %ERRORLEVEL% EQU 0 (");
593
+ cmd.push(" call npx -y oh-langfuse@latest auto-update opencode --skip-check");
594
+ cmd.push(")");
595
+ cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
568
596
  cmd.push(' "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
569
597
  cmd.push(" exit /b %ERRORLEVEL%");
570
598
  cmd.push(")");
@@ -588,6 +616,9 @@ function writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userI
588
616
  `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
589
617
  `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
590
618
  userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
619
+ 'if command -v npx >/dev/null 2>&1; then',
620
+ ' npx -y oh-langfuse@latest auto-update opencode --skip-check || true',
621
+ "fi",
591
622
  'if [ -x "$HOME/.opencode/bin/opencode" ]; then',
592
623
  ' exec "$HOME/.opencode/bin/opencode" "$@"',
593
624
  "fi",
@@ -906,6 +937,11 @@ async function main() {
906
937
  }
907
938
 
908
939
  console.log(paint("完成。", colors.green, colors.bold));
940
+ const runtimeState = writeRuntimeInstallRecord("opencode", {
941
+ packageName: packageJson.name,
942
+ packageVersion: packageJson.version,
943
+ });
944
+ console.log(`Runtime version recorded: ${runtimeState}`);
909
945
  printCommandHint("可运行以下命令校验:", "npx oh-langfuse@latest check opencode");
910
946
  if (process.platform !== "win32") {
911
947
  console.log("");
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const STATE_VERSION = 1;
6
+
7
+ export function runtimeStateDir(home = os.homedir()) {
8
+ return path.join(home, ".config", "oh-langfuse");
9
+ }
10
+
11
+ export function runtimeStatePath(home = os.homedir()) {
12
+ return path.join(runtimeStateDir(home), "runtime.json");
13
+ }
14
+
15
+ function stripBom(s) {
16
+ return typeof s === "string" && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
17
+ }
18
+
19
+ export function readRuntimeState(home = os.homedir()) {
20
+ const p = runtimeStatePath(home);
21
+ try {
22
+ if (!fs.existsSync(p)) return { version: STATE_VERSION, targets: {} };
23
+ const text = stripBom(fs.readFileSync(p, "utf8"));
24
+ if (!text.trim()) return { version: STATE_VERSION, targets: {} };
25
+ const parsed = JSON.parse(text);
26
+ return {
27
+ version: parsed.version || STATE_VERSION,
28
+ targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
29
+ };
30
+ } catch {
31
+ return { version: STATE_VERSION, targets: {} };
32
+ }
33
+ }
34
+
35
+ export function getRuntimeInstallRecord(target, home = os.homedir()) {
36
+ return readRuntimeState(home).targets?.[target] || null;
37
+ }
38
+
39
+ export function writeRuntimeInstallRecord(target, record = {}, home = os.homedir()) {
40
+ const state = readRuntimeState(home);
41
+ const dir = runtimeStateDir(home);
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ state.version = STATE_VERSION;
44
+ state.targets = state.targets || {};
45
+ state.targets[target] = {
46
+ ...state.targets[target],
47
+ ...record,
48
+ target,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ fs.writeFileSync(runtimeStatePath(home), JSON.stringify(state, null, 2) + os.EOL, "utf8");
52
+ return runtimeStatePath(home);
53
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { extractVersionFromNpmMetadata, selectUpdateTargets } from "./update-utils.mjs";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
@@ -166,6 +167,10 @@ async function main() {
166
167
  console.log(`[INFO] Updating ${target} runtime...`);
167
168
  const config = mergedConfig(target, args);
168
169
  runNodeScript(setupScript(target), setupArgs(target, config, args));
170
+ writeRuntimeInstallRecord(target, {
171
+ packageName: packageJson.name,
172
+ packageVersion: packageJson.version,
173
+ });
169
174
  if (!args["skip-check"]) {
170
175
  runNodeScript(checkScript(target), []);
171
176
  }
@@ -8,6 +8,33 @@ export function extractVersionFromNpmMetadata(metadata) {
8
8
  return latest.trim();
9
9
  }
10
10
 
11
+ function versionParts(version) {
12
+ return String(version || "")
13
+ .trim()
14
+ .replace(/^v/i, "")
15
+ .split("-")[0]
16
+ .split(".")
17
+ .map((part) => Number.parseInt(part, 10) || 0);
18
+ }
19
+
20
+ export function compareSemver(a, b) {
21
+ const left = versionParts(a);
22
+ const right = versionParts(b);
23
+ const length = Math.max(left.length, right.length, 3);
24
+ for (let i = 0; i < length; i += 1) {
25
+ const l = left[i] || 0;
26
+ const r = right[i] || 0;
27
+ if (l > r) return 1;
28
+ if (l < r) return -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ export function isNewerVersion(candidate, current) {
34
+ if (!candidate || !current) return false;
35
+ return compareSemver(candidate, current) > 0;
36
+ }
37
+
11
38
  export function selectUpdateTargets(target = "all", installed = {}) {
12
39
  const normalized = String(target || "all").trim().toLowerCase();
13
40
  if (normalized === "all") {