oh-langfuse 0.1.41 → 0.1.43
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 +62 -85
- package/bin/cli.js +21 -14
- package/codex_langfuse_notify.py +139 -59
- package/langfuse_hook.py +223 -57
- package/package.json +35 -35
- package/scripts/auto-update-runtime.mjs +4 -2
- package/scripts/codex-langfuse-setup.mjs +163 -11
- package/scripts/langfuse-check.mjs +11 -5
- package/scripts/langfuse-setup.mjs +155 -12
- package/scripts/metrics-utils.mjs +134 -10
- package/scripts/opencode-langfuse-setup.mjs +118 -61
- package/scripts/real-self-verify.mjs +13 -8
|
@@ -2,10 +2,11 @@ import fs from "node:fs";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
|
|
8
8
|
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
10
|
const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
|
|
10
11
|
const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
|
|
11
12
|
|
|
@@ -68,15 +69,143 @@ function shQuote(s) {
|
|
|
68
69
|
return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function psQuote(s) {
|
|
73
|
+
return `'${String(s).replace(/'/g, "''")}'`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cmdQuote(s) {
|
|
77
|
+
return `"${String(s).replace(/"/g, '""')}"`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function autoUpdateRuntimePath() {
|
|
81
|
+
return path.join(packageRoot, "scripts", "auto-update-runtime.mjs");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function windowsAutoUpdateCommand(target) {
|
|
85
|
+
return `call ${cmdQuote(process.execPath)} ${cmdQuote(autoUpdateRuntimePath())} ${target} --skip-check`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function unixAutoUpdateCommand(target) {
|
|
89
|
+
return `${shQuote(process.execPath)} ${shQuote(autoUpdateRuntimePath())} ${target} --skip-check || true`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isPathInsideDir(candidate, dir) {
|
|
93
|
+
if (!candidate || !dir) return false;
|
|
94
|
+
const relative = path.relative(path.resolve(dir), path.resolve(candidate));
|
|
95
|
+
return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function existingCliCandidate(candidate, shimDir) {
|
|
99
|
+
if (!candidate || isPathInsideDir(candidate, shimDir)) return "";
|
|
100
|
+
try {
|
|
101
|
+
return fs.existsSync(candidate) ? candidate : "";
|
|
102
|
+
} catch {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listCliCandidatesFromPath(target) {
|
|
108
|
+
const cmd = process.platform === "win32" ? "where.exe" : "which";
|
|
109
|
+
const args = process.platform === "win32" ? [target] : ["-a", target];
|
|
110
|
+
const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
|
|
111
|
+
if (result.status !== 0) return [];
|
|
112
|
+
return String(result.stdout || "")
|
|
113
|
+
.split(/\r?\n/)
|
|
114
|
+
.map((line) => line.trim())
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
|
|
119
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
120
|
+
const candidates = [
|
|
121
|
+
preferred,
|
|
122
|
+
process.platform === "win32" ? path.join(appData, "npm", `${target}.cmd`) : "",
|
|
123
|
+
process.platform === "win32" ? path.join(appData, "npm", `${target}.exe`) : "",
|
|
124
|
+
process.platform === "win32" ? path.join(appData, "npm", target) : "",
|
|
125
|
+
...listCliCandidatesFromPath(target)
|
|
126
|
+
];
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
const found = existingCliCandidate(candidate, shimDir);
|
|
129
|
+
if (found) return found;
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function prependWindowsUserPath(dir) {
|
|
135
|
+
if (process.platform !== "win32") return false;
|
|
136
|
+
const cmd = [
|
|
137
|
+
"$ErrorActionPreference = 'Stop';",
|
|
138
|
+
`$dir = ${psQuote(dir)};`,
|
|
139
|
+
"$current = [Environment]::GetEnvironmentVariable('Path', 'User');",
|
|
140
|
+
"$parts = @();",
|
|
141
|
+
"if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() } }",
|
|
142
|
+
"$exists = $false;",
|
|
143
|
+
"foreach ($part in $parts) { if ([string]::Equals($part.TrimEnd('\\'), $dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase)) { $exists = $true } }",
|
|
144
|
+
"if (-not $exists) {",
|
|
145
|
+
" $next = (@($dir) + $parts) -join ';';",
|
|
146
|
+
" [Environment]::SetEnvironmentVariable('Path', $next, 'User');",
|
|
147
|
+
"}"
|
|
148
|
+
].join(" ");
|
|
149
|
+
const result = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", windowsHide: true });
|
|
150
|
+
if (result.status !== 0) throw new Error("Failed to prepend Codex Langfuse shim to the user PATH.");
|
|
151
|
+
|
|
152
|
+
const currentParts = String(process.env.PATH || "").split(path.delimiter);
|
|
153
|
+
const normalized = dir.replace(/[\\/]+$/, "").toLowerCase();
|
|
154
|
+
if (!currentParts.some((part) => part && part.replace(/[\\/]+$/, "").toLowerCase() === normalized)) {
|
|
155
|
+
process.env.PATH = `${dir}${path.delimiter}${process.env.PATH || ""}`;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeAgentCommandShim({ baseDir, target, executable, realCli, publicKey, secretKey, baseUrl, userId }) {
|
|
161
|
+
if (!realCli) return null;
|
|
162
|
+
const shimDir = path.join(baseDir, "bin");
|
|
163
|
+
ensureDir(shimDir);
|
|
164
|
+
if (process.platform === "win32") {
|
|
165
|
+
const shim = path.join(shimDir, `${executable}.cmd`);
|
|
166
|
+
const envPrefix = `OH_LANGFUSE_${target.toUpperCase()}_SHIM`;
|
|
167
|
+
const content = [
|
|
168
|
+
"@echo off",
|
|
169
|
+
"REM Auto-generated by scripts/codex-langfuse-setup.mjs",
|
|
170
|
+
`set ${envPrefix}=1`,
|
|
171
|
+
`set LANGFUSE_PUBLIC_KEY=${publicKey}`,
|
|
172
|
+
`set LANGFUSE_SECRET_KEY=${secretKey}`,
|
|
173
|
+
`set LANGFUSE_BASEURL=${baseUrl}`,
|
|
174
|
+
userId ? `set LANGFUSE_USER_ID=${userId}` : null,
|
|
175
|
+
windowsAutoUpdateCommand(target),
|
|
176
|
+
`call ${cmdQuote(realCli)} %*`,
|
|
177
|
+
"exit /b %ERRORLEVEL%",
|
|
178
|
+
""
|
|
179
|
+
].filter(Boolean).join(os.EOL);
|
|
180
|
+
fs.writeFileSync(shim, content, "utf8");
|
|
181
|
+
return { shim, shimDir };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const shim = path.join(shimDir, executable);
|
|
185
|
+
const lines = [
|
|
186
|
+
"#!/usr/bin/env sh",
|
|
187
|
+
"# Auto-generated by scripts/codex-langfuse-setup.mjs",
|
|
188
|
+
"set -eu",
|
|
189
|
+
`export OH_LANGFUSE_${target.toUpperCase()}_SHIM=1`,
|
|
190
|
+
`export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
|
|
191
|
+
`export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
|
|
192
|
+
`export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
|
|
193
|
+
userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
|
|
194
|
+
unixAutoUpdateCommand(target),
|
|
195
|
+
`exec ${shQuote(realCli)} "$@"`,
|
|
196
|
+
""
|
|
197
|
+
].filter(Boolean);
|
|
198
|
+
fs.writeFileSync(shim, lines.join("\n"), "utf8");
|
|
199
|
+
fs.chmodSync(shim, 0o755);
|
|
200
|
+
return { shim, shimDir };
|
|
201
|
+
}
|
|
202
|
+
|
|
71
203
|
function createAgentLauncher({ baseDir, target, executable }) {
|
|
72
204
|
if (process.platform === "win32") {
|
|
73
205
|
const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
|
|
74
206
|
const content = [
|
|
75
207
|
"@echo off",
|
|
76
|
-
|
|
77
|
-
"if %ERRORLEVEL% EQU 0 (",
|
|
78
|
-
` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
|
|
79
|
-
")",
|
|
208
|
+
windowsAutoUpdateCommand(target),
|
|
80
209
|
`${executable} %*`,
|
|
81
210
|
""
|
|
82
211
|
].join(os.EOL);
|
|
@@ -88,9 +217,7 @@ function createAgentLauncher({ baseDir, target, executable }) {
|
|
|
88
217
|
const content = [
|
|
89
218
|
"#!/usr/bin/env sh",
|
|
90
219
|
"set -eu",
|
|
91
|
-
|
|
92
|
-
` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
|
|
93
|
-
"fi",
|
|
220
|
+
unixAutoUpdateCommand(target),
|
|
94
221
|
`exec ${shQuote(executable)} "$@"`,
|
|
95
222
|
""
|
|
96
223
|
].join("\n");
|
|
@@ -172,7 +299,7 @@ function updateCodexNotify(configPath, notifyCommand) {
|
|
|
172
299
|
|
|
173
300
|
async function main() {
|
|
174
301
|
const args = parseArgs(process.argv.slice(2));
|
|
175
|
-
const rootDir =
|
|
302
|
+
const rootDir = packageRoot;
|
|
176
303
|
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
177
304
|
|
|
178
305
|
const publicKey =
|
|
@@ -226,12 +353,37 @@ async function main() {
|
|
|
226
353
|
? pythonCmd
|
|
227
354
|
: createOrUpdateLangfuseVenv({ baseDir: codexHome, pipIndexUrl });
|
|
228
355
|
updateCodexNotify(configPath, [notifyPython, destHook]);
|
|
229
|
-
const
|
|
230
|
-
|
|
356
|
+
const codexShimDir = path.join(codexHome, "bin");
|
|
357
|
+
const realCodexCli = resolveAgentCli({ target: "codex", preferred: args.cmd || "", shimDir: codexShimDir });
|
|
358
|
+
const directShim = writeAgentCommandShim({
|
|
359
|
+
baseDir: codexHome,
|
|
360
|
+
target: "codex",
|
|
361
|
+
executable: "codex",
|
|
362
|
+
realCli: realCodexCli,
|
|
363
|
+
publicKey,
|
|
364
|
+
secretKey,
|
|
365
|
+
baseUrl,
|
|
366
|
+
userId
|
|
367
|
+
});
|
|
368
|
+
if (directShim?.shimDir) {
|
|
369
|
+
prependWindowsUserPath(directShim.shimDir);
|
|
370
|
+
}
|
|
371
|
+
const agentLauncher = createAgentLauncher({
|
|
372
|
+
baseDir: codexHome,
|
|
373
|
+
target: "codex",
|
|
374
|
+
executable: realCodexCli ? cmdQuote(realCodexCli) : "codex"
|
|
375
|
+
});
|
|
376
|
+
|
|
231
377
|
console.log(`Updated Codex notify hook: ${configPath}`);
|
|
232
378
|
console.log(`Installed hook script: ${destHook}`);
|
|
233
379
|
console.log(`Wrote Langfuse config: ${path.join(langfuseDir, "config.json")}`);
|
|
234
380
|
console.log(`Agent launcher with update check: ${agentLauncher}`);
|
|
381
|
+
if (directShim) {
|
|
382
|
+
console.log(`Direct Codex command shim ready: ${directShim.shim}`);
|
|
383
|
+
console.log(`Real Codex CLI: ${realCodexCli}`);
|
|
384
|
+
} else {
|
|
385
|
+
console.log("Codex CLI not found; skipped direct codex command shim.");
|
|
386
|
+
}
|
|
235
387
|
const runtimeState = writeRuntimeInstallRecord("codex", {
|
|
236
388
|
packageName: packageJson.name,
|
|
237
389
|
packageVersion: packageJson.version,
|
|
@@ -26,11 +26,17 @@ function launcherPath(hooksDir) {
|
|
|
26
26
|
: path.join(hooksDir, "run-langfuse-hook.sh");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function
|
|
29
|
+
function directPythonHookCommand(pythonPath, pyPath) {
|
|
30
|
+
if (process.platform !== "win32") return `${pythonPath} ${pyPath}`;
|
|
31
|
+
return `"${String(pythonPath).replace(/"/g, '""')}" "${String(pyPath).replace(/"/g, '""')}"`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expectedHookCommands({ launcher, pythonPath, pyPath }) {
|
|
30
35
|
if (process.platform !== "win32") return [path.normalize(launcher)];
|
|
31
36
|
return [
|
|
32
37
|
path.normalize(launcher),
|
|
33
|
-
`cmd.exe /d /s /c "${launcher.replace(/"/g, '""')}"
|
|
38
|
+
`cmd.exe /d /s /c "${launcher.replace(/"/g, '""')}"`,
|
|
39
|
+
directPythonHookCommand(pythonPath, pyPath)
|
|
34
40
|
];
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -118,7 +124,7 @@ function main() {
|
|
|
118
124
|
const hook = findLangfuseHook(settings);
|
|
119
125
|
addResult(results, "hooks.Stop contains Langfuse", !!hook, hook ? "OK" : "missing", "Run setup again to register the Stop hook.");
|
|
120
126
|
|
|
121
|
-
const expectedCommands = expectedHookCommands(expectedLauncher);
|
|
127
|
+
const expectedCommands = expectedHookCommands({ launcher: expectedLauncher, pythonPath: expectedPython, pyPath });
|
|
122
128
|
const hookCommand = hook?.command || "";
|
|
123
129
|
const launcherFormOk =
|
|
124
130
|
!!hook &&
|
|
@@ -128,8 +134,8 @@ function main() {
|
|
|
128
134
|
results,
|
|
129
135
|
"hook command form",
|
|
130
136
|
launcherFormOk,
|
|
131
|
-
launcherFormOk ? "
|
|
132
|
-
"Run setup again. Claude Code should execute the generated
|
|
137
|
+
launcherFormOk ? "supported command" : hook ? "legacy command form" : "missing",
|
|
138
|
+
"Run setup again. Claude Code should execute the generated Python hook command."
|
|
133
139
|
);
|
|
134
140
|
|
|
135
141
|
addResult(
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
|
|
8
8
|
|
|
@@ -140,24 +140,144 @@ function cmdQuote(s) {
|
|
|
140
140
|
return `"${String(s).replace(/"/g, '""')}"`;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function hookCommandForClaude(launcher) {
|
|
143
|
+
function hookCommandForClaude({ launcher, hookPython, pyPath }) {
|
|
144
144
|
if (process.platform !== "win32") return launcher;
|
|
145
|
-
return
|
|
145
|
+
return `${cmdQuote(hookPython)} ${cmdQuote(pyPath)}`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
function shQuote(s) {
|
|
149
149
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function autoUpdateRuntimePath() {
|
|
153
|
+
return path.join(packageRoot, "scripts", "auto-update-runtime.mjs");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function windowsAutoUpdateCommand(target) {
|
|
157
|
+
return `call ${cmdQuote(process.execPath)} ${cmdQuote(autoUpdateRuntimePath())} ${target} --skip-check`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unixAutoUpdateCommand(target) {
|
|
161
|
+
return `${shQuote(process.execPath)} ${shQuote(autoUpdateRuntimePath())} ${target} --skip-check || true`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isPathInsideDir(candidate, dir) {
|
|
165
|
+
if (!candidate || !dir) return false;
|
|
166
|
+
const relative = path.relative(path.resolve(dir), path.resolve(candidate));
|
|
167
|
+
return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function existingCliCandidate(candidate, shimDir) {
|
|
171
|
+
if (!candidate || isPathInsideDir(candidate, shimDir)) return "";
|
|
172
|
+
try {
|
|
173
|
+
return fs.existsSync(candidate) ? candidate : "";
|
|
174
|
+
} catch {
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function listCliCandidatesFromPath(target) {
|
|
180
|
+
const cmd = process.platform === "win32" ? "where.exe" : "which";
|
|
181
|
+
const args = process.platform === "win32" ? [target] : ["-a", target];
|
|
182
|
+
const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
|
|
183
|
+
if (result.status !== 0) return [];
|
|
184
|
+
return String(result.stdout || "")
|
|
185
|
+
.split(/\r?\n/)
|
|
186
|
+
.map((line) => line.trim())
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveAgentCli({ target, preferred = "", shimDir = "" }) {
|
|
191
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
192
|
+
const candidates = [
|
|
193
|
+
preferred,
|
|
194
|
+
process.platform === "win32" ? path.join(appData, "npm", `${target}.cmd`) : "",
|
|
195
|
+
process.platform === "win32" ? path.join(appData, "npm", `${target}.exe`) : "",
|
|
196
|
+
process.platform === "win32" ? path.join(appData, "npm", target) : "",
|
|
197
|
+
...listCliCandidatesFromPath(target)
|
|
198
|
+
];
|
|
199
|
+
for (const candidate of candidates) {
|
|
200
|
+
const found = existingCliCandidate(candidate, shimDir);
|
|
201
|
+
if (found) return found;
|
|
202
|
+
}
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function prependWindowsUserPath(dir) {
|
|
207
|
+
if (process.platform !== "win32") return false;
|
|
208
|
+
const cmd = [
|
|
209
|
+
"$ErrorActionPreference = 'Stop';",
|
|
210
|
+
`$dir = ${psQuote(dir)};`,
|
|
211
|
+
"$current = [Environment]::GetEnvironmentVariable('Path', 'User');",
|
|
212
|
+
"$parts = @();",
|
|
213
|
+
"if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() } }",
|
|
214
|
+
"$exists = $false;",
|
|
215
|
+
"foreach ($part in $parts) { if ([string]::Equals($part.TrimEnd('\\'), $dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase)) { $exists = $true } }",
|
|
216
|
+
"if (-not $exists) {",
|
|
217
|
+
" $next = (@($dir) + $parts) -join ';';",
|
|
218
|
+
" [Environment]::SetEnvironmentVariable('Path', $next, 'User');",
|
|
219
|
+
"}"
|
|
220
|
+
].join(" ");
|
|
221
|
+
const result = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", windowsHide: true });
|
|
222
|
+
if (result.status !== 0) throw new Error("Failed to prepend Claude Langfuse shim to the user PATH.");
|
|
223
|
+
|
|
224
|
+
const currentParts = String(process.env.PATH || "").split(path.delimiter);
|
|
225
|
+
const normalized = dir.replace(/[\\/]+$/, "").toLowerCase();
|
|
226
|
+
if (!currentParts.some((part) => part && part.replace(/[\\/]+$/, "").toLowerCase() === normalized)) {
|
|
227
|
+
process.env.PATH = `${dir}${path.delimiter}${process.env.PATH || ""}`;
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeAgentCommandShim({ baseDir, target, executable, realCli, publicKey, secretKey, baseUrl, userId }) {
|
|
233
|
+
if (!realCli) return null;
|
|
234
|
+
const shimDir = path.join(baseDir, "bin");
|
|
235
|
+
ensureDir(shimDir);
|
|
236
|
+
if (process.platform === "win32") {
|
|
237
|
+
const shim = path.join(shimDir, `${executable}.cmd`);
|
|
238
|
+
const envPrefix = `OH_LANGFUSE_${target.toUpperCase()}_SHIM`;
|
|
239
|
+
const content = [
|
|
240
|
+
"@echo off",
|
|
241
|
+
"REM Auto-generated by scripts/langfuse-setup.mjs",
|
|
242
|
+
`set ${envPrefix}=1`,
|
|
243
|
+
`set LANGFUSE_PUBLIC_KEY=${publicKey}`,
|
|
244
|
+
`set LANGFUSE_SECRET_KEY=${secretKey}`,
|
|
245
|
+
`set LANGFUSE_BASEURL=${baseUrl}`,
|
|
246
|
+
userId ? `set LANGFUSE_USER_ID=${userId}` : null,
|
|
247
|
+
windowsAutoUpdateCommand(target),
|
|
248
|
+
`call ${cmdQuote(realCli)} %*`,
|
|
249
|
+
"exit /b %ERRORLEVEL%",
|
|
250
|
+
""
|
|
251
|
+
].filter(Boolean).join(os.EOL);
|
|
252
|
+
fs.writeFileSync(shim, content, "utf8");
|
|
253
|
+
return { shim, shimDir };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const shim = path.join(shimDir, executable);
|
|
257
|
+
const lines = [
|
|
258
|
+
"#!/usr/bin/env sh",
|
|
259
|
+
"# Auto-generated by scripts/langfuse-setup.mjs",
|
|
260
|
+
"set -eu",
|
|
261
|
+
`export OH_LANGFUSE_${target.toUpperCase()}_SHIM=1`,
|
|
262
|
+
`export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
|
|
263
|
+
`export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
|
|
264
|
+
`export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
|
|
265
|
+
userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
|
|
266
|
+
unixAutoUpdateCommand(target),
|
|
267
|
+
`exec ${shQuote(realCli)} "$@"`,
|
|
268
|
+
""
|
|
269
|
+
].filter(Boolean);
|
|
270
|
+
fs.writeFileSync(shim, lines.join("\n"), "utf8");
|
|
271
|
+
fs.chmodSync(shim, 0o755);
|
|
272
|
+
return { shim, shimDir };
|
|
273
|
+
}
|
|
274
|
+
|
|
152
275
|
function createAgentLauncher({ baseDir, target, executable }) {
|
|
153
276
|
if (process.platform === "win32") {
|
|
154
277
|
const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
|
|
155
278
|
const content = [
|
|
156
279
|
"@echo off",
|
|
157
|
-
|
|
158
|
-
"if %ERRORLEVEL% EQU 0 (",
|
|
159
|
-
` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
|
|
160
|
-
")",
|
|
280
|
+
windowsAutoUpdateCommand(target),
|
|
161
281
|
`${executable} %*`,
|
|
162
282
|
""
|
|
163
283
|
].join(os.EOL);
|
|
@@ -169,9 +289,7 @@ function createAgentLauncher({ baseDir, target, executable }) {
|
|
|
169
289
|
const content = [
|
|
170
290
|
"#!/usr/bin/env sh",
|
|
171
291
|
"set -eu",
|
|
172
|
-
|
|
173
|
-
` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
|
|
174
|
-
"fi",
|
|
292
|
+
unixAutoUpdateCommand(target),
|
|
175
293
|
`exec ${shQuote(executable)} "$@"`,
|
|
176
294
|
""
|
|
177
295
|
].join("\n");
|
|
@@ -321,7 +439,26 @@ async function main() {
|
|
|
321
439
|
}
|
|
322
440
|
const hookPython = createOrUpdateLangfuseVenv({ baseDir: claudeDir, pipIndexUrl });
|
|
323
441
|
const hookLauncher = await createHookLauncher({ hooksDir, hookPython, pyPath });
|
|
324
|
-
const
|
|
442
|
+
const claudeShimDir = path.join(claudeDir, "bin");
|
|
443
|
+
const realClaudeCli = resolveAgentCli({ target: "claude", preferred: args.cmd || "", shimDir: claudeShimDir });
|
|
444
|
+
const directShim = writeAgentCommandShim({
|
|
445
|
+
baseDir: claudeDir,
|
|
446
|
+
target: "claude",
|
|
447
|
+
executable: "claude",
|
|
448
|
+
realCli: realClaudeCli,
|
|
449
|
+
publicKey,
|
|
450
|
+
secretKey,
|
|
451
|
+
baseUrl: langfuseHost,
|
|
452
|
+
userId
|
|
453
|
+
});
|
|
454
|
+
if (directShim?.shimDir) {
|
|
455
|
+
prependWindowsUserPath(directShim.shimDir);
|
|
456
|
+
}
|
|
457
|
+
const agentLauncher = createAgentLauncher({
|
|
458
|
+
baseDir: claudeDir,
|
|
459
|
+
target: "claude",
|
|
460
|
+
executable: realClaudeCli ? cmdQuote(realClaudeCli) : "claude"
|
|
461
|
+
});
|
|
325
462
|
|
|
326
463
|
// 4) 合并写入 settings.json
|
|
327
464
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
@@ -346,7 +483,7 @@ async function main() {
|
|
|
346
483
|
hooks: [
|
|
347
484
|
{
|
|
348
485
|
type: "command",
|
|
349
|
-
command: hookCommandForClaude(hookLauncher)
|
|
486
|
+
command: hookCommandForClaude({ launcher: hookLauncher, hookPython, pyPath })
|
|
350
487
|
}
|
|
351
488
|
]
|
|
352
489
|
}
|
|
@@ -364,6 +501,12 @@ async function main() {
|
|
|
364
501
|
console.log(`Runtime version recorded: ${runtimeState}`);
|
|
365
502
|
console.log(`已更新:${settingsPath}`);
|
|
366
503
|
console.log(`Agent launcher with update check: ${agentLauncher}`);
|
|
504
|
+
if (directShim) {
|
|
505
|
+
console.log(`Direct Claude command shim ready: ${directShim.shim}`);
|
|
506
|
+
console.log(`Real Claude CLI: ${realClaudeCli}`);
|
|
507
|
+
} else {
|
|
508
|
+
console.log("Claude CLI not found; skipped direct claude command shim.");
|
|
509
|
+
}
|
|
367
510
|
|
|
368
511
|
}
|
|
369
512
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const METRICS_SCHEMA_VERSION = "1.
|
|
1
|
+
export const METRICS_SCHEMA_VERSION = "1.1";
|
|
2
2
|
|
|
3
3
|
function numberOrNull(value) {
|
|
4
4
|
if (typeof value === "string" && value.trim().startsWith("{")) {
|
|
@@ -64,8 +64,39 @@ function normalizeSkillNames(names) {
|
|
|
64
64
|
return out;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function
|
|
68
|
-
return String(
|
|
67
|
+
function skillNamespace(name) {
|
|
68
|
+
return String(name || "").includes(":") ? String(name).split(":", 1)[0] : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function skillEventType(detectedBy) {
|
|
72
|
+
return ["tool_call", "plugin_event", "attribution_skill", "slash_command"].includes(detectedBy) ? "invoked" : "detected";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function skillIdSegment(name) {
|
|
76
|
+
return String(name || "unknown")
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/[^A-Za-z0-9_.:-]+/g, "-")
|
|
79
|
+
.replace(/^-+|-+$/g, "")
|
|
80
|
+
.slice(0, 96) || "unknown";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeSkillUsages(usages) {
|
|
84
|
+
if (!Array.isArray(usages)) return [];
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const usage of usages) {
|
|
87
|
+
const raw = typeof usage === "string" ? { name: usage } : usage;
|
|
88
|
+
if (!raw || typeof raw !== "object") continue;
|
|
89
|
+
const name = String(raw.name ?? raw.skill_name ?? raw.skillName ?? "").trim();
|
|
90
|
+
if (!name) continue;
|
|
91
|
+
const detectedBy = String(raw.detected_by ?? raw.detectedBy ?? "metadata").trim() || "metadata";
|
|
92
|
+
out.push({
|
|
93
|
+
name,
|
|
94
|
+
skill_namespace: String(raw.skill_namespace ?? raw.skillNamespace ?? skillNamespace(name)),
|
|
95
|
+
detected_by: detectedBy,
|
|
96
|
+
skill_call_id: String(raw.skill_call_id ?? raw.skillCallId ?? raw.call_id ?? raw.callId ?? "").trim(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
function collectStrings(value, out = []) {
|
|
@@ -89,13 +120,52 @@ function collectStrings(value, out = []) {
|
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
export function detectOpencodeSkillNames(source, knownSkills = []) {
|
|
123
|
+
const detected = new Set(detectOpencodeSkillUsages(source, knownSkills).map((usage) => usage.name));
|
|
124
|
+
return normalizeSkillNames(knownSkills).filter((name) => detected.has(name));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectOpencodeExplicitSkillUsages(value, out = []) {
|
|
128
|
+
if (value == null) return out;
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
for (const item of value) collectOpencodeExplicitSkillUsages(item, out);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== "object") return out;
|
|
134
|
+
|
|
135
|
+
const toolName = pickString(value.tool, value.toolName, value.name).toLowerCase();
|
|
136
|
+
const input = value.input ?? value.state?.input ?? value.properties?.input;
|
|
137
|
+
if (toolName === "skill" && input && typeof input === "object" && !Array.isArray(input)) {
|
|
138
|
+
const skill = pickString(input.skill_name, input.skill, input.name);
|
|
139
|
+
const callId = pickString(value.callID, value.callId, value.id, value.toolCallID, value.toolCallId);
|
|
140
|
+
if (skill) out.push({ name: skill, skill_namespace: skillNamespace(skill), detected_by: "tool_call", skill_call_id: callId });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const item of Object.values(value)) collectOpencodeExplicitSkillUsages(item, out);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function detectOpencodeSkillUsages(source, knownSkills = []) {
|
|
92
148
|
const skills = normalizeSkillNames(knownSkills);
|
|
93
149
|
if (skills.length === 0) return [];
|
|
94
|
-
const haystack = collectStrings(source).join("\n");
|
|
95
150
|
const found = [];
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
151
|
+
const explicitSeen = new Set();
|
|
152
|
+
const explicit = [];
|
|
153
|
+
for (const usage of collectOpencodeExplicitSkillUsages(source).filter((item) => skills.includes(item.name))) {
|
|
154
|
+
if (usage.skill_call_id) {
|
|
155
|
+
const key = `call:${usage.skill_call_id}`;
|
|
156
|
+
if (explicitSeen.has(key)) continue;
|
|
157
|
+
explicitSeen.add(key);
|
|
158
|
+
}
|
|
159
|
+
explicit.push(usage);
|
|
160
|
+
}
|
|
161
|
+
found.push(...explicit);
|
|
162
|
+
const haystack = collectStrings(source).join("\n");
|
|
163
|
+
const pathSeen = new Set();
|
|
164
|
+
for (const match of haystack.matchAll(/(?:^|["'\s])(?:[A-Za-z]:)?[^"'\n\r]*[\\/]+([^\\/"'\n\r]+)[\\/]+SKILL\.md(?=$|["'\s])/gi)) {
|
|
165
|
+
const skillName = match[1];
|
|
166
|
+
if (!skills.includes(skillName) || explicit.some((usage) => usage.name === skillName) || pathSeen.has(skillName)) continue;
|
|
167
|
+
pathSeen.add(skillName);
|
|
168
|
+
found.push({ name: skillName, skill_namespace: skillNamespace(skillName), detected_by: "skill_file_path", skill_call_id: "" });
|
|
99
169
|
}
|
|
100
170
|
return found;
|
|
101
171
|
}
|
|
@@ -132,13 +202,19 @@ export function buildInteractionMetadata(options = {}) {
|
|
|
132
202
|
const sessionId = String(options.sessionId || options.session_id || "unknown");
|
|
133
203
|
const turnNumber = Number(options.turnNumber ?? options.turn_number ?? 0) || 0;
|
|
134
204
|
const tokenMetrics = normalizeTokenMetrics(options.tokenMetrics);
|
|
135
|
-
const
|
|
136
|
-
const
|
|
205
|
+
const skillUseEvents = normalizeSkillUseEvents(options.skillUseEvents ?? options.skill_use_events);
|
|
206
|
+
const skillNamesAll = skillUseEvents.length
|
|
207
|
+
? skillUseEvents.map((event) => event.skill_name)
|
|
208
|
+
: normalizeSkillNames(options.skillNames ?? options.skill_names);
|
|
209
|
+
const skillNames = normalizeSkillNames(skillNamesAll);
|
|
210
|
+
const skillUseCount = skillUseEvents.length || skillNamesAll.length || Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
|
|
137
211
|
const toolCallCount = Math.max(Number(options.toolCallCount ?? options.tool_call_count ?? 0) || 0, skillUseCount);
|
|
138
212
|
const toolResultCount = Math.max(Number(options.toolResultCount ?? options.tool_result_count ?? 0) || 0, skillUseCount);
|
|
213
|
+
const uniqueSkillCount = skillNames.length;
|
|
139
214
|
|
|
140
215
|
const metadata = {
|
|
141
216
|
source,
|
|
217
|
+
agent: source,
|
|
142
218
|
user_id: String(options.userId || options.user_id || ""),
|
|
143
219
|
session_id: sessionId,
|
|
144
220
|
interaction_id: options.interactionId || buildInteractionId(source, sessionId, turnNumber),
|
|
@@ -149,6 +225,8 @@ export function buildInteractionMetadata(options = {}) {
|
|
|
149
225
|
tool_call_count: toolCallCount,
|
|
150
226
|
tool_result_count: toolResultCount,
|
|
151
227
|
skill_use_count: skillUseCount,
|
|
228
|
+
unique_skill_count: uniqueSkillCount,
|
|
229
|
+
repeated_skill_count: Math.max(0, skillUseCount - uniqueSkillCount),
|
|
152
230
|
...tokenMetrics,
|
|
153
231
|
model: options.model || null,
|
|
154
232
|
turn_number: turnNumber,
|
|
@@ -163,12 +241,57 @@ export function buildInteractionMetadata(options = {}) {
|
|
|
163
241
|
|
|
164
242
|
if (skillNames.length) {
|
|
165
243
|
metadata.skill_names = skillNames;
|
|
166
|
-
|
|
244
|
+
}
|
|
245
|
+
if (skillNamesAll.length) {
|
|
246
|
+
metadata.skill_names_all = skillNamesAll;
|
|
167
247
|
}
|
|
168
248
|
|
|
169
249
|
return metadata;
|
|
170
250
|
}
|
|
171
251
|
|
|
252
|
+
function normalizeSkillUseEvents(events) {
|
|
253
|
+
if (!Array.isArray(events)) return [];
|
|
254
|
+
return events
|
|
255
|
+
.filter((event) => event && typeof event === "object" && event.skill_name)
|
|
256
|
+
.map((event) => ({ ...event }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function buildSkillUseEvents(options = {}) {
|
|
260
|
+
const interactionId = String(options.interactionId ?? options.interaction_id ?? "unknown");
|
|
261
|
+
const usages = dedupeSkillUsages(normalizeSkillUsages(options.skillUsages ?? options.skill_usages));
|
|
262
|
+
const total = usages.length;
|
|
263
|
+
return usages.map((usage, index) => {
|
|
264
|
+
const skillUseIndex = index + 1;
|
|
265
|
+
const detectedBy = usage.detected_by;
|
|
266
|
+
return {
|
|
267
|
+
skill_use_id: `${interactionId}:skill:${skillUseIndex}:${skillIdSegment(usage.name)}`,
|
|
268
|
+
skill_use_index: skillUseIndex,
|
|
269
|
+
skill_use_count_in_interaction: total,
|
|
270
|
+
skill_event_type: skillEventType(detectedBy),
|
|
271
|
+
skill_trigger: "unknown",
|
|
272
|
+
skill_name: usage.name,
|
|
273
|
+
skill_namespace: usage.skill_namespace,
|
|
274
|
+
detected_by: detectedBy,
|
|
275
|
+
...(usage.skill_call_id ? { skill_call_id: usage.skill_call_id } : {}),
|
|
276
|
+
skill_use_count: 1,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function dedupeSkillUsages(usages) {
|
|
282
|
+
const out = [];
|
|
283
|
+
const seenCallIds = new Set();
|
|
284
|
+
for (const usage of usages) {
|
|
285
|
+
if (usage.skill_call_id) {
|
|
286
|
+
const key = `call:${usage.skill_call_id}`;
|
|
287
|
+
if (seenCallIds.has(key)) continue;
|
|
288
|
+
seenCallIds.add(key);
|
|
289
|
+
}
|
|
290
|
+
out.push(usage);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
172
295
|
export function buildOpencodeMetricAttributes(options = {}) {
|
|
173
296
|
const attrs = options.attributes || {};
|
|
174
297
|
const userId = String(options.userId || attrs["oh.langfuse.user_id"] || attrs["langfuse.user.id"] || "");
|
|
@@ -178,6 +301,7 @@ export function buildOpencodeMetricAttributes(options = {}) {
|
|
|
178
301
|
|
|
179
302
|
return {
|
|
180
303
|
"langfuse.observation.metadata.source": "opencode",
|
|
304
|
+
"langfuse.observation.metadata.agent": "opencode",
|
|
181
305
|
"langfuse.observation.metadata.user_id": userId,
|
|
182
306
|
"langfuse.observation.metadata.metrics_schema_version": METRICS_SCHEMA_VERSION,
|
|
183
307
|
"langfuse.observation.metadata.model": model,
|