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.
@@ -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
- "where npx >nul 2>nul",
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
- "if command -v npx >/dev/null 2>&1; then",
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 = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
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 agentLauncher = createAgentLauncher({ baseDir: codexHome, target: "codex", executable: "codex" });
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 expectedHookCommands(launcher) {
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 ? "launcher command" : hook ? "legacy command form" : "missing",
132
- "Run setup again. Claude Code should execute the generated run-langfuse-hook launcher directly."
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 `cmd.exe /d /s /c ${cmdQuote(launcher)}`;
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
- "where npx >nul 2>nul",
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
- "if command -v npx >/dev/null 2>&1; then",
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 agentLauncher = createAgentLauncher({ baseDir: claudeDir, target: "claude", executable: "claude" });
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.0";
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 escapeRegExp(value) {
68
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
- for (const skillName of skills) {
97
- const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(skillName)}([^A-Za-z0-9_-]|$)`, "i");
98
- if (pattern.test(haystack)) found.push(skillName);
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 skillNames = normalizeSkillNames(options.skillNames ?? options.skill_names);
136
- const skillUseCount = skillNames.length || Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
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
- metadata.skill_names_json = JSON.stringify(skillNames);
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,