oh-langfuse 0.1.77 → 0.1.79

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.
@@ -1,1604 +1,1697 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
- import { spawn, spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import { parseJsonRelaxed, stripBom } from "./json-utils.mjs";
7
- import { shouldSuppressAgentLogLine } from "./log-filter-utils.mjs";
8
- import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
9
- import { resolveOpencodeCli } from "./resolve-opencode-cli.mjs";
10
-
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseJsonRelaxed, stripBom } from "./json-utils.mjs";
7
+ import { shouldSuppressAgentLogLine } from "./log-filter-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
9
+ import { resolveOpencodeCli } from "./resolve-opencode-cli.mjs";
10
+
11
11
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
12
12
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
13
13
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
14
  const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
15
+ const DEFAULT_LANGFUSE_BASE_URL = "https://metrics.openharmonyinsight.cn";
16
+ const LEGACY_LANGFUSE_BASE_URLS = new Set([
17
+ "http://120.46.221.227:3000",
18
+ "https://120.46.221.227:3000",
19
+ ]);
20
+
21
+ function normalizeLegacyBaseUrl(baseUrl) {
22
+ const value = String(baseUrl || "").trim().replace(/\/+$/, "");
23
+ return LEGACY_LANGFUSE_BASE_URLS.has(value) ? DEFAULT_LANGFUSE_BASE_URL : baseUrl;
24
+ }
15
25
 
16
26
  function normalizeUserId(v) {
17
27
  return String(v || "").trim();
18
28
  }
19
-
20
- function assertValidUserId(userId) {
21
- if (!USER_ID_PATTERN.test(normalizeUserId(userId))) {
22
- throw new Error(`工号格式不正确:--userId 必须匹配 ${USER_ID_PATTERN_TEXT},例如 h00613222 或 hwx1234567`);
23
- }
24
- }
25
-
26
- function parseArgs(argv) {
27
- const args = {};
28
- for (const raw of argv) {
29
- if (!raw.startsWith("--")) continue;
30
- const eq = raw.indexOf("=");
31
- if (eq === -1) {
32
- args[raw.slice(2)] = true;
33
- } else {
34
- args[raw.slice(2, eq)] = raw.slice(eq + 1);
35
- }
36
- }
37
- return args;
38
- }
39
-
40
- function ensureDir(p) {
41
- fs.mkdirSync(p, { recursive: true });
42
- }
43
-
44
- function readJsonIfExists(p) {
45
- if (!fs.existsSync(p)) return null;
46
- const txt = stripBom(fs.readFileSync(p, "utf8"));
47
- if (!txt.trim()) return null;
48
- return parseJsonRelaxed(txt, p);
49
- }
50
-
51
- function writeJsonPretty(p, obj) {
52
- fs.writeFileSync(p, JSON.stringify(obj, null, 2) + os.EOL, "utf8");
53
- }
54
-
55
- const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
56
- const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
57
- const colors = {
58
- reset: ansi(0),
59
- bold: ansi(1),
60
- cyan: ansi(36),
61
- green: ansi(32),
62
- yellow: ansi(33),
63
- dim: ansi(2)
64
- };
65
-
66
- function paint(text, ...styles) {
67
- if (!colorEnabled) return String(text);
68
- return `${styles.join("")}${text}${colors.reset}`;
69
- }
70
-
71
- function printCommandHint(label, command) {
72
- console.log(paint(label, colors.yellow, colors.bold));
73
- console.log(`${paint(">", colors.green, colors.bold)} ${paint(command, colors.cyan, colors.bold)}`);
74
- }
75
-
76
- function isObject(x) {
77
- return x && typeof x === "object" && !Array.isArray(x);
78
- }
79
-
80
- function deepMerge(target, src) {
81
- if (!isObject(target) || !isObject(src)) return src;
82
- const out = { ...target };
83
- for (const [k, v] of Object.entries(src)) {
84
- if (isObject(v) && isObject(out[k])) out[k] = deepMerge(out[k], v);
85
- else out[k] = v;
86
- }
87
- return out;
88
- }
89
-
90
- function mergePluginList(existing, name) {
91
- let arr = [];
92
- if (Array.isArray(existing)) arr = [...existing];
93
- else if (typeof existing === "string" && existing.trim()) arr = [existing];
94
- if (!arr.includes(name)) arr.push(name);
95
- return arr;
96
- }
97
-
98
- function removePlugins(existing, names) {
99
- const removeSet = new Set(names);
100
- if (Array.isArray(existing)) return existing.filter((x) => !removeSet.has(x));
101
- if (typeof existing === "string") return removeSet.has(existing) ? [] : [existing];
102
- return [];
103
- }
104
-
105
- function psQuote(s) {
106
- return `'${String(s).replace(/'/g, "''")}'`;
107
- }
108
-
109
- function cmdQuote(s) {
110
- return `"${String(s).replace(/"/g, '""')}"`;
111
- }
112
-
113
- function cmdSetValue(s) {
114
- return String(s).replace(/"/g, '""');
115
- }
116
-
117
- function writeLangfusePluginUserConfig({ userId }) {
118
- const home = os.homedir();
119
- const dir = path.join(home, ".config", "opencode-plugin-langfuse");
120
- const p = path.join(dir, "config.json");
121
- ensureDir(dir);
122
- const obj = { usrid: userId, userId };
123
- writeJsonPretty(p, obj);
124
- return p;
125
- }
126
-
127
- function getPatchedLangfuseDistIndexJs() {
128
- // This replaces opencode-plugin-langfuse/dist/index.js to inject userId into spans.
129
- // It is intentionally self-contained (no extra build steps).
130
- return [
131
- 'import { LangfuseSpanProcessor } from "@langfuse/otel";',
132
- 'import { configureGlobalLogger, LogLevel } from "@langfuse/core";',
133
- 'import { promises as fs } from "node:fs";',
134
- 'import os from "node:os";',
135
- 'import path from "node:path";',
136
- 'import { execFileSync } from "node:child_process";',
137
- 'import { NodeSDK } from "@opentelemetry/sdk-node";',
138
- 'import { trace } from "@opentelemetry/api";',
139
- "",
140
- "const USER_CONFIG_PATH = path.join(",
141
- " os.homedir(),",
142
- ' ".config",',
143
- ' "opencode-plugin-langfuse",',
144
- ' "config.json"',
145
- ");",
146
- "",
147
- "const readConfiguredUserId = async () => {",
148
- " try {",
149
- ' const content = await fs.readFile(USER_CONFIG_PATH, "utf8");',
150
- " // tolerate UTF-8 BOM",
151
- " const txt = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;",
152
- " const parsed = JSON.parse(txt);",
153
- " const userId =",
154
- ' typeof parsed.userId === "string"',
155
- " ? parsed.userId.trim()",
156
- ' : typeof parsed.usrid === "string"',
157
- " ? parsed.usrid.trim()",
158
- " : \"\";",
159
- " return userId || undefined;",
160
- " } catch {",
161
- " return undefined;",
162
- " }",
163
- "};",
164
- "",
165
- "const showNoisyLogs = /^(1|true|yes|on)$/i.test(String(process.env.OH_LANGFUSE_SHOW_LOGS || process.env.LANGFUSE_SHOW_LOGS || process.env.OH_LANGFUSE_SHOW_WARN || process.env.LANGFUSE_SHOW_WARN || ''));",
166
- "if (!showNoisyLogs) {",
167
- " process.env.LANGFUSE_LOG_LEVEL = 'NONE';",
168
- " configureGlobalLogger({ level: LogLevel.NONE });",
169
- "}",
170
- "",
171
- "const createUserIdSpanProcessor = (userId) => ({",
172
- " onStart: (span) => {",
173
- ' span.setAttribute("langfuse.user.id", userId);',
174
- ' span.setAttribute("user.id", userId);',
175
- ' span.setAttribute("ai.telemetry.metadata.userId", userId);',
176
- ' span.setAttribute("oh.langfuse.source", "opencode");',
177
- ' span.setAttribute("oh.langfuse.user_id", userId);',
178
- ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
179
- ' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
180
- ' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
181
- ' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
182
- ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
183
- ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
184
- ' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
185
- ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
186
- " },",
187
- " onEnd: (span) => {",
188
- ' if (typeof span.setAttribute !== "function") return;',
189
- ' span.setAttribute("langfuse.user.id", userId);',
190
- ' span.setAttribute("user.id", userId);',
191
- ' span.setAttribute("ai.telemetry.metadata.userId", userId);',
192
- ' span.setAttribute("oh.langfuse.source", "opencode");',
193
- ' span.setAttribute("oh.langfuse.user_id", userId);',
194
- ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
195
- ' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
196
- ' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
197
- ' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
198
- ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
199
- ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
200
- ' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
201
- ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
202
- " },",
203
- " forceFlush: async () => {},",
204
- " shutdown: async () => {},",
205
- "});",
206
- "",
207
- "const metricNumber = (value) => {",
208
- ' if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;',
209
- ' if (typeof value === "string" && value.trim().startsWith("{")) {',
210
- " try {",
211
- " const parsed = JSON.parse(value);",
212
- " return metricNumber(parsed.intValue ?? parsed.doubleValue ?? parsed.value);",
213
- " } catch {",
214
- " return undefined;",
215
- " }",
216
- " }",
217
- " const n = Number(value);",
218
- " return Number.isFinite(n) && n >= 0 ? n : undefined;",
219
- "};",
220
- "",
221
- "const writeSpanAttribute = (span, key, value) => {",
222
- " if (value === undefined || value === null) return;",
223
- ' if (typeof span.setAttribute === "function") {',
224
- " span.setAttribute(key, value);",
225
- " return;",
226
- " }",
227
- ' if (span.attributes && typeof span.attributes === "object") {',
228
- " span.attributes[key] = value;",
229
- " }",
230
- "};",
231
- "",
232
- "const writeMetric = (span, key, value) => writeSpanAttribute(span, `langfuse.observation.metadata.${key}`, value);",
233
- "",
234
- "const emptyRepoContext = (cwd = '', cwdSource = 'unavailable', available = false, error = '', isGitRepo = false) => ({",
235
- " cwd: String(cwd || ''),",
236
- " cwd_source: cwdSource || 'unavailable',",
237
- " git_context_available: Boolean(available),",
238
- " git_context_error: error || '',",
239
- " is_git_repo: Boolean(isGitRepo),",
240
- " git_root: '',",
241
- " git_remote: '',",
242
- " git_remote_host: '',",
243
- " git_repo_owner: '',",
244
- " git_repo_name: '',",
245
- " git_repo_slug: '',",
246
- " git_branch: '',",
247
- " git_commit: '',",
248
- "});",
249
- "",
250
- "const sanitizeGitRemote = (remote) => {",
251
- " const empty = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
252
- " const raw = String(remote || '').trim();",
253
- " if (!raw) return empty;",
254
- " try {",
255
- " let host = '';",
256
- " let parts = [];",
257
- " if (raw.includes('://')) {",
258
- " const parsed = new URL(raw);",
259
- " host = parsed.hostname;",
260
- " parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
261
- " } else {",
262
- " const scp = raw.match(/^[^@]+@([^:]+):(.+)$/);",
263
- " if (scp) {",
264
- " host = scp[1];",
265
- " parts = scp[2].replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
266
- " } else {",
267
- " const parsed = new URL(`ssh://${raw}`);",
268
- " host = parsed.hostname;",
269
- " parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
270
- " }",
271
- " }",
272
- " if (!host || parts.length < 2) return empty;",
273
- " const owner = parts[parts.length - 2];",
274
- " const repo = parts[parts.length - 1].replace(/\\.git$/i, '');",
275
- " if (!owner || !repo) return empty;",
276
- " const slug = `${owner}/${repo}`;",
277
- " return { git_remote: `${host}/${slug}`, git_remote_host: host, git_repo_owner: owner, git_repo_name: repo, git_repo_slug: slug };",
278
- " } catch {",
279
- " return empty;",
280
- " }",
281
- "};",
282
- "",
283
- "const runGit = (cwd, args) => {",
284
- " try {",
285
- " const stdout = execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 500, stdio: ['ignore', 'pipe', 'ignore'] });",
286
- " return { ok: true, stdout: String(stdout || '').trim(), error: '' };",
287
- " } catch (error) {",
288
- " if (error && error.code === 'ETIMEDOUT') return { ok: false, stdout: '', error: 'timeout' };",
289
- " if (error && error.code === 'ENOENT') return { ok: false, stdout: '', error: 'git_unavailable' };",
290
- " return { ok: false, stdout: '', error: 'git_error' };",
291
- " }",
292
- "};",
293
- "",
294
- "const repoContextCache = new Map();",
295
- 'const collectRepoContext = (cwd, cwdSource = "process") => {',
296
- " const cwdText = String(cwd || '').trim();",
297
- " if (!cwdText) return emptyRepoContext('', 'unavailable', false, 'missing_cwd');",
298
- " const now = Date.now();",
299
- " const cached = repoContextCache.get(cwdText);",
300
- " if (cached && now - cached.time <= 30000) return { ...cached.context, cwd_source: cwdSource || cached.context.cwd_source };",
301
- " let context = emptyRepoContext(cwdText, cwdSource, false, '', false);",
302
- " const inside = runGit(cwdText, ['rev-parse', '--is-inside-work-tree']);",
303
- " if (!inside.ok) {",
304
- " const reason = ['timeout', 'git_unavailable'].includes(inside.error) ? inside.error : '';",
305
- " context = reason ? emptyRepoContext(cwdText, cwdSource, false, reason, false) : emptyRepoContext(cwdText, cwdSource, true, '', false);",
306
- " repoContextCache.set(cwdText, { time: now, context });",
307
- " return context;",
308
- " }",
309
- " if (inside.stdout.toLowerCase() !== 'true') {",
310
- " context = emptyRepoContext(cwdText, cwdSource, true, '', false);",
311
- " repoContextCache.set(cwdText, { time: now, context });",
312
- " return context;",
313
- " }",
314
- " context = emptyRepoContext(cwdText, cwdSource, true, '', true);",
315
- " const root = runGit(cwdText, ['rev-parse', '--show-toplevel']);",
316
- " if (!root.ok && ['timeout', 'git_unavailable'].includes(root.error)) {",
317
- " context.git_context_available = false;",
318
- " context.git_context_error = root.error;",
319
- " repoContextCache.set(cwdText, { time: now, context });",
320
- " return context;",
321
- " }",
322
- " context.git_root = root.stdout || '';",
323
- " const remote = runGit(cwdText, ['config', '--get', 'remote.origin.url']);",
324
- " if (remote.ok && remote.stdout) {",
325
- " const sanitized = sanitizeGitRemote(remote.stdout);",
326
- " context = { ...context, ...sanitized };",
327
- " if (!sanitized.git_remote) context.git_context_error = 'remote_parse_failed';",
328
- " } else if (['timeout', 'git_unavailable'].includes(remote.error)) {",
329
- " context.git_context_available = false;",
330
- " context.git_context_error = remote.error;",
331
- " }",
332
- " const branch = runGit(cwdText, ['branch', '--show-current']);",
333
- " if (branch.ok) context.git_branch = branch.stdout || '';",
334
- " const commit = runGit(cwdText, ['rev-parse', '--short', 'HEAD']);",
335
- " if (commit.ok) context.git_commit = commit.stdout || '';",
336
- " repoContextCache.set(cwdText, { time: now, context });",
337
- " return context;",
338
- "};",
339
- "",
340
- "const writeRepoContextMetrics = (span, context) => {",
341
- " const repoContext = context || {};",
342
- " writeSpanAttribute(span, 'langfuse.observation.metadata.cwd', repoContext.cwd);",
343
- " writeSpanAttribute(span, 'langfuse.observation.metadata.cwd_source', repoContext.cwd_source);",
344
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_available', repoContext.git_context_available);",
345
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_error', repoContext.git_context_error);",
346
- " writeSpanAttribute(span, 'langfuse.observation.metadata.is_git_repo', repoContext.is_git_repo);",
347
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_root', repoContext.git_root);",
348
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote', repoContext.git_remote);",
349
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote_host', repoContext.git_remote_host);",
350
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_owner', repoContext.git_repo_owner);",
351
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_name', repoContext.git_repo_name);",
352
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_slug', repoContext.git_repo_slug);",
353
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_branch', repoContext.git_branch);",
354
- " writeSpanAttribute(span, 'langfuse.observation.metadata.git_commit', repoContext.git_commit);",
355
- "};",
356
- "",
357
- "const writeOpencodeMetricAttributes = (span, userId) => {",
358
- " const attrs = span.attributes ?? {};",
359
- ' writeSpanAttribute(span, "oh.langfuse.source", "opencode");',
360
- ' writeSpanAttribute(span, "oh.langfuse.metrics_schema_version", "1.1");',
361
- ' if (userId) writeSpanAttribute(span, "oh.langfuse.user_id", userId);',
362
- ' writeMetric(span, "source", "opencode");',
363
- ' writeMetric(span, "agent", "opencode");',
364
- ' writeMetric(span, "metrics_schema_version", "1.1");',
365
- ' if (userId) writeMetric(span, "user_id", userId);',
366
- ' const hasModel = attrs["ai.model.id"] || attrs["ai.model.provider"] || attrs["gen_ai.request.model"];',
367
- ' const isTool = attrs["ai.toolCall.name"] || attrs["ai.toolCall.id"];',
368
- " if (!hasModel || isTool) return false;",
369
- ' const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";',
370
- ' const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";',
371
- ' const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || undefined;',
372
- ' writeMetric(span, "model", model);',
373
- " return true;",
374
- "};",
375
- "",
376
- "const createMetricsSpanProcessor = (userId) => ({",
377
- " onStart: (span) => {",
378
- " writeOpencodeMetricAttributes(span, userId);",
379
- " },",
380
- " onEnd: (span) => {",
381
- " writeOpencodeMetricAttributes(span, userId);",
382
- " },",
383
- " forceFlush: async () => {},",
384
- " shutdown: async () => {},",
385
- "});",
386
- "",
387
- "const eventPayload = (event) => event?.properties ?? event?.data ?? event?.payload ?? event?.body ?? event ?? {};",
388
- "const eventPart = (event) => {",
389
- " const payload = eventPayload(event);",
390
- " return payload.part ?? payload.properties?.part ?? payload.data?.part ?? payload.payload?.part ?? event?.part ?? event?.data?.part ?? event?.payload?.part ?? payload;",
391
- "};",
392
- "const pickEventString = (...values) => {",
393
- " for (const value of values) {",
394
- ' if (typeof value === "string" && value.trim()) return value.trim();',
395
- " }",
396
- ' return "";',
397
- "};",
398
- "const countOpencodeToolActivity = (event) => {",
399
- " const payload = eventPayload(event);",
400
- " const part = eventPart(event);",
401
- " const eventType = pickEventString(event?.name, event?.type, payload?.name, payload?.type);",
402
- " const partType = pickEventString(part?.type);",
403
- " const toolName = pickEventString(part?.tool, part?.toolName, part?.name, payload?.tool, payload?.toolName, payload?.name);",
404
- " const callId = pickEventString(part?.callID, part?.callId, payload?.callID, payload?.callId, part?.id);",
405
- " const status = pickEventString(part?.state?.status, part?.status, payload?.state?.status, payload?.status).toLowerCase();",
406
- " const hasToolInput = part?.state?.input !== undefined || part?.input !== undefined || payload?.input !== undefined;",
407
- " const hasToolOutput = part?.state?.output !== undefined || part?.output !== undefined || payload?.output !== undefined;",
408
- " const isToolCall = eventType === 'tool_use' || eventType === 'tool_result' || partType === 'tool' || partType === 'tool_use' || Boolean(toolName) || hasToolInput;",
409
- " const isToolResult = eventType === 'tool_result' || status === 'completed' || status === 'success' || hasToolOutput;",
410
- " return {",
411
- " toolCallCount: isToolCall ? 1 : 0,",
412
- " toolResultCount: isToolCall && isToolResult ? 1 : 0,",
413
- " toolCallId: callId || '',",
414
- " };",
415
- "};",
416
- "const tokenMetricsFromPart = (part) => {",
417
- " const tokens = part?.tokens ?? part?.usage ?? {};",
418
- " return {",
419
- " input: metricNumber(tokens.input ?? tokens.input_tokens ?? tokens.inputTokens),",
420
- " output: metricNumber(tokens.output ?? tokens.output_tokens ?? tokens.outputTokens),",
421
- " total: metricNumber(tokens.total ?? tokens.total_tokens ?? tokens.totalTokens),",
422
- " cacheRead: metricNumber(tokens.cache?.read ?? tokens.cacheRead ?? tokens.cache_read_tokens ?? tokens.cachedInputTokens),",
423
- " reasoning: metricNumber(tokens.reasoning ?? tokens.reasoning_tokens ?? tokens.reasoningTokens),",
424
- " };",
425
- "};",
426
- "const OBSERVATION_TEXT_LIMIT = 20000;",
427
- "const limitObservationText = (text) => {",
428
- " const normalized = typeof text === 'string' ? text.trim() : '';",
429
- " if (!normalized) return '';",
430
- " return normalized.length > OBSERVATION_TEXT_LIMIT ? `${normalized.slice(0, OBSERVATION_TEXT_LIMIT)}\\n...[truncated]` : normalized;",
431
- "};",
432
- "const textFromContent = (value, depth = 0) => {",
433
- " if (depth > 6 || value === null || value === undefined) return '';",
434
- " if (typeof value === 'string') return value.trim();",
435
- " if (Array.isArray(value)) return value.map((item) => textFromContent(item, depth + 1)).filter(Boolean).join('\\n').trim();",
436
- " if (typeof value !== 'object') return '';",
437
- " for (const key of ['text', 'content', 'prompt', 'message', 'input', 'output', 'parts']) {",
438
- " const text = textFromContent(value[key], depth + 1);",
439
- " if (text) return text;",
440
- " }",
441
- " return '';",
442
- "};",
443
- "const promptFromArgv = () => {",
444
- " const args = process.argv.slice(2);",
445
- " if (!args.includes('run')) return '';",
446
- " for (let i = args.length - 1; i >= 0; i -= 1) {",
447
- " const arg = args[i];",
448
- " if (typeof arg === 'string' && arg.trim() && !arg.startsWith('-')) return arg.trim();",
449
- " }",
450
- " return '';",
451
- "};",
452
- "",
453
- "const normalizeSkillNames = (names) => {",
454
- " if (!Array.isArray(names)) return [];",
455
- " const out = [];",
456
- " const seen = new Set();",
457
- " for (const raw of names) {",
458
- " const name = String(raw || '').trim();",
459
- " if (!name || seen.has(name)) continue;",
460
- " seen.add(name);",
461
- " out.push(name);",
462
- " }",
463
- " return out;",
464
- "};",
465
- "const skillNamespace = (name) => String(name || '').includes(':') ? String(name).split(':', 1)[0] : '';",
466
- "const skillAgentPath = (detectedBy) => {",
467
- " if (detectedBy === 'tool_call') return 'opencode_skill_tool';",
468
- " if (detectedBy === 'slash_command') return 'opencode_slash_prompt';",
469
- " if (detectedBy === 'natural_language_request') return 'opencode_prompt_skill_name';",
470
- " if (detectedBy === 'skill_file_path') return 'skill_file_path';",
471
- " return detectedBy || 'metadata';",
472
- "};",
473
- "const skillInvocationMode = (detectedBy) => {",
474
- " if (detectedBy === 'slash_command') return 'explicit_request';",
475
- " if (detectedBy === 'natural_language_request') return 'implicit_request';",
476
- " if (detectedBy === 'tool_call' || detectedBy === 'plugin_event') return 'implicit';",
477
- " return 'detected';",
478
- "};",
479
- "const skillEventType = (detectedBy) => (detectedBy === 'slash_command' || detectedBy === 'natural_language_request') ? 'requested' : (detectedBy === 'tool_call' || detectedBy === 'plugin_event' ? 'invoked' : 'detected');",
480
- "const skillIdSegment = (name) => String(name || 'unknown').trim().replace(/[^A-Za-z0-9_.:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96) || 'unknown';",
481
- "",
482
- "const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');",
483
- "const normalizeExplicitSkillCommand = (value) => {",
484
- " const command = pickEventString(value).replace(/^\\/+/, '').trim();",
485
- " if (!command) return '';",
486
- " return command.split(/\\s+/, 1)[0];",
487
- "};",
488
- "",
489
- "const collectStrings = (value, out = []) => {",
490
- " if (value == null) return out;",
491
- " if (typeof value === 'string') {",
492
- " out.push(value);",
493
- " return out;",
494
- " }",
495
- " if (typeof value === 'number' || typeof value === 'boolean') return out;",
496
- " if (Array.isArray(value)) {",
497
- " for (const item of value) collectStrings(item, out);",
498
- " return out;",
499
- " }",
500
- " if (typeof value === 'object') {",
501
- " for (const [key, item] of Object.entries(value)) {",
502
- " out.push(key);",
503
- " collectStrings(item, out);",
504
- " }",
505
- " }",
506
- " return out;",
507
- "};",
508
- "",
509
- "const collectExplicitSkillUsages = (value, out = []) => {",
510
- " if (value == null) return out;",
511
- " if (Array.isArray(value)) {",
512
- " for (const item of value) collectExplicitSkillUsages(item, out);",
513
- " return out;",
514
- " }",
515
- " if (typeof value !== 'object') return out;",
516
- " const command = normalizeExplicitSkillCommand(value.command ?? value.commandName ?? value.promptCommand ?? value.properties?.command ?? value.properties?.commandName);",
517
- " if (command) {",
518
- " const callId = pickEventString(value.commandID, value.commandId, value.id);",
519
- " out.push({ name: command, skill_namespace: skillNamespace(command), detected_by: 'slash_command', skill_call_id: callId });",
520
- " }",
521
- " const toolName = pickEventString(value.tool, value.toolName, value.name).toLowerCase();",
522
- " const input = value.input ?? value.state?.input ?? value.properties?.input;",
523
- " if (toolName === 'skill' && input && typeof input === 'object' && !Array.isArray(input)) {",
524
- " const skill = pickEventString(input.skill_name, input.skill, input.name);",
525
- " const callId = pickEventString(value.callID, value.callId, value.id, value.toolCallID, value.toolCallId);",
526
- " if (skill) out.push({ name: skill, skill_namespace: skillNamespace(skill), detected_by: 'tool_call', skill_call_id: callId });",
527
- " }",
528
- " for (const item of Object.values(value)) collectExplicitSkillUsages(item, out);",
529
- " return out;",
530
- "};",
531
- "",
532
- "const detectPromptSkillRequests = (haystack, skills) => {",
533
- " const text = String(haystack || '');",
534
- " if (!text.trim()) return [];",
535
- " const out = [];",
536
- " for (const skillName of skills) {",
537
- " const escaped = escapeRegExp(skillName);",
538
- " const pattern = new RegExp(`(?:请\\\\s*)?(?:使用|调用|启用|采用)\\\\s*[\\\"'“”‘’]?${escaped}(?=$|[\\\\s,,。;;::、])|\\\\b(?:use|invoke|run|apply)\\\\s+[\\\"']?${escaped}(?=$|[\\\\s,,。;;::])`, 'i');",
539
- " if (pattern.test(text)) out.push({ name: skillName, skill_namespace: skillNamespace(skillName), detected_by: 'natural_language_request', skill_call_id: '' });",
540
- " }",
541
- " return out;",
542
- "};",
543
- "",
544
- "const detectOpencodeSkillUsages = (source, knownSkills = []) => {",
545
- " const skills = normalizeSkillNames(knownSkills);",
546
- " if (skills.length === 0) return [];",
547
- " const found = [];",
548
- " const explicitSeen = new Set();",
549
- " const explicit = [];",
550
- " for (const usage of collectExplicitSkillUsages(source).filter((item) => skills.includes(item.name))) {",
551
- " if (usage.skill_call_id) {",
552
- " const key = `call:${usage.skill_call_id}`;",
553
- " if (explicitSeen.has(key)) continue;",
554
- " explicitSeen.add(key);",
555
- " } else if (usage.detected_by === 'slash_command') {",
556
- " const key = `command:${usage.name}`;",
557
- " if (explicitSeen.has(key)) continue;",
558
- " explicitSeen.add(key);",
559
- " }",
560
- " explicit.push(usage);",
561
- " }",
562
- " found.push(...explicit);",
563
- " const haystack = collectStrings(source).join('\\n');",
564
- " const promptSeen = new Set(explicit.map((usage) => usage.name));",
565
- " for (const usage of detectPromptSkillRequests(haystack, skills)) {",
566
- " if (promptSeen.has(usage.name)) continue;",
567
- " promptSeen.add(usage.name);",
568
- " found.push(usage);",
569
- " }",
570
- " const pathSeen = new Set();",
571
- " for (const match of haystack.matchAll(/(?:^|[\"'\\s])(?:[A-Za-z]:)?[^\"'\\n\\r]*[\\\\/]+([^\\\\/\"'\\n\\r]+)[\\\\/]+SKILL\\.md(?=$|[\"'\\s])/gi)) {",
572
- " const skillName = match[1];",
573
- " if (!skills.includes(skillName) || explicit.some((usage) => usage.name === skillName) || pathSeen.has(skillName)) continue;",
574
- " pathSeen.add(skillName);",
575
- " found.push({ name: skillName, skill_namespace: skillNamespace(skillName), detected_by: 'skill_file_path', skill_call_id: '' });",
576
- " }",
577
- " return found;",
578
- "};",
579
- "const uniqueSkillNames = (usages) => normalizeSkillNames((usages || []).map((usage) => usage.name));",
580
- "const dedupeSkillUsages = (usages) => {",
581
- " const out = [];",
582
- " const seen = new Set();",
583
- " for (const usage of usages || []) {",
584
- " const key = usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`;",
585
- " if (seen.has(key)) continue;",
586
- " seen.add(key);",
587
- " out.push(usage);",
588
- " }",
589
- " return out;",
590
- "};",
591
- "const buildSkillUseEvents = (interactionId, skillUsages) => {",
592
- " const usages = dedupeSkillUsages(skillUsages);",
593
- " const total = usages.length;",
594
- " return usages.map((usage, index) => {",
595
- " const skillName = String(usage.name || '').trim();",
596
- " const detectedBy = String(usage.detected_by || 'metadata');",
597
- " const invocationMode = skillInvocationMode(detectedBy);",
598
- " return {",
599
- " skill_use_id: `${interactionId}:skill:${index + 1}:${skillIdSegment(skillName)}`,",
600
- " skill_use_index: index + 1,",
601
- " skill_use_count_in_interaction: total,",
602
- " skill_event_type: skillEventType(detectedBy),",
603
- " skill_trigger: invocationMode,",
604
- " skill_invocation_mode: invocationMode,",
605
- " skill_agent_path: skillAgentPath(detectedBy),",
606
- " skill_name: skillName,",
607
- " skill_use_count: 1,",
608
- " skill_namespace: usage.skill_namespace || skillNamespace(skillName),",
609
- " detected_by: detectedBy,",
610
- " ...(usage.skill_call_id ? { skill_call_id: usage.skill_call_id } : {}),",
611
- " };",
612
- " }).filter((event) => event.skill_name);",
613
- "};",
614
- "",
615
- "const collectKnownSkillNames = async () => {",
616
- " const dirs = [",
617
- " path.join(process.cwd(), '.opencode', 'skill'),",
618
- " path.join(process.cwd(), '.opencode', 'skills'),",
619
- " path.join(os.homedir(), '.config', 'opencode', 'skill'),",
620
- " path.join(os.homedir(), '.config', 'opencode', 'skills'),",
621
- " path.join(os.homedir(), '.opencode', 'skill'),",
622
- " path.join(os.homedir(), '.opencode', 'skills'),",
623
- " ];",
624
- " const names = new Set();",
625
- " for (const dir of dirs) {",
626
- " try {",
627
- " const entries = await fs.readdir(dir, { withFileTypes: true });",
628
- " for (const entry of entries) {",
629
- " if (!entry.isDirectory() || entry.name.startsWith('.')) continue;",
630
- " try {",
631
- " await fs.access(path.join(dir, entry.name, 'SKILL.md'));",
632
- " names.add(entry.name);",
633
- " } catch {",
634
- " // Ignore helper folders that are not OpenCode skills.",
635
- " }",
636
- " }",
637
- " } catch {",
638
- " // Optional skill directories are allowed to be absent.",
639
- " }",
640
- " }",
641
- " return [...names];",
642
- "};",
643
- "",
644
- "export const LangfusePlugin = async ({ client }) => {",
645
- " const publicKey = process.env.LANGFUSE_PUBLIC_KEY;",
646
- " const secretKey = process.env.LANGFUSE_SECRET_KEY;",
647
- ' const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com";',
648
- ' const environment = process.env.LANGFUSE_ENVIRONMENT ?? "development";',
649
- "",
650
- " const userIdFromConfig = await readConfiguredUserId();",
651
- " const userIdEnv = process.env.LANGFUSE_USER_ID?.trim();",
652
- " const userId = userIdEnv || userIdFromConfig;",
653
- "",
654
- " const log = (level, message) => {",
655
- " if ((level === 'warn' || level === 'error') && !showNoisyLogs) return;",
656
- ' client.app.log({ body: { service: "langfuse-otel", level, message } });',
657
- " };",
658
- "",
659
- " if (!publicKey || !secretKey) {",
660
- ' log("warn", "Missing LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY - tracing disabled");',
661
- " return {};",
662
- " }",
663
- "",
664
- " const processor = new LangfuseSpanProcessor({ publicKey, secretKey, baseUrl, environment });",
665
- " const spanProcessors = [processor, createMetricsSpanProcessor(userId)];",
666
- " if (userId) spanProcessors.push(createUserIdSpanProcessor(userId));",
667
- "",
668
- " const sdk = new NodeSDK({ spanProcessors });",
669
- " const sdkStartPromise = Promise.resolve().then(() => sdk.start()).catch((err) => {",
670
- ' log("warn", `OTEL SDK start failed: ${err?.message ?? err}`);',
671
- " });",
672
- " const getMetricsTracer = () => trace.getTracer('oh-langfuse-opencode-metrics');",
673
- " const knownSkillNames = await collectKnownSkillNames();",
674
- " const startupSkillUsages = detectOpencodeSkillUsages(process.argv.join('\\n'), knownSkillNames);",
675
- " const startupPromptText = limitObservationText(promptFromArgv());",
676
- " const messageTextById = new Map();",
677
- " const messageInputById = new Map();",
678
- " const lastUserTextBySessionId = new Map();",
679
- " const skillUsagesByMessageId = new Map();",
680
- " const skillUsagesBySessionId = new Map();",
681
- " const startupSkillSessionIds = new Set();",
682
- " const toolCallIdsByMessageId = new Map();",
683
- " const toolCallIdsBySessionId = new Map();",
684
- " const toolResultIdsByMessageId = new Map();",
685
- " const toolResultIdsBySessionId = new Map();",
686
- " const emittedMessageIds = new Set();",
687
- "",
688
- ' log("info", `OTEL tracing initialized -> ${baseUrl}`);',
689
- ' if (userId) log("info", `LANGFUSE userId configured -> ${userId}`);',
690
- ' if (knownSkillNames.length) log("info", `OpenCode skills discovered -> ${knownSkillNames.length}`);',
691
- "",
692
- " let shutdownStarted = false;",
693
- " const flush = async (reason) => {",
694
- " try {",
695
- ' log("info", `Flushing OTEL spans on ${reason}`);',
696
- " await sdkStartPromise;",
697
- " await processor.forceFlush();",
698
- " } catch (error) {",
699
- ' log("warn", `OTEL forceFlush failed on ${reason}: ${error?.message ?? error}`);',
700
- " }",
701
- " };",
702
- " const shutdown = async (reason) => {",
703
- " if (shutdownStarted) return;",
704
- " shutdownStarted = true;",
705
- " try {",
706
- ' log("info", `Shutting down OTEL SDK on ${reason}`);',
707
- " await sdkStartPromise;",
708
- " await sdk.shutdown();",
709
- " } catch (error) {",
710
- ' log("warn", `OTEL shutdown failed on ${reason}: ${error?.message ?? error}`);',
711
- " }",
712
- " };",
713
- " process.once('beforeExit', async () => {",
714
- " await flush('process.beforeExit');",
715
- " await shutdown('process.beforeExit');",
716
- " });",
717
- " const originalExit = process.exit.bind(process);",
718
- " process.exit = ((code) => {",
719
- " void shutdown('process.exit').finally(() => originalExit(code));",
720
- " });",
721
- "",
722
- " const rememberSkillUsages = (map, key, usages) => {",
723
- " if (!key || !usages.length) return;",
724
- " let list = map.get(key);",
725
- " if (!list) {",
726
- " list = [];",
727
- " map.set(key, list);",
728
- " }",
729
- " const seen = new Set(list.map((usage) => usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`));",
730
- " for (const usage of usages) {",
731
- " const key = usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`;",
732
- " if (seen.has(key)) continue;",
733
- " seen.add(key);",
734
- " list.push(usage);",
735
- " }",
736
- " };",
737
- " const rememberToolActivity = (map, key, activity, kind) => {",
738
- " if (!key || !activity?.[kind]) return;",
739
- " let set = map.get(key);",
740
- " if (!set) {",
741
- " set = new Set();",
742
- " map.set(key, set);",
743
- " }",
744
- " set.add(activity.toolCallId || `${kind}:${set.size + 1}`);",
745
- " };",
746
- " const recordInteractionMetric = async (event) => {",
747
- " const payload = eventPayload(event);",
748
- " const part = eventPart(event);",
749
- " const partType = part?.type ?? '';",
750
- " const sessionId = pickEventString(part?.sessionID, part?.sessionId, payload?.sessionID, payload?.sessionId, payload?.session?.id, event?.sessionID, event?.sessionId);",
751
- " const messageId = pickEventString(part?.messageID, part?.messageId, payload?.messageID, payload?.messageId, payload?.message?.id, event?.messageID, event?.messageId);",
752
- " const role = pickEventString(part?.role, payload?.role, payload?.message?.role, event?.role).toLowerCase();",
753
- " const eventText = textFromContent(part) || textFromContent(payload?.message) || textFromContent(payload);",
754
- " if (eventText && role === 'user') {",
755
- " const inputText = limitObservationText(eventText);",
756
- " if (messageId) messageInputById.set(messageId, inputText);",
757
- " if (sessionId) lastUserTextBySessionId.set(sessionId, inputText);",
758
- " return;",
759
- " }",
760
- " if (sessionId && startupSkillUsages.length && !startupSkillSessionIds.has(sessionId)) {",
761
- " startupSkillSessionIds.add(sessionId);",
762
- " rememberSkillUsages(skillUsagesBySessionId, sessionId, startupSkillUsages);",
763
- " }",
764
- " const skillDetectionSources = [part, payload, part?.state?.input, part?.input, payload?.input, part?.state?.metadata, payload?.metadata, part?.state?.file, part?.file];",
765
- " const eventSkillUsages = detectOpencodeSkillUsages(skillDetectionSources, knownSkillNames);",
766
- " rememberSkillUsages(skillUsagesByMessageId, messageId, eventSkillUsages);",
767
- " rememberSkillUsages(skillUsagesBySessionId, sessionId, eventSkillUsages);",
768
- " const toolActivity = countOpencodeToolActivity(event);",
769
- " rememberToolActivity(toolCallIdsByMessageId, messageId, toolActivity, 'toolCallCount');",
770
- " rememberToolActivity(toolCallIdsBySessionId, sessionId, toolActivity, 'toolCallCount');",
771
- " rememberToolActivity(toolResultIdsByMessageId, messageId, toolActivity, 'toolResultCount');",
772
- " rememberToolActivity(toolResultIdsBySessionId, sessionId, toolActivity, 'toolResultCount');",
773
- " if (eventText && messageId && (role === 'assistant' || partType === 'text')) {",
774
- " messageTextById.set(messageId, limitObservationText(eventText));",
775
- " const textSkillUsages = detectOpencodeSkillUsages(eventText, knownSkillNames);",
776
- " rememberSkillUsages(skillUsagesByMessageId, messageId, textSkillUsages);",
777
- " rememberSkillUsages(skillUsagesBySessionId, sessionId, textSkillUsages);",
778
- " return;",
779
- " }",
780
- " if (partType !== 'step-finish' || !messageId || emittedMessageIds.has(messageId)) return;",
781
- " emittedMessageIds.add(messageId);",
782
- " const tokenMetrics = tokenMetricsFromPart(part);",
783
- " const total = tokenMetrics.total ?? (tokenMetrics.input !== undefined && tokenMetrics.output !== undefined ? tokenMetrics.input + tokenMetrics.output : undefined);",
784
- " const tokenAvailable = [tokenMetrics.input, tokenMetrics.output, total, tokenMetrics.cacheRead, tokenMetrics.reasoning].some((value) => value !== undefined);",
785
- " await sdkStartPromise;",
786
- " const span = getMetricsTracer().startSpan('OpenCode Agent Turn');",
787
- " const text = messageTextById.get(messageId) || '';",
788
- " const inputText = messageInputById.get(messageId) || lastUserTextBySessionId.get(sessionId) || startupPromptText || '';",
789
- " const skillUsages = dedupeSkillUsages([...(skillUsagesByMessageId.get(messageId) ?? []), ...(skillUsagesBySessionId.get(sessionId) ?? [])]);",
790
- " const interactionId = `opencode:${userId || \"unknown\"}:${sessionId || \"unknown\"}:${messageId}`;",
791
- " const skillUseEvents = buildSkillUseEvents(interactionId, skillUsages);",
792
- " const skillNames = uniqueSkillNames(skillUsages);",
793
- " const skillNamesAll = skillUseEvents.map((event) => event.skill_name);",
794
- " const skillInvocationModes = skillUseEvents.map((event) => event.skill_invocation_mode).filter(Boolean);",
795
- " const skillAgentPaths = skillUseEvents.map((event) => event.skill_agent_path).filter(Boolean);",
796
- " const toolCallCount = Math.max(new Set([...(toolCallIdsByMessageId.get(messageId) ?? []), ...(toolCallIdsBySessionId.get(sessionId) ?? [])]).size, skillUseEvents.length);",
797
- " const toolResultCount = Math.max(new Set([...(toolResultIdsByMessageId.get(messageId) ?? []), ...(toolResultIdsBySessionId.get(sessionId) ?? [])]).size, skillUseEvents.length);",
798
- ' span.setAttribute("oh.langfuse.source", "opencode");',
799
- ' span.setAttribute("oh.langfuse.user_id", userId || "");',
800
- ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
801
- ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
802
- ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
803
- ' span.setAttribute("langfuse.observation.metadata.user_id", userId || "");',
804
- ' span.setAttribute("langfuse.observation.metadata.session_id", sessionId || "unknown");',
805
- ' span.setAttribute("langfuse.observation.metadata.interaction_id", interactionId);',
806
- ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
807
- ' span.setAttribute("langfuse.observation.metadata.interaction_count", 1);',
808
- ' span.setAttribute("langfuse.observation.metadata.user_message_count", 1);',
809
- ' span.setAttribute("langfuse.observation.metadata.assistant_message_count", 1);',
810
- ' span.setAttribute("langfuse.observation.metadata.token_metrics_available", tokenAvailable);',
811
- ' span.setAttribute("langfuse.observation.metadata.tool_call_count", toolCallCount);',
812
- ' span.setAttribute("langfuse.observation.metadata.tool_result_count", toolResultCount);',
813
- ' span.setAttribute("langfuse.observation.metadata.skill_use_count", skillUseEvents.length);',
814
- ' span.setAttribute("langfuse.observation.metadata.unique_skill_count", skillNames.length);',
815
- ' span.setAttribute("langfuse.observation.metadata.repeated_skill_count", Math.max(0, skillUseEvents.length - skillNames.length));',
816
- ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names", skillNames);',
817
- ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names_csv", skillNames.join(","));',
818
- ' if (skillNamesAll.length) span.setAttribute("langfuse.observation.metadata.skill_names_all", skillNamesAll);',
819
- ' if (skillInvocationModes.length) span.setAttribute("langfuse.observation.metadata.skill_invocation_modes", skillInvocationModes);',
820
- ' if (skillAgentPaths.length) span.setAttribute("langfuse.observation.metadata.skill_agent_paths", skillAgentPaths);',
821
- ' if (tokenMetrics.input !== undefined) span.setAttribute("langfuse.observation.metadata.input_tokens", tokenMetrics.input);',
822
- ' if (tokenMetrics.output !== undefined) span.setAttribute("langfuse.observation.metadata.output_tokens", tokenMetrics.output);',
823
- ' if (total !== undefined) span.setAttribute("langfuse.observation.metadata.total_tokens", total);',
824
- ' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
825
- ' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
826
- ' if (inputText) span.setAttribute("langfuse.observation.input", inputText);',
827
- ' if (inputText) span.setAttribute("langfuse.trace.input", inputText);',
828
- ' if (inputText) span.setAttribute("input.value", inputText);',
829
- ' if (text) span.setAttribute("langfuse.observation.output", text);',
830
- ' if (text) span.setAttribute("langfuse.trace.output", text);',
831
- ' if (text) span.setAttribute("output.value", text);',
832
- ' if (inputText) span.setAttribute("langfuse.observation.metadata.input_text_preview", inputText.slice(0, 512));',
833
- ' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
834
- ' writeRepoContextMetrics(span, collectRepoContext(process.cwd(), "process"));',
835
- " span.end();",
836
- " messageTextById.delete(messageId);",
837
- " messageInputById.delete(messageId);",
838
- " skillUsagesByMessageId.delete(messageId);",
839
- " skillUsagesBySessionId.delete(sessionId);",
840
- " toolCallIdsByMessageId.delete(messageId);",
841
- " toolCallIdsBySessionId.delete(sessionId);",
842
- " toolResultIdsByMessageId.delete(messageId);",
843
- " toolResultIdsBySessionId.delete(sessionId);",
844
- " };",
845
- "",
846
- " return {",
847
- " config: async (config) => {",
848
- " if (!config.experimental?.openTelemetry) {",
849
- ' log("warn", "OpenTelemetry experimental feature is disabled in Opencode config - tracing disabled");',
850
- " }",
851
- " },",
852
- " event: async ({ event }) => {",
853
- " const eventType = pickEventString(event?.name, event?.type, eventPayload(event)?.name, eventPayload(event)?.type);",
854
- " await recordInteractionMetric(event);",
855
- ' if (eventType === "session.idle" || eventType === "message.updated" || eventType === "message.part.updated" || eventType === "session.updated" || eventType === "session.idle.1" || eventType === "message.updated.1" || eventType === "message.part.updated.1" || eventType === "session.updated.1") {',
856
- " await flush(eventType);",
857
- " }",
858
- ' if (eventType === "server.instance.disposed") {',
859
- " await shutdown('server.instance.disposed');",
860
- " }",
861
- " },",
862
- " };",
863
- "};",
864
- ""
865
- ].join(os.EOL);
866
- }
867
-
868
- function patchDistIndexJs(distIndexPath) {
869
- const code = getPatchedLangfuseDistIndexJs();
870
- ensureDir(path.dirname(distIndexPath));
871
- fs.writeFileSync(distIndexPath, code + os.EOL, "utf8");
872
- }
873
-
874
- function syncPluginPackageToLocalPlugins({ pkgDir, pluginDest, pluginsDir }) {
875
- ensureDir(pluginsDir);
876
- if (fs.existsSync(pluginDest)) {
877
- fs.rmSync(pluginDest, { recursive: true, force: true });
878
- }
879
- fs.cpSync(pkgDir, pluginDest, { recursive: true });
880
- }
881
-
882
- function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, userId }) {
883
- if (process.platform !== "win32") return null;
884
- const p = path.join(opencodeDir, "launch-opencode-langfuse.cmd");
885
- writeWindowsUpdateCheckScript(opencodeDir);
886
- const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
887
- cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
888
- cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
889
- cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
890
- if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
891
- cmd.push('powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0oh-langfuse-opencode-update-check.ps1"');
892
- cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
893
- cmd.push(' "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
894
- cmd.push(" exit /b");
895
- cmd.push(")");
896
- cmd.push("opencode %*");
897
- fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
898
- return p;
899
- }
900
-
901
- function shQuote(s) {
902
- return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
903
- }
904
-
905
- function autoUpdateRuntimePath() {
906
- return path.join(rootDir, "scripts", "auto-update-runtime.mjs");
907
- }
908
-
909
- function autoUpdateHelperDir() {
910
- return path.join(os.homedir(), ".config", "oh-langfuse", "bin");
911
- }
912
-
913
- function writeAutoUpdateHelper(target) {
914
- const helperDir = autoUpdateHelperDir();
915
- ensureDir(helperDir);
916
- const runtimePath = autoUpdateRuntimePath();
917
- const fallbackArgs = ["-y", "--registry=https://registry.npmjs.org/", "oh-langfuse@latest", "auto-update", target, "--skip-check", "--startup-status"];
918
-
919
- if (process.platform === "win32") {
920
- const helper = path.join(helperDir, `oh-langfuse-auto-update-${target}.cmd`);
921
- const lines = [
922
- "@echo off",
923
- "REM Auto-generated by scripts/opencode-langfuse-setup.mjs",
924
- `if exist ${cmdQuote(runtimePath)} (`,
925
- ` call ${cmdQuote(process.execPath)} ${cmdQuote(runtimePath)} ${target} --skip-check --startup-status %*`,
926
- ") else (",
927
- ` call npx.cmd ${fallbackArgs.map(cmdQuote).join(" ")} %*`,
928
- ")",
929
- "exit /b 0",
930
- ""
931
- ];
932
- fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
933
- return helper;
934
- }
935
-
936
- const helper = path.join(helperDir, `oh-langfuse-auto-update-${target}`);
937
- const lines = [
938
- "#!/usr/bin/env sh",
939
- "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
940
- "set +e",
941
- `if [ -f ${shQuote(runtimePath)} ]; then`,
942
- ` ${shQuote(process.execPath)} ${shQuote(runtimePath)} ${shQuote(target)} --skip-check --startup-status "$@"`,
943
- "else",
944
- ` npx ${fallbackArgs.map(shQuote).join(" ")} "$@"`,
945
- "fi",
946
- "exit 0",
947
- ""
948
- ];
949
- fs.writeFileSync(helper, lines.join("\n"), "utf8");
950
- fs.chmodSync(helper, 0o755);
951
- return helper;
952
- }
953
-
954
- function windowsAutoUpdateCommand(target) {
955
- return `call ${cmdQuote(writeAutoUpdateHelper(target))}`;
956
- }
957
-
958
- function unixAutoUpdateCommand(target) {
959
- return `${shQuote(writeAutoUpdateHelper(target))} || true`;
960
- }
961
-
962
- function windowsGitPathBootstrap() {
963
- return [
964
- "for /f \"delims=\" %%G in ('where.exe git.exe 2^>nul') do (",
965
- " set \"OH_LANGFUSE_GIT_EXE=%%G\"",
966
- " set \"npm_config_git=%%G\"",
967
- " set \"PATH=%%~dpG;%PATH%\"",
968
- " goto :oh_langfuse_git_ready",
969
- ")",
970
- ":oh_langfuse_git_ready"
971
- ];
972
- }
973
-
29
+
30
+ function assertValidUserId(userId) {
31
+ if (!USER_ID_PATTERN.test(normalizeUserId(userId))) {
32
+ throw new Error(`工号格式不正确:--userId 必须匹配 ${USER_ID_PATTERN_TEXT},例如 h00613222 或 hwx1234567`);
33
+ }
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const args = {};
38
+ for (const raw of argv) {
39
+ if (!raw.startsWith("--")) continue;
40
+ const eq = raw.indexOf("=");
41
+ if (eq === -1) {
42
+ args[raw.slice(2)] = true;
43
+ } else {
44
+ args[raw.slice(2, eq)] = raw.slice(eq + 1);
45
+ }
46
+ }
47
+ return args;
48
+ }
49
+
50
+ function ensureDir(p) {
51
+ fs.mkdirSync(p, { recursive: true });
52
+ }
53
+
54
+ function readJsonIfExists(p) {
55
+ if (!fs.existsSync(p)) return null;
56
+ const txt = stripBom(fs.readFileSync(p, "utf8"));
57
+ if (!txt.trim()) return null;
58
+ return parseJsonRelaxed(txt, p);
59
+ }
60
+
61
+ function writeJsonPretty(p, obj) {
62
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + os.EOL, "utf8");
63
+ }
64
+
65
+ const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
66
+ const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
67
+ const colors = {
68
+ reset: ansi(0),
69
+ bold: ansi(1),
70
+ cyan: ansi(36),
71
+ green: ansi(32),
72
+ yellow: ansi(33),
73
+ dim: ansi(2)
74
+ };
75
+
76
+ function paint(text, ...styles) {
77
+ if (!colorEnabled) return String(text);
78
+ return `${styles.join("")}${text}${colors.reset}`;
79
+ }
80
+
81
+ function printCommandHint(label, command) {
82
+ console.log(paint(label, colors.yellow, colors.bold));
83
+ console.log(`${paint(">", colors.green, colors.bold)} ${paint(command, colors.cyan, colors.bold)}`);
84
+ }
85
+
86
+ function isObject(x) {
87
+ return x && typeof x === "object" && !Array.isArray(x);
88
+ }
89
+
90
+ function deepMerge(target, src) {
91
+ if (!isObject(target) || !isObject(src)) return src;
92
+ const out = { ...target };
93
+ for (const [k, v] of Object.entries(src)) {
94
+ if (isObject(v) && isObject(out[k])) out[k] = deepMerge(out[k], v);
95
+ else out[k] = v;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function mergePluginList(existing, name) {
101
+ let arr = [];
102
+ if (Array.isArray(existing)) arr = [...existing];
103
+ else if (typeof existing === "string" && existing.trim()) arr = [existing];
104
+ if (!arr.includes(name)) arr.push(name);
105
+ return arr;
106
+ }
107
+
108
+ function removePlugins(existing, names) {
109
+ const removeSet = new Set(names);
110
+ if (Array.isArray(existing)) return existing.filter((x) => !removeSet.has(x));
111
+ if (typeof existing === "string") return removeSet.has(existing) ? [] : [existing];
112
+ return [];
113
+ }
114
+
115
+ function psQuote(s) {
116
+ return `'${String(s).replace(/'/g, "''")}'`;
117
+ }
118
+
119
+ function cmdQuote(s) {
120
+ return `"${String(s).replace(/"/g, '""')}"`;
121
+ }
122
+
123
+ function cmdSetValue(s) {
124
+ return String(s).replace(/"/g, '""');
125
+ }
126
+
127
+ function writeLangfusePluginUserConfig({ userId }) {
128
+ const home = os.homedir();
129
+ const dir = path.join(home, ".config", "opencode-plugin-langfuse");
130
+ const p = path.join(dir, "config.json");
131
+ ensureDir(dir);
132
+ const obj = { usrid: userId, userId };
133
+ writeJsonPretty(p, obj);
134
+ return p;
135
+ }
136
+
137
+ export function getPatchedLangfuseDistIndexJs() {
138
+ // This replaces opencode-plugin-langfuse/dist/index.js to inject userId into spans.
139
+ // It is intentionally self-contained (no extra build steps).
140
+ return [
141
+ 'import { LangfuseSpanProcessor } from "@langfuse/otel";',
142
+ 'import { configureGlobalLogger, LogLevel } from "@langfuse/core";',
143
+ 'import fs, { promises as fsPromise } from "node:fs";',
144
+ 'import os from "node:os";',
145
+ 'import path from "node:path";',
146
+ 'import { execFileSync } from "node:child_process";',
147
+ 'import { NodeSDK } from "@opentelemetry/sdk-node";',
148
+ 'import { trace } from "@opentelemetry/api";',
149
+ "",
150
+ "const USER_CONFIG_PATH = path.join(",
151
+ " os.homedir(),",
152
+ ' ".config",',
153
+ ' "opencode-plugin-langfuse",',
154
+ ' "config.json"',
155
+ ");",
156
+ "",
157
+ "const readConfiguredUserId = async () => {",
158
+ " try {",
159
+ ' const content = await fsPromise.readFile(USER_CONFIG_PATH, "utf8");',
160
+ " // tolerate UTF-8 BOM",
161
+ " const txt = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;",
162
+ " const parsed = JSON.parse(txt);",
163
+ " const userId =",
164
+ ' typeof parsed.userId === "string"',
165
+ " ? parsed.userId.trim()",
166
+ ' : typeof parsed.usrid === "string"',
167
+ " ? parsed.usrid.trim()",
168
+ " : \"\";",
169
+ " return userId || undefined;",
170
+ " } catch {",
171
+ " return undefined;",
172
+ " }",
173
+ "};",
174
+ "",
175
+ "const showNoisyLogs = /^(1|true|yes|on)$/i.test(String(process.env.OH_LANGFUSE_SHOW_LOGS || process.env.LANGFUSE_SHOW_LOGS || process.env.OH_LANGFUSE_SHOW_WARN || process.env.LANGFUSE_SHOW_WARN || ''));",
176
+ "if (!showNoisyLogs) {",
177
+ " process.env.LANGFUSE_LOG_LEVEL = 'NONE';",
178
+ " configureGlobalLogger({ level: LogLevel.NONE });",
179
+ "}",
180
+ "",
181
+ "const createUserIdSpanProcessor = (userId) => ({",
182
+ " onStart: (span) => {",
183
+ ' span.setAttribute("langfuse.user.id", userId);',
184
+ ' span.setAttribute("user.id", userId);',
185
+ ' span.setAttribute("ai.telemetry.metadata.userId", userId);',
186
+ ' span.setAttribute("oh.langfuse.source", "opencode");',
187
+ ' span.setAttribute("oh.langfuse.user_id", userId);',
188
+ ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
189
+ ' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
190
+ ' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
191
+ ' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
192
+ ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
193
+ ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
194
+ ' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
195
+ ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
196
+ " },",
197
+ " onEnd: (span) => {",
198
+ ' if (typeof span.setAttribute !== "function") return;',
199
+ ' span.setAttribute("langfuse.user.id", userId);',
200
+ ' span.setAttribute("user.id", userId);',
201
+ ' span.setAttribute("ai.telemetry.metadata.userId", userId);',
202
+ ' span.setAttribute("oh.langfuse.source", "opencode");',
203
+ ' span.setAttribute("oh.langfuse.user_id", userId);',
204
+ ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
205
+ ' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
206
+ ' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
207
+ ' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
208
+ ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
209
+ ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
210
+ ' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
211
+ ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
212
+ " },",
213
+ " forceFlush: async () => {},",
214
+ " shutdown: async () => {},",
215
+ "});",
216
+ "",
217
+ "const metricNumber = (value) => {",
218
+ ' if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;',
219
+ ' if (typeof value === "string" && value.trim().startsWith("{")) {',
220
+ " try {",
221
+ " const parsed = JSON.parse(value);",
222
+ " return metricNumber(parsed.intValue ?? parsed.doubleValue ?? parsed.value);",
223
+ " } catch {",
224
+ " return undefined;",
225
+ " }",
226
+ " }",
227
+ " const n = Number(value);",
228
+ " return Number.isFinite(n) && n >= 0 ? n : undefined;",
229
+ "};",
230
+ "",
231
+ "const writeSpanAttribute = (span, key, value) => {",
232
+ " if (value === undefined || value === null) return;",
233
+ ' if (typeof span.setAttribute === "function") {',
234
+ " span.setAttribute(key, value);",
235
+ " return;",
236
+ " }",
237
+ ' if (span.attributes && typeof span.attributes === "object") {',
238
+ " span.attributes[key] = value;",
239
+ " }",
240
+ "};",
241
+ "",
242
+ "const writeMetric = (span, key, value) => writeSpanAttribute(span, `langfuse.observation.metadata.${key}`, value);",
243
+ "",
244
+ "const emptyRepoContext = (cwd = '', cwdSource = 'unavailable', available = false, error = '', isGitRepo = false) => ({",
245
+ " cwd: String(cwd || ''),",
246
+ " cwd_source: cwdSource || 'unavailable',",
247
+ " git_context_available: Boolean(available),",
248
+ " git_context_error: error || '',",
249
+ " is_git_repo: Boolean(isGitRepo),",
250
+ " git_root: '',",
251
+ " git_remote: '',",
252
+ " git_remote_host: '',",
253
+ " git_repo_owner: '',",
254
+ " git_repo_name: '',",
255
+ " git_repo_slug: '',",
256
+ " git_branch: '',",
257
+ " git_commit: '',",
258
+ "});",
259
+ "",
260
+ "const sanitizeGitRemote = (remote) => {",
261
+ " const empty = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
262
+ " const raw = String(remote || '').trim();",
263
+ " if (!raw) return empty;",
264
+ " try {",
265
+ " let host = '';",
266
+ " let parts = [];",
267
+ " if (raw.includes('://')) {",
268
+ " const parsed = new URL(raw);",
269
+ " host = parsed.hostname;",
270
+ " parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
271
+ " } else {",
272
+ " const scp = raw.match(/^[^@]+@([^:]+):(.+)$/);",
273
+ " if (scp) {",
274
+ " host = scp[1];",
275
+ " parts = scp[2].replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
276
+ " } else {",
277
+ " const parsed = new URL(`ssh://${raw}`);",
278
+ " host = parsed.hostname;",
279
+ " parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
280
+ " }",
281
+ " }",
282
+ " if (!host || parts.length < 2) return empty;",
283
+ " const owner = parts[parts.length - 2];",
284
+ " const repo = parts[parts.length - 1].replace(/\\.git$/i, '');",
285
+ " if (!owner || !repo) return empty;",
286
+ " const slug = `${owner}/${repo}`;",
287
+ " return { git_remote: `${host}/${slug}`, git_remote_host: host, git_repo_owner: owner, git_repo_name: repo, git_repo_slug: slug };",
288
+ " } catch {",
289
+ " return empty;",
290
+ " }",
291
+ "};",
292
+ "",
293
+ "const runGit = (cwd, args) => {",
294
+ " try {",
295
+ " const stdout = execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 500, stdio: ['ignore', 'pipe', 'ignore'] });",
296
+ " return { ok: true, stdout: String(stdout || '').trim(), error: '' };",
297
+ " } catch (error) {",
298
+ " if (error && error.code === 'ETIMEDOUT') return { ok: false, stdout: '', error: 'timeout' };",
299
+ " if (error && error.code === 'ENOENT') return { ok: false, stdout: '', error: 'git_unavailable' };",
300
+ " return { ok: false, stdout: '', error: 'git_error' };",
301
+ " }",
302
+ "};",
303
+ "",
304
+ "const repoContextCache = new Map();",
305
+ 'const DISK_CACHE_TTL = 1000 * 60 * 15; // 15 minutes — covers typical conversations',
306
+ 'const cwdToCacheKey = (cwd) => {',
307
+ " const text = String(cwd || '').trim();",
308
+ " if (!text) return '';",
309
+ " return Buffer.from(text, 'utf8').toString('base64url').replace(/[^A-Za-z0-9_-]/g, '_');",
310
+ '};',
311
+ '',
312
+ 'const getDiskCachePath = (cwd) => {',
313
+ ' const key = cwdToCacheKey(cwd);',
314
+ " if (!key) return '';",
315
+ " const dir = path.join(os.homedir(), '.opencode', 'langfuse-cache');",
316
+ " return path.join(dir, `git-context-${key}.json`);",
317
+ '};',
318
+ '',
319
+ 'const readDiskCache = (cachePath) => {',
320
+ ' try {',
321
+ " if (!fs.existsSync(cachePath)) return null;",
322
+ " const raw = fs.readFileSync(cachePath, 'utf8');",
323
+ ' const parsed = JSON.parse(raw);',
324
+ ' if (parsed && typeof parsed === \'object\' && parsed.time && parsed.context) return parsed;',
325
+ ' } catch {}',
326
+ ' return null;',
327
+ '};',
328
+ '',
329
+ 'const writeDiskCache = (cachePath, payload) => {',
330
+ ' try {',
331
+ ' if (!cachePath) return;',
332
+ ' const dir = path.dirname(cachePath);',
333
+ " if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });",
334
+ ' fs.writeFileSync(cachePath, JSON.stringify(payload));',
335
+ ' } catch {}',
336
+ '};',
337
+ '',
338
+ 'const isCacheValid = (parsed) => parsed && typeof parsed === \'object\' && parsed.time && parsed.context && Date.now() - parsed.time <= DISK_CACHE_TTL;',
339
+ '',
340
+ 'const collectRepoContext = (cwd, cwdSource = "process") => {',
341
+ " const cwdText = String(cwd || '').trim();",
342
+ " if (!cwdText) return emptyRepoContext('', 'unavailable', false, 'missing_cwd');",
343
+ ' const now = Date.now();',
344
+ ' const memCached = repoContextCache.get(cwdText);',
345
+ ' if (memCached && now - memCached.time <= 30000) return { ...memCached.context, cwd_source: cwdSource || memCached.context.cwd_source };',
346
+ ' const cachePath = getDiskCachePath(cwdText);',
347
+ ' let staleFallback = null;',
348
+ ' const diskCached = cachePath ? readDiskCache(cachePath) : null;',
349
+ ' if (diskCached && diskCached.context && diskCached.cwd === cwdText) {',
350
+ ' if (isCacheValid(diskCached)) {',
351
+ ' repoContextCache.set(cwdText, diskCached);',
352
+ ' return { ...diskCached.context, cwd_source: cwdSource || diskCached.context.cwd_source };',
353
+ ' }',
354
+ ' staleFallback = diskCached;',
355
+ ' }',
356
+ " let context = emptyRepoContext(cwdText, cwdSource, false, '', false);",
357
+ " const inside = runGit(cwdText, ['rev-parse', '--is-inside-work-tree']);",
358
+ ' if (!inside.ok) {',
359
+ " const reason = ['timeout', 'git_unavailable'].includes(inside.error) ? inside.error : '';",
360
+ " context = reason ? emptyRepoContext(cwdText, cwdSource, false, reason, false) : emptyRepoContext(cwdText, cwdSource, true, '', false);",
361
+ ' repoContextCache.set(cwdText, { time: now, context });',
362
+ ' if (staleFallback && staleFallback.context) return { ...staleFallback.context, cwd_source: cwdSource || staleFallback.context.cwd_source };',
363
+ ' return context;',
364
+ ' }',
365
+ " if (inside.stdout.toLowerCase() !== 'true') {",
366
+ " context = emptyRepoContext(cwdText, cwdSource, true, '', false);",
367
+ ' repoContextCache.set(cwdText, { time: now, context });',
368
+ ' return context;',
369
+ ' }',
370
+ " context = emptyRepoContext(cwdText, cwdSource, true, '', true);",
371
+ " const root = runGit(cwdText, ['rev-parse', '--show-toplevel']);",
372
+ " if (!root.ok && ['timeout', 'git_unavailable'].includes(root.error)) {\n context.git_context_available = false;\n context.git_context_error = root.error;\n repoContextCache.set(cwdText, { time: now, context });",
373
+ ' if (staleFallback && staleFallback.context) return { ...staleFallback.context, cwd_source: cwdSource || staleFallback.context.cwd_source };',
374
+ ' return context;',
375
+ ' }',
376
+ " context.git_root = root.stdout || '';",
377
+ " let sanitized = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
378
+ " let remoteError = '';",
379
+ " for (const name of ['origin', 'gitcode', 'upstream']) {",
380
+ " const r = runGit(cwdText, ['config', '--get', `remote.${name}.url`]);",
381
+ " if (r.ok && r.stdout) {",
382
+ " sanitized = sanitizeGitRemote(r.stdout);",
383
+ " if (sanitized.git_remote) break;",
384
+ " sanitized = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
385
+ " } else if (['timeout', 'git_unavailable'].includes(r.error)) {",
386
+ " remoteError = r.error;",
387
+ " }",
388
+ " }",
389
+ " if (!sanitized.git_remote) {",
390
+ " const list = runGit(cwdText, ['remote']);",
391
+ " if (list.ok && list.stdout) {",
392
+ " const first = (list.stdout.split(/\\r?\\n/).find(l => l.trim()) || '').trim();",
393
+ " if (first) {",
394
+ " const r = runGit(cwdText, ['config', '--get', `remote.${first}.url`]);",
395
+ " if (r.ok && r.stdout) sanitized = sanitizeGitRemote(r.stdout);",
396
+ " }",
397
+ " }",
398
+ " }",
399
+ " if (sanitized.git_remote) {",
400
+ " context = { ...context, ...sanitized };",
401
+ " } else if (['timeout', 'git_unavailable'].includes(remoteError)) {",
402
+ " context.git_context_available = false;",
403
+ " context.git_context_error = remoteError;",
404
+ " }",
405
+ " const branch = runGit(cwdText, ['branch', '--show-current']);",
406
+ " if (branch.ok) context.git_branch = branch.stdout || '';",
407
+ " const commit = runGit(cwdText, ['rev-parse', '--short', 'HEAD']);",
408
+ " if (commit.ok) context.git_commit = commit.stdout || '';",
409
+ ' const payload = { time: now, cwd: cwdText, context };',
410
+ ' repoContextCache.set(cwdText, payload);',
411
+ ' if (cachePath) writeDiskCache(cachePath, payload);',
412
+ ' return context;',
413
+ '};',
414
+ '',
415
+ "const writeRepoContextMetrics = (span, context) => {",
416
+ " const repoContext = context || {};",
417
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.cwd', repoContext.cwd);",
418
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.cwd_source', repoContext.cwd_source);",
419
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_available', repoContext.git_context_available);",
420
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_error', repoContext.git_context_error);",
421
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.is_git_repo', repoContext.is_git_repo);",
422
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_root', repoContext.git_root);",
423
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote', repoContext.git_remote);",
424
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote_host', repoContext.git_remote_host);",
425
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_owner', repoContext.git_repo_owner);",
426
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_name', repoContext.git_repo_name);",
427
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_slug', repoContext.git_repo_slug);",
428
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_branch', repoContext.git_branch);",
429
+ " writeSpanAttribute(span, 'langfuse.observation.metadata.git_commit', repoContext.git_commit);",
430
+ "};",
431
+ "",
432
+ "const writeOpencodeMetricAttributes = (span, userId) => {",
433
+ " const attrs = span.attributes ?? {};",
434
+ ' writeSpanAttribute(span, "oh.langfuse.source", "opencode");',
435
+ ' writeSpanAttribute(span, "oh.langfuse.metrics_schema_version", "1.1");',
436
+ ' if (userId) writeSpanAttribute(span, "oh.langfuse.user_id", userId);',
437
+ ' writeMetric(span, "source", "opencode");',
438
+ ' writeMetric(span, "agent", "opencode");',
439
+ ' writeMetric(span, "metrics_schema_version", "1.1");',
440
+ ' if (userId) writeMetric(span, "user_id", userId);',
441
+ ' const hasModel = attrs["ai.model.id"] || attrs["ai.model.provider"] || attrs["gen_ai.request.model"];',
442
+ ' const isTool = attrs["ai.toolCall.name"] || attrs["ai.toolCall.id"];',
443
+ " if (!hasModel || isTool) return false;",
444
+ ' const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";',
445
+ ' const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";',
446
+ ' const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || undefined;',
447
+ ' writeMetric(span, "model", model);',
448
+ " return true;",
449
+ "};",
450
+ "",
451
+ "const createMetricsSpanProcessor = (userId) => ({",
452
+ " onStart: (span) => {",
453
+ " writeOpencodeMetricAttributes(span, userId);",
454
+ " },",
455
+ " onEnd: (span) => {",
456
+ " writeOpencodeMetricAttributes(span, userId);",
457
+ " },",
458
+ " forceFlush: async () => {},",
459
+ " shutdown: async () => {},",
460
+ "});",
461
+ "",
462
+ "const eventPayload = (event) => event?.properties ?? event?.data ?? event?.payload ?? event?.body ?? event ?? {};",
463
+ "const eventPart = (event) => {",
464
+ " const payload = eventPayload(event);",
465
+ " return payload.part ?? payload.properties?.part ?? payload.data?.part ?? payload.payload?.part ?? event?.part ?? event?.data?.part ?? event?.payload?.part ?? payload;",
466
+ "};",
467
+ "const pickEventString = (...values) => {",
468
+ " for (const value of values) {",
469
+ ' if (typeof value === "string" && value.trim()) return value.trim();',
470
+ " }",
471
+ ' return "";',
472
+ "};",
473
+ "const countOpencodeToolActivity = (event) => {",
474
+ " const payload = eventPayload(event);",
475
+ " const part = eventPart(event);",
476
+ " const eventType = pickEventString(event?.name, event?.type, payload?.name, payload?.type);",
477
+ " const partType = pickEventString(part?.type);",
478
+ " const toolName = pickEventString(part?.tool, part?.toolName, part?.name, payload?.tool, payload?.toolName, payload?.name);",
479
+ " const callId = pickEventString(part?.callID, part?.callId, payload?.callID, payload?.callId, part?.id);",
480
+ " const status = pickEventString(part?.state?.status, part?.status, payload?.state?.status, payload?.status).toLowerCase();",
481
+ " const hasToolInput = part?.state?.input !== undefined || part?.input !== undefined || payload?.input !== undefined;",
482
+ " const hasToolOutput = part?.state?.output !== undefined || part?.output !== undefined || payload?.output !== undefined;",
483
+ " const isToolCall = eventType === 'tool_use' || eventType === 'tool_result' || partType === 'tool' || partType === 'tool_use' || Boolean(toolName) || hasToolInput;",
484
+ " const isToolResult = eventType === 'tool_result' || status === 'completed' || status === 'success' || hasToolOutput;",
485
+ " return {",
486
+ " toolCallCount: isToolCall ? 1 : 0,",
487
+ " toolResultCount: isToolCall && isToolResult ? 1 : 0,",
488
+ " toolCallId: callId || '',",
489
+ " };",
490
+ "};",
491
+ "const tokenMetricsFromPart = (part) => {",
492
+ " const tokens = part?.tokens ?? part?.usage ?? {};",
493
+ " return {",
494
+ " input: metricNumber(tokens.input ?? tokens.input_tokens ?? tokens.inputTokens),",
495
+ " output: metricNumber(tokens.output ?? tokens.output_tokens ?? tokens.outputTokens),",
496
+ " total: metricNumber(tokens.total ?? tokens.total_tokens ?? tokens.totalTokens),",
497
+ " cacheRead: metricNumber(tokens.cache?.read ?? tokens.cacheRead ?? tokens.cache_read_tokens ?? tokens.cachedInputTokens),",
498
+ " reasoning: metricNumber(tokens.reasoning ?? tokens.reasoning_tokens ?? tokens.reasoningTokens),",
499
+ " };",
500
+ "};",
501
+ "const OBSERVATION_TEXT_LIMIT = 20000;",
502
+ "const limitObservationText = (text) => {",
503
+ " const normalized = typeof text === 'string' ? text.trim() : '';",
504
+ " if (!normalized) return '';",
505
+ " return normalized.length > OBSERVATION_TEXT_LIMIT ? `${normalized.slice(0, OBSERVATION_TEXT_LIMIT)}\\n...[truncated]` : normalized;",
506
+ "};",
507
+ "const textFromContent = (value, depth = 0) => {",
508
+ " if (depth > 6 || value === null || value === undefined) return '';",
509
+ " if (typeof value === 'string') return value.trim();",
510
+ " if (Array.isArray(value)) return value.map((item) => textFromContent(item, depth + 1)).filter(Boolean).join('\\n').trim();",
511
+ " if (typeof value !== 'object') return '';",
512
+ " for (const key of ['text', 'content', 'prompt', 'message', 'input', 'output', 'parts']) {",
513
+ " const text = textFromContent(value[key], depth + 1);",
514
+ " if (text) return text;",
515
+ " }",
516
+ " return '';",
517
+ "};",
518
+ "const promptFromArgv = () => {",
519
+ " const args = process.argv.slice(2);",
520
+ " if (!args.includes('run')) return '';",
521
+ " for (let i = args.length - 1; i >= 0; i -= 1) {",
522
+ " const arg = args[i];",
523
+ " if (typeof arg === 'string' && arg.trim() && !arg.startsWith('-')) return arg.trim();",
524
+ " }",
525
+ " return '';",
526
+ "};",
527
+ "",
528
+ "const normalizeSkillNames = (names) => {",
529
+ " if (!Array.isArray(names)) return [];",
530
+ " const out = [];",
531
+ " const seen = new Set();",
532
+ " for (const raw of names) {",
533
+ " const name = String(raw || '').trim();",
534
+ " if (!name || seen.has(name)) continue;",
535
+ " seen.add(name);",
536
+ " out.push(name);",
537
+ " }",
538
+ " return out;",
539
+ "};",
540
+ "const skillNamespace = (name) => String(name || '').includes(':') ? String(name).split(':', 1)[0] : '';",
541
+ "const skillAgentPath = (detectedBy) => {",
542
+ " if (detectedBy === 'tool_call') return 'opencode_skill_tool';",
543
+ " if (detectedBy === 'slash_command') return 'opencode_slash_prompt';",
544
+ " if (detectedBy === 'natural_language_request') return 'opencode_prompt_skill_name';",
545
+ " if (detectedBy === 'skill_file_path') return 'skill_file_path';",
546
+ " return detectedBy || 'metadata';",
547
+ "};",
548
+ "const skillInvocationMode = (detectedBy) => {",
549
+ " if (detectedBy === 'slash_command') return 'explicit_request';",
550
+ " if (detectedBy === 'natural_language_request') return 'implicit_request';",
551
+ " if (detectedBy === 'tool_call' || detectedBy === 'plugin_event') return 'implicit';",
552
+ " return 'detected';",
553
+ "};",
554
+ "const skillEventType = (detectedBy) => (detectedBy === 'slash_command' || detectedBy === 'natural_language_request') ? 'requested' : (detectedBy === 'tool_call' || detectedBy === 'plugin_event' ? 'invoked' : 'detected');",
555
+ "const skillIdSegment = (name) => String(name || 'unknown').trim().replace(/[^A-Za-z0-9_.:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96) || 'unknown';",
556
+ "",
557
+ "const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');",
558
+ "const normalizeExplicitSkillCommand = (value) => {",
559
+ " const command = pickEventString(value).replace(/^\\/+/, '').trim();",
560
+ " if (!command) return '';",
561
+ " return command.split(/\\s+/, 1)[0];",
562
+ "};",
563
+ "",
564
+ "const collectStrings = (value, out = []) => {",
565
+ " if (value == null) return out;",
566
+ " if (typeof value === 'string') {",
567
+ " out.push(value);",
568
+ " return out;",
569
+ " }",
570
+ " if (typeof value === 'number' || typeof value === 'boolean') return out;",
571
+ " if (Array.isArray(value)) {",
572
+ " for (const item of value) collectStrings(item, out);",
573
+ " return out;",
574
+ " }",
575
+ " if (typeof value === 'object') {",
576
+ " for (const [key, item] of Object.entries(value)) {",
577
+ " out.push(key);",
578
+ " collectStrings(item, out);",
579
+ " }",
580
+ " }",
581
+ " return out;",
582
+ "};",
583
+ "",
584
+ "const collectExplicitSkillUsages = (value, out = []) => {",
585
+ " if (value == null) return out;",
586
+ " if (Array.isArray(value)) {",
587
+ " for (const item of value) collectExplicitSkillUsages(item, out);",
588
+ " return out;",
589
+ " }",
590
+ " if (typeof value !== 'object') return out;",
591
+ " const command = normalizeExplicitSkillCommand(value.command ?? value.commandName ?? value.promptCommand ?? value.properties?.command ?? value.properties?.commandName);",
592
+ " if (command) {",
593
+ " const callId = pickEventString(value.commandID, value.commandId, value.id);",
594
+ " out.push({ name: command, skill_namespace: skillNamespace(command), detected_by: 'slash_command', skill_call_id: callId });",
595
+ " }",
596
+ " const toolName = pickEventString(value.tool, value.toolName, value.name).toLowerCase();",
597
+ " const input = value.input ?? value.state?.input ?? value.properties?.input;",
598
+ " if (toolName === 'skill' && input && typeof input === 'object' && !Array.isArray(input)) {",
599
+ " const skill = pickEventString(input.skill_name, input.skill, input.name);",
600
+ " const callId = pickEventString(value.callID, value.callId, value.id, value.toolCallID, value.toolCallId);",
601
+ " if (skill) out.push({ name: skill, skill_namespace: skillNamespace(skill), detected_by: 'tool_call', skill_call_id: callId });",
602
+ " }",
603
+ " for (const item of Object.values(value)) collectExplicitSkillUsages(item, out);",
604
+ " return out;",
605
+ "};",
606
+ "",
607
+ "const detectPromptSkillRequests = (haystack, skills) => {",
608
+ " const text = String(haystack || '');",
609
+ " if (!text.trim()) return [];",
610
+ " const out = [];",
611
+ " for (const skillName of skills) {",
612
+ " const escaped = escapeRegExp(skillName);",
613
+ " const pattern = new RegExp(`(?:请\\\\s*)?(?:使用|调用|启用|采用)\\\\s*[\\\"'“”‘’]?${escaped}(?=$|[\\\\s,,。;;::、])|\\\\b(?:use|invoke|run|apply)\\\\s+[\\\"']?${escaped}(?=$|[\\\\s,,。;;::])`, 'i');",
614
+ " if (pattern.test(text)) out.push({ name: skillName, skill_namespace: skillNamespace(skillName), detected_by: 'natural_language_request', skill_call_id: '' });",
615
+ " }",
616
+ " return out;",
617
+ "};",
618
+ "",
619
+ "const detectOpencodeSkillUsages = (source, knownSkills = []) => {",
620
+ " const skills = normalizeSkillNames(knownSkills);",
621
+ " if (skills.length === 0) return [];",
622
+ " const found = [];",
623
+ " const explicitSeen = new Set();",
624
+ " const explicit = [];",
625
+ " for (const usage of collectExplicitSkillUsages(source).filter((item) => skills.includes(item.name))) {",
626
+ " if (usage.skill_call_id) {",
627
+ " const key = `call:${usage.skill_call_id}`;",
628
+ " if (explicitSeen.has(key)) continue;",
629
+ " explicitSeen.add(key);",
630
+ " } else if (usage.detected_by === 'slash_command') {",
631
+ " const key = `command:${usage.name}`;",
632
+ " if (explicitSeen.has(key)) continue;",
633
+ " explicitSeen.add(key);",
634
+ " }",
635
+ " explicit.push(usage);",
636
+ " }",
637
+ " found.push(...explicit);",
638
+ " const haystack = collectStrings(source).join('\\n');",
639
+ " const promptSeen = new Set(explicit.map((usage) => usage.name));",
640
+ " for (const usage of detectPromptSkillRequests(haystack, skills)) {",
641
+ " if (promptSeen.has(usage.name)) continue;",
642
+ " promptSeen.add(usage.name);",
643
+ " found.push(usage);",
644
+ " }",
645
+ " const pathSeen = new Set();",
646
+ " for (const match of haystack.matchAll(/(?:^|[\"'\\s])(?:[A-Za-z]:)?[^\"'\\n\\r]*[\\\\/]+([^\\\\/\"'\\n\\r]+)[\\\\/]+SKILL\\.md(?=$|[\"'\\s])/gi)) {",
647
+ " const skillName = match[1];",
648
+ " if (!skills.includes(skillName) || explicit.some((usage) => usage.name === skillName) || pathSeen.has(skillName)) continue;",
649
+ " pathSeen.add(skillName);",
650
+ " found.push({ name: skillName, skill_namespace: skillNamespace(skillName), detected_by: 'skill_file_path', skill_call_id: '' });",
651
+ " }",
652
+ " return found;",
653
+ "};",
654
+ "const uniqueSkillNames = (usages) => normalizeSkillNames((usages || []).map((usage) => usage.name));",
655
+ "const dedupeSkillUsages = (usages) => {",
656
+ " const out = [];",
657
+ " const seen = new Set();",
658
+ " for (const usage of usages || []) {",
659
+ " const key = usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`;",
660
+ " if (seen.has(key)) continue;",
661
+ " seen.add(key);",
662
+ " out.push(usage);",
663
+ " }",
664
+ " return out;",
665
+ "};",
666
+ "const buildSkillUseEvents = (interactionId, skillUsages) => {",
667
+ " const usages = dedupeSkillUsages(skillUsages);",
668
+ " const total = usages.length;",
669
+ " return usages.map((usage, index) => {",
670
+ " const skillName = String(usage.name || '').trim();",
671
+ " const detectedBy = String(usage.detected_by || 'metadata');",
672
+ " const invocationMode = skillInvocationMode(detectedBy);",
673
+ " return {",
674
+ " skill_use_id: `${interactionId}:skill:${index + 1}:${skillIdSegment(skillName)}`,",
675
+ " skill_use_index: index + 1,",
676
+ " skill_use_count_in_interaction: total,",
677
+ " skill_event_type: skillEventType(detectedBy),",
678
+ " skill_trigger: invocationMode,",
679
+ " skill_invocation_mode: invocationMode,",
680
+ " skill_agent_path: skillAgentPath(detectedBy),",
681
+ " skill_name: skillName,",
682
+ " skill_use_count: 1,",
683
+ " skill_namespace: usage.skill_namespace || skillNamespace(skillName),",
684
+ " detected_by: detectedBy,",
685
+ " ...(usage.skill_call_id ? { skill_call_id: usage.skill_call_id } : {}),",
686
+ " };",
687
+ " }).filter((event) => event.skill_name);",
688
+ "};",
689
+ "",
690
+ "const collectKnownSkillNames = async () => {",
691
+ " const dirs = [",
692
+ " path.join(process.cwd(), '.opencode', 'skill'),",
693
+ " path.join(process.cwd(), '.opencode', 'skills'),",
694
+ " path.join(os.homedir(), '.config', 'opencode', 'skill'),",
695
+ " path.join(os.homedir(), '.config', 'opencode', 'skills'),",
696
+ " path.join(os.homedir(), '.opencode', 'skill'),",
697
+ " path.join(os.homedir(), '.opencode', 'skills'),",
698
+ " ];",
699
+ " const names = new Set();",
700
+ " for (const dir of dirs) {",
701
+ " try {",
702
+ " const entries = await fsPromise.readdir(dir, { withFileTypes: true });",
703
+ " for (const entry of entries) {",
704
+ " if (!entry.isDirectory() || entry.name.startsWith('.')) continue;",
705
+ " try {",
706
+ " await fsPromise.access(path.join(dir, entry.name, 'SKILL.md'));",
707
+ " names.add(entry.name);",
708
+ " } catch {",
709
+ " // Ignore helper folders that are not OpenCode skills.",
710
+ " }",
711
+ " }",
712
+ " } catch {",
713
+ " // Optional skill directories are allowed to be absent.",
714
+ " }",
715
+ " }",
716
+ " return [...names];",
717
+ "};",
718
+ "",
719
+ "export const LangfusePlugin = async ({ client }) => {",
720
+ " const publicKey = process.env.LANGFUSE_PUBLIC_KEY;",
721
+ " const secretKey = process.env.LANGFUSE_SECRET_KEY;",
722
+ ' const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://metrics.openharmonyinsight.cn";',
723
+ ' const environment = process.env.LANGFUSE_ENVIRONMENT ?? "development";',
724
+ "",
725
+ " const userIdFromConfig = await readConfiguredUserId();",
726
+ " const userIdEnv = process.env.LANGFUSE_USER_ID?.trim();",
727
+ " const userId = userIdEnv || userIdFromConfig;",
728
+ "",
729
+ " const log = (level, message) => {",
730
+ " if ((level === 'warn' || level === 'error') && !showNoisyLogs) return;",
731
+ ' client.app.log({ body: { service: "langfuse-otel", level, message } });',
732
+ " };",
733
+ "",
734
+ " if (!publicKey || !secretKey) {",
735
+ ' log("warn", "Missing LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY - tracing disabled");',
736
+ " return {};",
737
+ " }",
738
+ "",
739
+ " const processor = new LangfuseSpanProcessor({ publicKey, secretKey, baseUrl, environment });",
740
+ " const spanProcessors = [processor, createMetricsSpanProcessor(userId)];",
741
+ " if (userId) spanProcessors.push(createUserIdSpanProcessor(userId));",
742
+ "",
743
+ " const sdk = new NodeSDK({ spanProcessors });",
744
+ " const sdkStartPromise = Promise.resolve().then(() => sdk.start()).catch((err) => {",
745
+ ' log("warn", `OTEL SDK start failed: ${err?.message ?? err}`);',
746
+ " });",
747
+ " const getMetricsTracer = () => trace.getTracer('oh-langfuse-opencode-metrics');",
748
+ " const knownSkillNames = await collectKnownSkillNames();",
749
+ " const startupSkillUsages = detectOpencodeSkillUsages(process.argv.join('\\n'), knownSkillNames);",
750
+ " const startupPromptText = limitObservationText(promptFromArgv());",
751
+ " const messageTextById = new Map();",
752
+ " const messageInputById = new Map();",
753
+ " const lastUserTextBySessionId = new Map();",
754
+ " const skillUsagesByMessageId = new Map();",
755
+ " const skillUsagesBySessionId = new Map();",
756
+ " const startupSkillSessionIds = new Set();",
757
+ " const toolCallIdsByMessageId = new Map();",
758
+ " const toolCallIdsBySessionId = new Map();",
759
+ " const toolResultIdsByMessageId = new Map();",
760
+ " const toolResultIdsBySessionId = new Map();",
761
+ " const emittedMessageIds = new Set();",
762
+ "",
763
+ ' log("info", `OTEL tracing initialized -> ${baseUrl}`);',
764
+ ' if (userId) log("info", `LANGFUSE userId configured -> ${userId}`);',
765
+ ' if (knownSkillNames.length) log("info", `OpenCode skills discovered -> ${knownSkillNames.length}`);',
766
+ "",
767
+ " let shutdownStarted = false;",
768
+ " const flush = async (reason) => {",
769
+ " try {",
770
+ ' log("info", `Flushing OTEL spans on ${reason}`);',
771
+ " await sdkStartPromise;",
772
+ " await processor.forceFlush();",
773
+ " } catch (error) {",
774
+ ' log("warn", `OTEL forceFlush failed on ${reason}: ${error?.message ?? error}`);',
775
+ " }",
776
+ " };",
777
+ " const shutdown = async (reason) => {",
778
+ " if (shutdownStarted) return;",
779
+ " shutdownStarted = true;",
780
+ " try {",
781
+ ' log("info", `Shutting down OTEL SDK on ${reason}`);',
782
+ " await sdkStartPromise;",
783
+ " await sdk.shutdown();",
784
+ " } catch (error) {",
785
+ ' log("warn", `OTEL shutdown failed on ${reason}: ${error?.message ?? error}`);',
786
+ " }",
787
+ " };",
788
+ " process.once('beforeExit', async () => {",
789
+ " await flush('process.beforeExit');",
790
+ " await shutdown('process.beforeExit');",
791
+ " });",
792
+ " const originalExit = process.exit.bind(process);",
793
+ " process.exit = ((code) => {",
794
+ " void shutdown('process.exit').finally(() => originalExit(code));",
795
+ " });",
796
+ "",
797
+ " const rememberSkillUsages = (map, key, usages) => {",
798
+ " if (!key || !usages.length) return;",
799
+ " let list = map.get(key);",
800
+ " if (!list) {",
801
+ " list = [];",
802
+ " map.set(key, list);",
803
+ " }",
804
+ " const seen = new Set(list.map((usage) => usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`));",
805
+ " for (const usage of usages) {",
806
+ " const key = usage.skill_call_id ? `call:${usage.skill_call_id}` : `${usage.name}:${usage.detected_by}`;",
807
+ " if (seen.has(key)) continue;",
808
+ " seen.add(key);",
809
+ " list.push(usage);",
810
+ " }",
811
+ " };",
812
+ " const rememberToolActivity = (map, key, activity, kind) => {",
813
+ " if (!key || !activity?.[kind]) return;",
814
+ " let set = map.get(key);",
815
+ " if (!set) {",
816
+ " set = new Set();",
817
+ " map.set(key, set);",
818
+ " }",
819
+ " set.add(activity.toolCallId || `${kind}:${set.size + 1}`);",
820
+ " };",
821
+ " const recordInteractionMetric = async (event) => {",
822
+ " const payload = eventPayload(event);",
823
+ " const part = eventPart(event);",
824
+ " const partType = part?.type ?? '';",
825
+ " const sessionId = pickEventString(part?.sessionID, part?.sessionId, payload?.sessionID, payload?.sessionId, payload?.session?.id, event?.sessionID, event?.sessionId);",
826
+ " const messageId = pickEventString(part?.messageID, part?.messageId, payload?.messageID, payload?.messageId, payload?.message?.id, event?.messageID, event?.messageId);",
827
+ " const role = pickEventString(part?.role, payload?.role, payload?.message?.role, event?.role).toLowerCase();",
828
+ " const eventText = textFromContent(part) || textFromContent(payload?.message) || textFromContent(payload);",
829
+ " if (eventText && role === 'user') {",
830
+ " const inputText = limitObservationText(eventText);",
831
+ " if (messageId) messageInputById.set(messageId, inputText);",
832
+ " if (sessionId) lastUserTextBySessionId.set(sessionId, inputText);",
833
+ " return;",
834
+ " }",
835
+ " if (sessionId && startupSkillUsages.length && !startupSkillSessionIds.has(sessionId)) {",
836
+ " startupSkillSessionIds.add(sessionId);",
837
+ " rememberSkillUsages(skillUsagesBySessionId, sessionId, startupSkillUsages);",
838
+ " }",
839
+ " const skillDetectionSources = [part, payload, part?.state?.input, part?.input, payload?.input, part?.state?.metadata, payload?.metadata, part?.state?.file, part?.file];",
840
+ " const eventSkillUsages = detectOpencodeSkillUsages(skillDetectionSources, knownSkillNames);",
841
+ " rememberSkillUsages(skillUsagesByMessageId, messageId, eventSkillUsages);",
842
+ " rememberSkillUsages(skillUsagesBySessionId, sessionId, eventSkillUsages);",
843
+ " const toolActivity = countOpencodeToolActivity(event);",
844
+ " rememberToolActivity(toolCallIdsByMessageId, messageId, toolActivity, 'toolCallCount');",
845
+ " rememberToolActivity(toolCallIdsBySessionId, sessionId, toolActivity, 'toolCallCount');",
846
+ " rememberToolActivity(toolResultIdsByMessageId, messageId, toolActivity, 'toolResultCount');",
847
+ " rememberToolActivity(toolResultIdsBySessionId, sessionId, toolActivity, 'toolResultCount');",
848
+ " if (eventText && messageId && (role === 'assistant' || partType === 'text')) {",
849
+ " messageTextById.set(messageId, limitObservationText(eventText));",
850
+ " const textSkillUsages = detectOpencodeSkillUsages(eventText, knownSkillNames);",
851
+ " rememberSkillUsages(skillUsagesByMessageId, messageId, textSkillUsages);",
852
+ " rememberSkillUsages(skillUsagesBySessionId, sessionId, textSkillUsages);",
853
+ " return;",
854
+ " }",
855
+ " if (partType !== 'step-finish' || !messageId || emittedMessageIds.has(messageId)) return;",
856
+ " emittedMessageIds.add(messageId);",
857
+ " const tokenMetrics = tokenMetricsFromPart(part);",
858
+ " const total = tokenMetrics.total ?? (tokenMetrics.input !== undefined && tokenMetrics.output !== undefined ? tokenMetrics.input + tokenMetrics.output : undefined);",
859
+ " const tokenAvailable = [tokenMetrics.input, tokenMetrics.output, total, tokenMetrics.cacheRead, tokenMetrics.reasoning].some((value) => value !== undefined);",
860
+ " await sdkStartPromise;",
861
+ " const span = getMetricsTracer().startSpan('OpenCode Agent Turn');",
862
+ " const text = messageTextById.get(messageId) || '';",
863
+ " const inputText = messageInputById.get(messageId) || lastUserTextBySessionId.get(sessionId) || startupPromptText || '';",
864
+ " const skillUsages = dedupeSkillUsages([...(skillUsagesByMessageId.get(messageId) ?? []), ...(skillUsagesBySessionId.get(sessionId) ?? [])]);",
865
+ " const interactionId = `opencode:${userId || \"unknown\"}:${sessionId || \"unknown\"}:${messageId}`;",
866
+ " const skillUseEvents = buildSkillUseEvents(interactionId, skillUsages);",
867
+ " const skillNames = uniqueSkillNames(skillUsages);",
868
+ " const skillNamesAll = skillUseEvents.map((event) => event.skill_name);",
869
+ " const skillInvocationModes = skillUseEvents.map((event) => event.skill_invocation_mode).filter(Boolean);",
870
+ " const skillAgentPaths = skillUseEvents.map((event) => event.skill_agent_path).filter(Boolean);",
871
+ " const toolCallCount = Math.max(new Set([...(toolCallIdsByMessageId.get(messageId) ?? []), ...(toolCallIdsBySessionId.get(sessionId) ?? [])]).size, skillUseEvents.length);",
872
+ " const toolResultCount = Math.max(new Set([...(toolResultIdsByMessageId.get(messageId) ?? []), ...(toolResultIdsBySessionId.get(sessionId) ?? [])]).size, skillUseEvents.length);",
873
+ ' span.setAttribute("oh.langfuse.source", "opencode");',
874
+ ' span.setAttribute("oh.langfuse.user_id", userId || "");',
875
+ ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.1");',
876
+ ' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
877
+ ' span.setAttribute("langfuse.observation.metadata.agent", "opencode");',
878
+ ' span.setAttribute("langfuse.observation.metadata.user_id", userId || "");',
879
+ ' span.setAttribute("langfuse.observation.metadata.session_id", sessionId || "unknown");',
880
+ ' span.setAttribute("langfuse.observation.metadata.interaction_id", interactionId);',
881
+ ' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.1");',
882
+ ' span.setAttribute("langfuse.observation.metadata.interaction_count", 1);',
883
+ ' span.setAttribute("langfuse.observation.metadata.user_message_count", 1);',
884
+ ' span.setAttribute("langfuse.observation.metadata.assistant_message_count", 1);',
885
+ ' span.setAttribute("langfuse.observation.metadata.token_metrics_available", tokenAvailable);',
886
+ ' span.setAttribute("langfuse.observation.metadata.tool_call_count", toolCallCount);',
887
+ ' span.setAttribute("langfuse.observation.metadata.tool_result_count", toolResultCount);',
888
+ ' span.setAttribute("langfuse.observation.metadata.skill_use_count", skillUseEvents.length);',
889
+ ' span.setAttribute("langfuse.observation.metadata.unique_skill_count", skillNames.length);',
890
+ ' span.setAttribute("langfuse.observation.metadata.repeated_skill_count", Math.max(0, skillUseEvents.length - skillNames.length));',
891
+ ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names", skillNames);',
892
+ ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names_csv", skillNames.join(","));',
893
+ ' if (skillNamesAll.length) span.setAttribute("langfuse.observation.metadata.skill_names_all", skillNamesAll);',
894
+ ' if (skillInvocationModes.length) span.setAttribute("langfuse.observation.metadata.skill_invocation_modes", skillInvocationModes);',
895
+ ' if (skillAgentPaths.length) span.setAttribute("langfuse.observation.metadata.skill_agent_paths", skillAgentPaths);',
896
+ ' if (tokenMetrics.input !== undefined) span.setAttribute("langfuse.observation.metadata.input_tokens", tokenMetrics.input);',
897
+ ' if (tokenMetrics.output !== undefined) span.setAttribute("langfuse.observation.metadata.output_tokens", tokenMetrics.output);',
898
+ ' if (total !== undefined) span.setAttribute("langfuse.observation.metadata.total_tokens", total);',
899
+ ' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
900
+ ' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
901
+ ' if (inputText) span.setAttribute("langfuse.observation.input", inputText);',
902
+ ' if (inputText) span.setAttribute("langfuse.trace.input", inputText);',
903
+ ' if (inputText) span.setAttribute("input.value", inputText);',
904
+ ' if (text) span.setAttribute("langfuse.observation.output", text);',
905
+ ' if (text) span.setAttribute("langfuse.trace.output", text);',
906
+ ' if (text) span.setAttribute("output.value", text);',
907
+ ' if (inputText) span.setAttribute("langfuse.observation.metadata.input_text_preview", inputText.slice(0, 512));',
908
+ ' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
909
+ ' writeRepoContextMetrics(span, collectRepoContext(process.cwd(), "process"));',
910
+ " span.end();",
911
+ " messageTextById.delete(messageId);",
912
+ " messageInputById.delete(messageId);",
913
+ " skillUsagesByMessageId.delete(messageId);",
914
+ " skillUsagesBySessionId.delete(sessionId);",
915
+ " toolCallIdsByMessageId.delete(messageId);",
916
+ " toolCallIdsBySessionId.delete(sessionId);",
917
+ " toolResultIdsByMessageId.delete(messageId);",
918
+ " toolResultIdsBySessionId.delete(sessionId);",
919
+ " };",
920
+ "",
921
+ " return {",
922
+ " config: async (config) => {",
923
+ " if (!config.experimental?.openTelemetry) {",
924
+ ' log("warn", "OpenTelemetry experimental feature is disabled in Opencode config - tracing disabled");',
925
+ " }",
926
+ " },",
927
+ " event: async ({ event }) => {",
928
+ " const eventType = pickEventString(event?.name, event?.type, eventPayload(event)?.name, eventPayload(event)?.type);",
929
+ " await recordInteractionMetric(event);",
930
+ ' if (eventType === "session.idle" || eventType === "message.updated" || eventType === "message.part.updated" || eventType === "session.updated" || eventType === "session.idle.1" || eventType === "message.updated.1" || eventType === "message.part.updated.1" || eventType === "session.updated.1") {',
931
+ " await flush(eventType);",
932
+ " }",
933
+ ' if (eventType === "server.instance.disposed") {',
934
+ " await shutdown('server.instance.disposed');",
935
+ " }",
936
+ " },",
937
+ " };",
938
+ "};",
939
+ ""
940
+ ].join(os.EOL);
941
+ }
942
+
943
+ export function patchDistIndexJs(distIndexPath) {
944
+ const code = getPatchedLangfuseDistIndexJs();
945
+ ensureDir(path.dirname(distIndexPath));
946
+ fs.writeFileSync(distIndexPath, code + os.EOL, "utf8");
947
+ }
948
+
949
+ function syncPluginPackageToLocalPlugins({ pkgDir, pluginDest, pluginsDir }) {
950
+ ensureDir(pluginsDir);
951
+ if (fs.existsSync(pluginDest)) {
952
+ fs.rmSync(pluginDest, { recursive: true, force: true });
953
+ }
954
+ fs.cpSync(pkgDir, pluginDest, { recursive: true });
955
+ }
956
+
957
+ function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, userId }) {
958
+ if (process.platform !== "win32") return null;
959
+ const p = path.join(opencodeDir, "launch-opencode-langfuse.cmd");
960
+ writeWindowsUpdateCheckScript(opencodeDir);
961
+ const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
962
+ cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
963
+ cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
964
+ cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
965
+ if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
966
+ cmd.push('powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0oh-langfuse-opencode-update-check.ps1"');
967
+ cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
968
+ cmd.push(' "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
969
+ cmd.push(" exit /b");
970
+ cmd.push(")");
971
+ cmd.push("opencode %*");
972
+ fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
973
+ return p;
974
+ }
975
+
976
+ function shQuote(s) {
977
+ return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
978
+ }
979
+
980
+ function autoUpdateRuntimePath() {
981
+ return path.join(rootDir, "scripts", "auto-update-runtime.mjs");
982
+ }
983
+
984
+ function autoUpdateHelperDir() {
985
+ return path.join(os.homedir(), ".config", "oh-langfuse", "bin");
986
+ }
987
+
988
+ function writeAutoUpdateHelper(target) {
989
+ const helperDir = autoUpdateHelperDir();
990
+ ensureDir(helperDir);
991
+ const runtimePath = autoUpdateRuntimePath();
992
+ const fallbackArgs = ["-y", "--registry=https://registry.npmjs.org/", "oh-langfuse@latest", "auto-update", target, "--skip-check", "--startup-status"];
993
+
994
+ if (process.platform === "win32") {
995
+ const helper = path.join(helperDir, `oh-langfuse-auto-update-${target}.cmd`);
996
+ const lines = [
997
+ "@echo off",
998
+ "REM Auto-generated by scripts/opencode-langfuse-setup.mjs",
999
+ `if exist ${cmdQuote(runtimePath)} (`,
1000
+ ` call ${cmdQuote(process.execPath)} ${cmdQuote(runtimePath)} ${target} --skip-check --startup-status %*`,
1001
+ ") else (",
1002
+ ` call npx.cmd ${fallbackArgs.map(cmdQuote).join(" ")} %*`,
1003
+ ")",
1004
+ "exit /b 0",
1005
+ ""
1006
+ ];
1007
+ fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
1008
+ return helper;
1009
+ }
1010
+
1011
+ const helper = path.join(helperDir, `oh-langfuse-auto-update-${target}`);
1012
+ const lines = [
1013
+ "#!/usr/bin/env sh",
1014
+ "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
1015
+ "set +e",
1016
+ `if [ -f ${shQuote(runtimePath)} ]; then`,
1017
+ ` ${shQuote(process.execPath)} ${shQuote(runtimePath)} ${shQuote(target)} --skip-check --startup-status "$@"`,
1018
+ "else",
1019
+ ` npx ${fallbackArgs.map(shQuote).join(" ")} "$@"`,
1020
+ "fi",
1021
+ "exit 0",
1022
+ ""
1023
+ ];
1024
+ fs.writeFileSync(helper, lines.join("\n"), "utf8");
1025
+ fs.chmodSync(helper, 0o755);
1026
+ return helper;
1027
+ }
1028
+
1029
+ function windowsAutoUpdateCommand(target) {
1030
+ return `call ${cmdQuote(writeAutoUpdateHelper(target))}`;
1031
+ }
1032
+
1033
+ function unixAutoUpdateCommand(target) {
1034
+ return `${shQuote(writeAutoUpdateHelper(target))} || true`;
1035
+ }
1036
+
1037
+ function windowsGitPathBootstrap() {
1038
+ return [
1039
+ "for /f \"delims=\" %%G in ('where.exe git.exe 2^>nul') do (",
1040
+ " set \"OH_LANGFUSE_GIT_EXE=%%G\"",
1041
+ " set \"npm_config_git=%%G\"",
1042
+ " set \"PATH=%%~dpG;%PATH%\"",
1043
+ " goto :oh_langfuse_git_ready",
1044
+ ")",
1045
+ ":oh_langfuse_git_ready"
1046
+ ];
1047
+ }
1048
+
974
1049
  function writeWindowsUpdateCheckScript(dir) {
975
- if (process.platform !== "win32") return null;
976
- ensureDir(dir);
977
- const p = path.join(dir, "oh-langfuse-opencode-update-check.ps1");
978
- const ps = [
979
- "$ErrorActionPreference = 'SilentlyContinue'",
980
- "$statePath = Join-Path $env:USERPROFILE '.config\\oh-langfuse\\runtime.json'",
981
- "if (-not (Test-Path -LiteralPath $statePath)) { exit 0 }",
982
- "$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json",
983
- "$installed = $state.targets.opencode.packageVersion",
984
- "$meta = Invoke-RestMethod -UseBasicParsing -Uri 'https://registry.npmjs.org/oh-langfuse' -TimeoutSec 3",
985
- "$latest = $meta.'dist-tags'.latest",
986
- "if ($installed -and $latest -and ([version]$latest -gt [version]$installed)) {",
987
- " Write-Host \"[INFO] oh-langfuse opencode runtime update available: $installed -> $latest\"",
988
- " Write-Host '[INFO] Close OpenCode and run: npx oh-langfuse@latest update opencode'",
989
- "}",
990
- ""
991
- ].join("\r\n");
992
- fs.writeFileSync(p, "\ufeff" + ps, "utf8");
1050
+ if (process.platform !== "win32") return null;
1051
+ ensureDir(dir);
1052
+ const p = path.join(dir, "oh-langfuse-opencode-update-check.ps1");
1053
+ const ps = [
1054
+ "$ErrorActionPreference = 'SilentlyContinue'",
1055
+ "$statePath = Join-Path $env:USERPROFILE '.config\\oh-langfuse\\runtime.json'",
1056
+ "if (-not (Test-Path -LiteralPath $statePath)) { exit 0 }",
1057
+ "$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json",
1058
+ "$installed = $state.targets.opencode.packageVersion",
1059
+ "$meta = Invoke-RestMethod -UseBasicParsing -Uri 'https://registry.npmjs.org/oh-langfuse' -TimeoutSec 3",
1060
+ "$latest = $meta.'dist-tags'.latest",
1061
+ "if ($installed -and $latest -and ([version]$latest -gt [version]$installed)) {",
1062
+ " Write-Host \"[INFO] oh-langfuse opencode runtime update available: $installed -> $latest\"",
1063
+ " Write-Host '[INFO] Close OpenCode and run: npx oh-langfuse@latest update opencode'",
1064
+ "}",
1065
+ ""
1066
+ ].join("\r\n");
1067
+ fs.writeFileSync(p, "\ufeff" + ps, "utf8");
993
1068
  return p;
994
1069
  }
995
1070
 
996
- function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli }) {
997
- const shimDir = path.join(opencodeDir, "bin");
998
- ensureDir(shimDir);
999
- if (process.platform === "win32") {
1000
- const shim = path.join(shimDir, "opencode.cmd");
1001
- const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
1002
- cmd.push("set OH_LANGFUSE_OPENCODE_SHIM=1");
1003
- cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
1004
- cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
1005
- cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
1006
- if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
1007
- cmd.push(...windowsGitPathBootstrap());
1008
- cmd.push(windowsAutoUpdateCommand("opencode"));
1009
- cmd.push(`set "OH_LANGFUSE_REAL_OPENCODE=${cmdSetValue(realOpencodeCli)}"`);
1010
- cmd.push('echo "%OH_LANGFUSE_REAL_OPENCODE%" | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
1011
- cmd.push('if errorlevel 1 if exist "%OH_LANGFUSE_REAL_OPENCODE%" (');
1012
- cmd.push(' call "%OH_LANGFUSE_REAL_OPENCODE%" %*');
1013
- cmd.push(" exit /b");
1014
- cmd.push(")");
1015
- cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
1016
- cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
1017
- cmd.push(" exit /b");
1018
- cmd.push(")");
1019
- cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
1020
- cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
1021
- cmd.push(" exit /b");
1022
- cmd.push(")");
1023
- cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
1024
- cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
1025
- cmd.push(" exit /b");
1026
- cmd.push(")");
1027
- cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
1028
- cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
1029
- cmd.push(" exit /b");
1030
- cmd.push(")");
1031
- cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
1032
- cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
1033
- cmd.push(" exit /b");
1034
- cmd.push(")");
1035
- cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" (');
1036
- cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" %*');
1037
- cmd.push(" exit /b");
1038
- cmd.push(")");
1039
- cmd.push('if exist "%APPDATA%\\npm\\opencode.cmd" (');
1040
- cmd.push(' call "%APPDATA%\\npm\\opencode.cmd" %*');
1041
- cmd.push(" exit /b");
1042
- cmd.push(")");
1043
- cmd.push('for /f "delims=" %%O in (\'where.exe opencode 2^>nul\') do (');
1044
- cmd.push(' if /I not "%%~fO"=="%~f0" (');
1045
- cmd.push(' echo %%~fO | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
1046
- cmd.push(" if errorlevel 1 (");
1047
- cmd.push(' call "%%O" %*');
1048
- cmd.push(" exit /b");
1049
- cmd.push(" )");
1050
- cmd.push(" )");
1051
- cmd.push(")");
1052
- cmd.push("echo [ERROR] OpenCode CLI not found. Install OpenCode CLI first. 1>&2");
1053
- cmd.push("echo Try: npm install -g opencode-ai@latest 1>&2");
1054
- cmd.push("echo Then run: npx oh-langfuse@latest update opencode 1>&2");
1055
- cmd.push("exit /b 127");
1056
- fs.writeFileSync(shim, cmd.join("\r\n") + "\r\n", "utf8");
1057
- return { shim, shimDir };
1058
- }
1059
-
1060
- const shim = path.join(shimDir, "opencode");
1061
- const quotedRealOpencodeCli = shQuote(realOpencodeCli);
1062
- const lines = [
1063
- "#!/usr/bin/env sh",
1064
- "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
1065
- "set -eu",
1066
- "export OH_LANGFUSE_OPENCODE_SHIM=1",
1067
- `export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
1068
- `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
1069
- `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
1070
- userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
1071
- unixAutoUpdateCommand("opencode"),
1072
- `OH_LANGFUSE_REAL_OPENCODE=${quotedRealOpencodeCli}`,
1073
- "OH_LANGFUSE_BROKEN_OPENCODE=",
1074
- "try_opencode_candidate() {",
1075
- ' candidate="$1"',
1076
- " shift",
1077
- ' if [ -x "$candidate" ]; then',
1078
- ' if "$candidate" --version >/dev/null 2>&1; then exec "$candidate" "$@"; fi',
1079
- ' OH_LANGFUSE_BROKEN_OPENCODE="$candidate"',
1080
- " fi",
1081
- "}",
1082
- 'try_opencode_candidate "$HOME/.opencode/bin/opencode" "$@"',
1083
- 'try_opencode_candidate "$OH_LANGFUSE_REAL_OPENCODE" "$@"',
1084
- 'if command -v opencode >/dev/null 2>&1; then',
1085
- ' resolved="$(command -v opencode)"',
1086
- ' if [ "$resolved" != "$0" ]; then try_opencode_candidate "$resolved" "$@"; fi',
1087
- "fi",
1088
- 'if [ -n "$OH_LANGFUSE_BROKEN_OPENCODE" ]; then',
1089
- ' echo "[ERROR] OpenCode CLI was found but failed to start: $OH_LANGFUSE_BROKEN_OPENCODE" >&2',
1090
- ' echo "Try: npm install -g opencode-ai@latest --registry=https://registry.npmjs.org/" >&2',
1091
- ' echo "Then run: npx --registry=https://registry.npmjs.org/ -y oh-langfuse@latest update opencode --npmRegistry=https://registry.npmjs.org/" >&2',
1092
- " exit 127",
1093
- "fi",
1094
- 'echo "[ERROR] OpenCode CLI not found. Install OpenCode CLI first." >&2',
1095
- 'echo "Try: npm install -g opencode-ai@latest" >&2',
1096
- 'echo "Then run: npx oh-langfuse@latest update opencode" >&2',
1097
- "exit 127",
1098
- ""
1099
- ].filter(Boolean);
1100
- fs.writeFileSync(shim, lines.join("\n"), "utf8");
1101
- fs.chmodSync(shim, 0o755);
1102
- return { shim, shimDir };
1103
- }
1104
-
1105
- function writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userId }) {
1106
- if (process.platform === "win32") return null;
1107
- const p = path.join(opencodeDir, "launch-opencode-langfuse.sh");
1108
- const lines = [
1109
- "#!/usr/bin/env sh",
1110
- "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
1111
- "set -eu",
1112
- `export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
1113
- `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
1114
- `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
1115
- userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
1116
- unixAutoUpdateCommand("opencode"),
1117
- 'if [ -x "$HOME/.opencode/bin/opencode" ]; then',
1118
- ' exec "$HOME/.opencode/bin/opencode" "$@"',
1119
- "fi",
1120
- 'exec opencode "$@"',
1121
- ""
1122
- ].filter(Boolean);
1123
- fs.writeFileSync(p, lines.join("\n"), "utf8");
1124
- fs.chmodSync(p, 0o755);
1125
- return p;
1126
- }
1127
-
1128
- function getNpmExecutable() {
1129
- if (process.platform === "win32") {
1130
- const beside = path.join(path.dirname(process.execPath), "npm.cmd");
1131
- if (fs.existsSync(beside)) return beside;
1132
- }
1133
- return process.platform === "win32" ? "npm.cmd" : "npm";
1134
- }
1135
-
1136
- /** 与 node.exe 同目录下的 npm-cli.js(避免 Windows 上直接 spawn `*.cmd` 出现 EINVAL)。 */
1137
- function getNpmCliJsPath() {
1138
- return path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
1139
- }
1140
-
1141
- /** @returns {{ status: number | null, stdout: string, stderr: string, error?: Error }} */
1142
- function runNpmCapture(npmArgs) {
1143
- const cliJs = getNpmCliJsPath();
1144
- if (fs.existsSync(cliJs)) {
1145
- return spawnSync(process.execPath, [cliJs, ...npmArgs], {
1146
- encoding: "utf8",
1147
- windowsHide: true,
1148
- maxBuffer: 10 * 1024 * 1024
1149
- });
1150
- }
1151
- const npmExe = getNpmExecutable();
1152
- const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(npmExe);
1153
- return spawnSync(npmExe, npmArgs, {
1154
- encoding: "utf8",
1155
- shell: useShell,
1156
- windowsHide: true,
1157
- maxBuffer: 10 * 1024 * 1024
1158
- });
1159
- }
1160
-
1161
- function printNpmDiagnostics() {
1162
- try {
1163
- const nodeV = spawnSync(process.execPath, ["-v"], { encoding: "utf8" });
1164
- console.error("诊断:node 版本 =", (nodeV.stdout || "").trim(), "exit =", nodeV.status);
1165
- } catch (_) {}
1166
- const cliJs = getNpmCliJsPath();
1167
- console.error("诊断:npm 入口 =", fs.existsSync(cliJs) ? `node + ${cliJs}` : getNpmExecutable());
1071
+ function readRealOpencodeFromManagedShim(shimPath) {
1168
1072
  try {
1169
- const npmVersion = runNpmCapture(["-v"]);
1170
- console.error(
1171
- "诊断:npm 版本 =",
1172
- (npmVersion.stdout || "").trim(),
1173
- "exit =",
1174
- npmVersion.status,
1175
- npmVersion.error || ""
1176
- );
1177
- } catch (e) {
1178
- console.error("诊断:无法执行 npm -v:", e?.message || e);
1179
- }
1180
- try {
1181
- const reg = runNpmCapture(["config", "get", "registry"]);
1182
- console.error("诊断:npm registry =", (reg.stdout || "").trim());
1183
- if (reg.stderr) console.error(reg.stderr);
1184
- } catch (_) {}
1185
- }
1186
-
1187
- /**
1188
- * 重置终端状态,防止子进程异常退出后遗留 ANSI 转义序列。
1189
- */
1190
- function resetTerminalState() {
1191
- if (process.stdout.isTTY) {
1192
- // \x1b[0m - 重置所有颜色和属性
1193
- // \x1b[?25h - 显示光标(防止 npm 隐藏光标后未恢复)
1194
- // \x1b[r - 重置滚动区域
1195
- process.stdout.write('\x1b[0m\x1b[?25h\x1b[r');
1196
- }
1197
- }
1198
-
1199
- function runNpmInstallCapture(npmArgs, options = {}) {
1200
- return new Promise((resolve) => {
1201
- const cliJs = getNpmCliJsPath();
1202
- const npmExe = getNpmExecutable();
1203
- const command = fs.existsSync(cliJs) ? process.execPath : npmExe;
1204
- const args = fs.existsSync(cliJs) ? [cliJs, ...npmArgs] : npmArgs;
1205
- const useShell = process.platform === "win32" && !fs.existsSync(cliJs) && /\.(cmd|bat)$/i.test(npmExe);
1206
- const timeoutMs = options.timeoutMs ?? 600000; // 默认 10 分钟超时
1207
-
1208
- const child = spawn(command, args, {
1209
- encoding: "utf8",
1210
- shell: useShell,
1211
- windowsHide: true,
1212
- timeout: timeoutMs
1213
- });
1214
- let stdout = "";
1215
- let stderr = "";
1216
- const started = Date.now();
1217
- const timer = setInterval(() => {
1218
- const secs = Math.round((Date.now() - started) / 1000);
1219
- console.error(`npm install still running (${secs}s). Slow networks can take a few minutes...`);
1220
- }, 15000);
1221
- child.stdout?.on("data", (chunk) => {
1222
- const text = String(chunk);
1223
- stdout += text;
1224
- process.stdout.write(text);
1225
- });
1226
- child.stderr?.on("data", (chunk) => {
1227
- const text = String(chunk);
1228
- stderr += text;
1229
- const filtered = text
1230
- .split(/(\r?\n)/)
1231
- .filter((part) => part === "\n" || part === "\r\n" || !shouldSuppressAgentLogLine(part))
1232
- .join("");
1233
- if (filtered) process.stderr.write(filtered);
1234
- });
1235
- child.on("error", (error) => {
1236
- clearInterval(timer);
1237
- resetTerminalState();
1238
- resolve({ status: null, stdout, stderr, error });
1239
- });
1240
- child.on("close", (status) => {
1241
- clearInterval(timer);
1242
- if (status !== 0) {
1243
- resetTerminalState();
1244
- }
1245
- resolve({ status, stdout, stderr });
1246
- });
1247
- child.on("timeout", () => {
1248
- clearInterval(timer);
1249
- console.error("");
1250
- console.error(`npm install timed out after ${Math.round(timeoutMs / 1000)}s, killing process...`);
1251
- resetTerminalState();
1252
- child.kill('SIGTERM');
1253
- });
1254
- });
1255
- }
1256
-
1257
- function npmInstallLooksLikeMissingVersion(result) {
1258
- const text = `${result?.stdout || ""}\n${result?.stderr || ""}\n${result?.error?.message || ""}`.toLowerCase();
1259
- return text.includes("etarget") || text.includes("notarget") || text.includes("no matching version found");
1260
- }
1261
-
1262
- function isOfficialNpmRegistry(registry) {
1263
- return /^https?:\/\/registry\.npmjs\.org\/?$/i.test(String(registry || "").trim());
1264
- }
1265
-
1266
- const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
1267
- const DEFAULT_NPM_REGISTRY = "https://registry.npmmirror.com/";
1268
-
1269
- function isChinaNetwork() {
1270
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
1271
- return tz.includes("Shanghai") || tz.includes("Chongqing") || tz.includes("Harbin");
1272
- }
1273
-
1274
- async function checkInstalledVersion(opencodeDir, pkgName) {
1275
- const pkgJsonPath = path.join(opencodeDir, "node_modules", pkgName, "package.json");
1276
- if (!fs.existsSync(pkgJsonPath)) return null;
1277
- try {
1278
- const txt = stripBom(fs.readFileSync(pkgJsonPath, "utf8"));
1279
- if (!txt.trim()) return null;
1280
- const installed = parseJsonRelaxed(txt, pkgJsonPath);
1281
- return installed?.version || null;
1073
+ if (!shimPath || !fs.existsSync(shimPath)) return "";
1074
+ const text = fs.readFileSync(shimPath, "utf8");
1075
+ const cmdMatch = text.match(/set\s+"OH_LANGFUSE_REAL_OPENCODE=([^"]+)"/i);
1076
+ if (cmdMatch?.[1] && fs.existsSync(cmdMatch[1])) return path.resolve(cmdMatch[1]);
1077
+ const shMatch = text.match(/^OH_LANGFUSE_REAL_OPENCODE=(?:"([^"]+)"|'([^']+)'|([^\r\n]+))/m);
1078
+ const candidate = shMatch?.[1] || shMatch?.[2] || shMatch?.[3];
1079
+ if (candidate && fs.existsSync(candidate.trim())) return path.resolve(candidate.trim());
1282
1080
  } catch {
1283
- return null;
1081
+ // Best effort only; setup can still fall back to ordinary CLI discovery.
1284
1082
  }
1083
+ return "";
1285
1084
  }
1286
1085
 
1287
- async function runNpmInstallOrThrow({ opencodeDir, pkgName = "opencode-plugin-langfuse", npmRegistry = "" }) {
1288
- const installedVersion = await checkInstalledVersion(opencodeDir, pkgName);
1289
- const currentVersion = packageJson.version;
1290
-
1291
- if (installedVersion === currentVersion) {
1292
- console.log(`插件版本 ${currentVersion} 已安装,跳过 npm install ...`);
1293
- return;
1294
- }
1295
-
1296
- if (installedVersion) {
1297
- console.log(`插件版本 ${installedVersion} 已安装,升级到 ${currentVersion} ...`);
1298
- }
1299
-
1300
- const isOfficial = npmRegistry && isOfficialNpmRegistry(npmRegistry);
1301
- const effectiveRegistry = npmRegistry || (isChinaNetwork() ? DEFAULT_NPM_REGISTRY : OFFICIAL_NPM_REGISTRY);
1302
-
1303
- const npmArgs = [
1304
- "install", pkgName, "--prefix", opencodeDir,
1305
- "--package-lock=false", "--no-save",
1306
- "--audit=false", "--fund=false",
1307
- "--prefer-offline",
1308
- "--registry", effectiveRegistry
1309
- ];
1310
-
1311
- const cliJs = getNpmCliJsPath();
1312
- console.log(`使用 npm:${fs.existsSync(cliJs) ? `node ${cliJs}` : getNpmExecutable()}`);
1313
- console.log(`使用 npm registry:${effectiveRegistry}`);
1314
- console.log("Installing OpenCode Langfuse plugin. This can take a few minutes on slow networks...");
1315
-
1316
- let r = await runNpmInstallCapture(npmArgs, { timeoutMs: 600000 });
1317
-
1318
- if (!r.error && r.status !== 0 && npmInstallLooksLikeMissingVersion(r) && !isOfficialNpmRegistry(effectiveRegistry)) {
1319
- console.error("");
1320
- console.error(`npm registry appears to be missing a package version. Retrying with ${OFFICIAL_NPM_REGISTRY} ...`);
1321
- const retryArgs = [
1322
- "install", pkgName, "--prefix", opencodeDir,
1323
- "--package-lock=false", "--no-save",
1324
- "--audit=false", "--fund=false",
1325
- "--prefer-offline",
1326
- "--registry", OFFICIAL_NPM_REGISTRY
1327
- ];
1328
- r = await runNpmInstallCapture(retryArgs, { timeoutMs: 600000 });
1329
- if (!r.error && r.status === 0) return;
1330
- }
1331
-
1332
- if (!r.error && r.status === 0) return;
1333
-
1334
- printNpmDiagnostics();
1335
- const npmLabel = fs.existsSync(cliJs) ? `node ${cliJs}` : getNpmExecutable();
1336
- throw new Error(
1337
- `执行 ${npmLabel} ${npmArgs.join(" ")} 失败(exit=${r.status}${r.error ? ` ${r.error.message}` : ""})。` +
1338
- `见 README「常见问题」或设置 OPENCODE_SKIP_PLUGIN_INSTALL=1 后重试。`
1086
+ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli }) {
1087
+ const shimDir = path.join(opencodeDir, "bin");
1088
+ ensureDir(shimDir);
1089
+ if (process.platform === "win32") {
1090
+ const shim = path.join(shimDir, "opencode.cmd");
1091
+ const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
1092
+ cmd.push("set OH_LANGFUSE_OPENCODE_SHIM=1");
1093
+ cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
1094
+ cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
1095
+ cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
1096
+ if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
1097
+ cmd.push(...windowsGitPathBootstrap());
1098
+ cmd.push(windowsAutoUpdateCommand("opencode"));
1099
+ cmd.push(`set "OH_LANGFUSE_REAL_OPENCODE=${cmdSetValue(realOpencodeCli)}"`);
1100
+ cmd.push('echo "%OH_LANGFUSE_REAL_OPENCODE%" | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
1101
+ cmd.push('if errorlevel 1 if exist "%OH_LANGFUSE_REAL_OPENCODE%" (');
1102
+ cmd.push(' call "%OH_LANGFUSE_REAL_OPENCODE%" %*');
1103
+ cmd.push(" exit /b");
1104
+ cmd.push(")");
1105
+ cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
1106
+ cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
1107
+ cmd.push(" exit /b");
1108
+ cmd.push(")");
1109
+ cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
1110
+ cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
1111
+ cmd.push(" exit /b");
1112
+ cmd.push(")");
1113
+ cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
1114
+ cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
1115
+ cmd.push(" exit /b");
1116
+ cmd.push(")");
1117
+ cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
1118
+ cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
1119
+ cmd.push(" exit /b");
1120
+ cmd.push(")");
1121
+ cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
1122
+ cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
1123
+ cmd.push(" exit /b");
1124
+ cmd.push(")");
1125
+ cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" (');
1126
+ cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" %*');
1127
+ cmd.push(" exit /b");
1128
+ cmd.push(")");
1129
+ cmd.push('if exist "%APPDATA%\\npm\\opencode.cmd" (');
1130
+ cmd.push(' call "%APPDATA%\\npm\\opencode.cmd" %*');
1131
+ cmd.push(" exit /b");
1132
+ cmd.push(")");
1133
+ cmd.push('for /f "delims=" %%O in (\'where.exe opencode 2^>nul\') do (');
1134
+ cmd.push(' if /I not "%%~fO"=="%~f0" (');
1135
+ cmd.push(' echo %%~fO | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
1136
+ cmd.push(" if errorlevel 1 (");
1137
+ cmd.push(' call "%%O" %*');
1138
+ cmd.push(" exit /b");
1139
+ cmd.push(" )");
1140
+ cmd.push(" )");
1141
+ cmd.push(")");
1142
+ cmd.push("echo [ERROR] OpenCode CLI not found. Install OpenCode CLI first. 1>&2");
1143
+ cmd.push("echo Try: npm install -g opencode-ai@latest 1>&2");
1144
+ cmd.push("echo Then run: npx oh-langfuse@latest update opencode 1>&2");
1145
+ cmd.push("exit /b 127");
1146
+ fs.writeFileSync(shim, cmd.join("\r\n") + "\r\n", "utf8");
1147
+ return { shim, shimDir };
1148
+ }
1149
+
1150
+ const shim = path.join(shimDir, "opencode");
1151
+ const quotedRealOpencodeCli = shQuote(realOpencodeCli);
1152
+ const lines = [
1153
+ "#!/usr/bin/env sh",
1154
+ "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
1155
+ "set -eu",
1156
+ "export OH_LANGFUSE_OPENCODE_SHIM=1",
1157
+ `export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
1158
+ `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
1159
+ `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
1160
+ userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
1161
+ unixAutoUpdateCommand("opencode"),
1162
+ `OH_LANGFUSE_REAL_OPENCODE=${quotedRealOpencodeCli}`,
1163
+ "OH_LANGFUSE_BROKEN_OPENCODE=",
1164
+ "try_opencode_candidate() {",
1165
+ ' candidate="$1"',
1166
+ " shift",
1167
+ ' if [ -x "$candidate" ]; then',
1168
+ ' if "$candidate" --version >/dev/null 2>&1; then exec "$candidate" "$@"; fi',
1169
+ ' OH_LANGFUSE_BROKEN_OPENCODE="$candidate"',
1170
+ " fi",
1171
+ "}",
1172
+ 'try_opencode_candidate "$HOME/.opencode/bin/opencode" "$@"',
1173
+ 'try_opencode_candidate "$OH_LANGFUSE_REAL_OPENCODE" "$@"',
1174
+ 'if command -v opencode >/dev/null 2>&1; then',
1175
+ ' resolved="$(command -v opencode)"',
1176
+ ' if [ "$resolved" != "$0" ]; then try_opencode_candidate "$resolved" "$@"; fi',
1177
+ "fi",
1178
+ 'if [ -n "$OH_LANGFUSE_BROKEN_OPENCODE" ]; then',
1179
+ ' echo "[ERROR] OpenCode CLI was found but failed to start: $OH_LANGFUSE_BROKEN_OPENCODE" >&2',
1180
+ ' echo "Try: npm install -g opencode-ai@latest --registry=https://registry.npmjs.org/" >&2',
1181
+ ' echo "Then run: npx --registry=https://registry.npmjs.org/ -y oh-langfuse@latest update opencode --npmRegistry=https://registry.npmjs.org/" >&2',
1182
+ " exit 127",
1183
+ "fi",
1184
+ 'echo "[ERROR] OpenCode CLI not found. Install OpenCode CLI first." >&2',
1185
+ 'echo "Try: npm install -g opencode-ai@latest" >&2',
1186
+ 'echo "Then run: npx oh-langfuse@latest update opencode" >&2',
1187
+ "exit 127",
1188
+ ""
1189
+ ].filter(Boolean);
1190
+ fs.writeFileSync(shim, lines.join("\n"), "utf8");
1191
+ fs.chmodSync(shim, 0o755);
1192
+ return { shim, shimDir };
1193
+ }
1194
+
1195
+ function writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userId }) {
1196
+ if (process.platform === "win32") return null;
1197
+ const p = path.join(opencodeDir, "launch-opencode-langfuse.sh");
1198
+ const lines = [
1199
+ "#!/usr/bin/env sh",
1200
+ "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
1201
+ "set -eu",
1202
+ `export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
1203
+ `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
1204
+ `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
1205
+ userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
1206
+ unixAutoUpdateCommand("opencode"),
1207
+ 'if [ -x "$HOME/.opencode/bin/opencode" ]; then',
1208
+ ' exec "$HOME/.opencode/bin/opencode" "$@"',
1209
+ "fi",
1210
+ 'exec opencode "$@"',
1211
+ ""
1212
+ ].filter(Boolean);
1213
+ fs.writeFileSync(p, lines.join("\n"), "utf8");
1214
+ fs.chmodSync(p, 0o755);
1215
+ return p;
1216
+ }
1217
+
1218
+ function getNpmExecutable() {
1219
+ if (process.platform === "win32") {
1220
+ const beside = path.join(path.dirname(process.execPath), "npm.cmd");
1221
+ if (fs.existsSync(beside)) return beside;
1222
+ }
1223
+ return process.platform === "win32" ? "npm.cmd" : "npm";
1224
+ }
1225
+
1226
+ /** 与 node.exe 同目录下的 npm-cli.js(避免 Windows 上直接 spawn `*.cmd` 出现 EINVAL)。 */
1227
+ function getNpmCliJsPath() {
1228
+ return path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
1229
+ }
1230
+
1231
+ /** @returns {{ status: number | null, stdout: string, stderr: string, error?: Error }} */
1232
+ function runNpmCapture(npmArgs) {
1233
+ const cliJs = getNpmCliJsPath();
1234
+ if (fs.existsSync(cliJs)) {
1235
+ return spawnSync(process.execPath, [cliJs, ...npmArgs], {
1236
+ encoding: "utf8",
1237
+ windowsHide: true,
1238
+ maxBuffer: 10 * 1024 * 1024
1239
+ });
1240
+ }
1241
+ const npmExe = getNpmExecutable();
1242
+ const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(npmExe);
1243
+ return spawnSync(npmExe, npmArgs, {
1244
+ encoding: "utf8",
1245
+ shell: useShell,
1246
+ windowsHide: true,
1247
+ maxBuffer: 10 * 1024 * 1024
1248
+ });
1249
+ }
1250
+
1251
+ function printNpmDiagnostics() {
1252
+ try {
1253
+ const nodeV = spawnSync(process.execPath, ["-v"], { encoding: "utf8" });
1254
+ console.error("诊断:node 版本 =", (nodeV.stdout || "").trim(), "exit =", nodeV.status);
1255
+ } catch (_) {}
1256
+ const cliJs = getNpmCliJsPath();
1257
+ console.error("诊断:npm 入口 =", fs.existsSync(cliJs) ? `node + ${cliJs}` : getNpmExecutable());
1258
+ try {
1259
+ const npmVersion = runNpmCapture(["-v"]);
1260
+ console.error(
1261
+ "诊断:npm 版本 =",
1262
+ (npmVersion.stdout || "").trim(),
1263
+ "exit =",
1264
+ npmVersion.status,
1265
+ npmVersion.error || ""
1266
+ );
1267
+ } catch (e) {
1268
+ console.error("诊断:无法执行 npm -v:", e?.message || e);
1269
+ }
1270
+ try {
1271
+ const reg = runNpmCapture(["config", "get", "registry"]);
1272
+ console.error("诊断:npm registry =", (reg.stdout || "").trim());
1273
+ if (reg.stderr) console.error(reg.stderr);
1274
+ } catch (_) {}
1275
+ }
1276
+
1277
+ /**
1278
+ * 重置终端状态,防止子进程异常退出后遗留 ANSI 转义序列。
1279
+ */
1280
+ function resetTerminalState() {
1281
+ if (process.stdout.isTTY) {
1282
+ // \x1b[0m - 重置所有颜色和属性
1283
+ // \x1b[?25h - 显示光标(防止 npm 隐藏光标后未恢复)
1284
+ // \x1b[r - 重置滚动区域
1285
+ process.stdout.write('\x1b[0m\x1b[?25h\x1b[r');
1286
+ }
1287
+ }
1288
+
1289
+ function runNpmInstallCapture(npmArgs, options = {}) {
1290
+ return new Promise((resolve) => {
1291
+ const cliJs = getNpmCliJsPath();
1292
+ const npmExe = getNpmExecutable();
1293
+ const command = fs.existsSync(cliJs) ? process.execPath : npmExe;
1294
+ const args = fs.existsSync(cliJs) ? [cliJs, ...npmArgs] : npmArgs;
1295
+ const useShell = process.platform === "win32" && !fs.existsSync(cliJs) && /\.(cmd|bat)$/i.test(npmExe);
1296
+ const timeoutMs = options.timeoutMs ?? 600000; // 默认 10 分钟超时
1297
+
1298
+ const child = spawn(command, args, {
1299
+ encoding: "utf8",
1300
+ shell: useShell,
1301
+ windowsHide: true,
1302
+ timeout: timeoutMs
1303
+ });
1304
+ let stdout = "";
1305
+ let stderr = "";
1306
+ const started = Date.now();
1307
+ const timer = setInterval(() => {
1308
+ const secs = Math.round((Date.now() - started) / 1000);
1309
+ console.error(`npm install still running (${secs}s). Slow networks can take a few minutes...`);
1310
+ }, 15000);
1311
+ child.stdout?.on("data", (chunk) => {
1312
+ const text = String(chunk);
1313
+ stdout += text;
1314
+ process.stdout.write(text);
1315
+ });
1316
+ child.stderr?.on("data", (chunk) => {
1317
+ const text = String(chunk);
1318
+ stderr += text;
1319
+ const filtered = text
1320
+ .split(/(\r?\n)/)
1321
+ .filter((part) => part === "\n" || part === "\r\n" || !shouldSuppressAgentLogLine(part))
1322
+ .join("");
1323
+ if (filtered) process.stderr.write(filtered);
1324
+ });
1325
+ child.on("error", (error) => {
1326
+ clearInterval(timer);
1327
+ resetTerminalState();
1328
+ resolve({ status: null, stdout, stderr, error });
1329
+ });
1330
+ child.on("close", (status) => {
1331
+ clearInterval(timer);
1332
+ if (status !== 0) {
1333
+ resetTerminalState();
1334
+ }
1335
+ resolve({ status, stdout, stderr });
1336
+ });
1337
+ child.on("timeout", () => {
1338
+ clearInterval(timer);
1339
+ console.error("");
1340
+ console.error(`npm install timed out after ${Math.round(timeoutMs / 1000)}s, killing process...`);
1341
+ resetTerminalState();
1342
+ child.kill('SIGTERM');
1343
+ });
1344
+ });
1345
+ }
1346
+
1347
+ function npmInstallLooksLikeMissingVersion(result) {
1348
+ const text = `${result?.stdout || ""}\n${result?.stderr || ""}\n${result?.error?.message || ""}`.toLowerCase();
1349
+ return text.includes("etarget") || text.includes("notarget") || text.includes("no matching version found");
1350
+ }
1351
+
1352
+ function isOfficialNpmRegistry(registry) {
1353
+ return /^https?:\/\/registry\.npmjs\.org\/?$/i.test(String(registry || "").trim());
1354
+ }
1355
+
1356
+ const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
1357
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmmirror.com/";
1358
+
1359
+ function isChinaNetwork() {
1360
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
1361
+ return tz.includes("Shanghai") || tz.includes("Chongqing") || tz.includes("Harbin");
1362
+ }
1363
+
1364
+ async function checkInstalledVersion(opencodeDir, pkgName) {
1365
+ const pkgJsonPath = path.join(opencodeDir, "node_modules", pkgName, "package.json");
1366
+ if (!fs.existsSync(pkgJsonPath)) return null;
1367
+ try {
1368
+ const txt = stripBom(fs.readFileSync(pkgJsonPath, "utf8"));
1369
+ if (!txt.trim()) return null;
1370
+ const installed = parseJsonRelaxed(txt, pkgJsonPath);
1371
+ return installed?.version || null;
1372
+ } catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+
1377
+ async function runNpmInstallOrThrow({ opencodeDir, pkgName = "opencode-plugin-langfuse", npmRegistry = "" }) {
1378
+ const installedVersion = await checkInstalledVersion(opencodeDir, pkgName);
1379
+ const currentVersion = packageJson.version;
1380
+
1381
+ if (installedVersion === currentVersion) {
1382
+ console.log(`插件版本 ${currentVersion} 已安装,跳过 npm install ...`);
1383
+ return;
1384
+ }
1385
+
1386
+ if (installedVersion) {
1387
+ console.log(`插件版本 ${installedVersion} 已安装,升级到 ${currentVersion} ...`);
1388
+ }
1389
+
1390
+ const isOfficial = npmRegistry && isOfficialNpmRegistry(npmRegistry);
1391
+ const effectiveRegistry = npmRegistry || (isChinaNetwork() ? DEFAULT_NPM_REGISTRY : OFFICIAL_NPM_REGISTRY);
1392
+
1393
+ const npmArgs = [
1394
+ "install", pkgName, "--prefix", opencodeDir,
1395
+ "--package-lock=false", "--no-save",
1396
+ "--audit=false", "--fund=false",
1397
+ "--prefer-offline",
1398
+ "--registry", effectiveRegistry
1399
+ ];
1400
+
1401
+ const cliJs = getNpmCliJsPath();
1402
+ console.log(`使用 npm:${fs.existsSync(cliJs) ? `node ${cliJs}` : getNpmExecutable()}`);
1403
+ console.log(`使用 npm registry:${effectiveRegistry}`);
1404
+ console.log("Installing OpenCode Langfuse plugin. This can take a few minutes on slow networks...");
1405
+
1406
+ let r = await runNpmInstallCapture(npmArgs, { timeoutMs: 600000 });
1407
+
1408
+ if (!r.error && r.status !== 0 && npmInstallLooksLikeMissingVersion(r) && !isOfficialNpmRegistry(effectiveRegistry)) {
1409
+ console.error("");
1410
+ console.error(`npm registry appears to be missing a package version. Retrying with ${OFFICIAL_NPM_REGISTRY} ...`);
1411
+ const retryArgs = [
1412
+ "install", pkgName, "--prefix", opencodeDir,
1413
+ "--package-lock=false", "--no-save",
1414
+ "--audit=false", "--fund=false",
1415
+ "--prefer-offline",
1416
+ "--registry", OFFICIAL_NPM_REGISTRY
1417
+ ];
1418
+ r = await runNpmInstallCapture(retryArgs, { timeoutMs: 600000 });
1419
+ if (!r.error && r.status === 0) return;
1420
+ }
1421
+
1422
+ if (!r.error && r.status === 0) return;
1423
+
1424
+ printNpmDiagnostics();
1425
+ const npmLabel = fs.existsSync(cliJs) ? `node ${cliJs}` : getNpmExecutable();
1426
+ throw new Error(
1427
+ `执行 ${npmLabel} ${npmArgs.join(" ")} 失败(exit=${r.status}${r.error ? ` ${r.error.message}` : ""})。` +
1428
+ `见 README「常见问题」或设置 OPENCODE_SKIP_PLUGIN_INSTALL=1 后重试。`
1429
+ );
1430
+ }
1431
+
1432
+ function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
1433
+ const cmd = [
1434
+ "$ErrorActionPreference = 'Stop';",
1435
+ `[Environment]::SetEnvironmentVariable('LANGFUSE_PUBLIC_KEY', ${psQuote(publicKey)}, 'User');`,
1436
+ `[Environment]::SetEnvironmentVariable('LANGFUSE_SECRET_KEY', ${psQuote(secretKey)}, 'User');`,
1437
+ `[Environment]::SetEnvironmentVariable('LANGFUSE_BASEURL', ${psQuote(baseUrl)}, 'User');`
1438
+ ].join(" ");
1439
+ const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1440
+ if (r.status !== 0) {
1441
+ throw new Error("写入用户级环境变量失败。");
1442
+ }
1443
+ }
1444
+
1445
+ function prependWindowsUserPath(dir) {
1446
+ if (process.platform !== "win32") return false;
1447
+ const cmd = [
1448
+ "$ErrorActionPreference = 'Stop';",
1449
+ `$dir = ${psQuote(dir)};`,
1450
+ "$current = [Environment]::GetEnvironmentVariable('Path', 'User');",
1451
+ "$parts = @();",
1452
+ "if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() } }",
1453
+ "$exists = $false;",
1454
+ "foreach ($part in $parts) { if ([string]::Equals($part.TrimEnd('\\'), $dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase)) { $exists = $true } }",
1455
+ "if (-not $exists) {",
1456
+ " $next = (@($dir) + $parts) -join ';';",
1457
+ " [Environment]::SetEnvironmentVariable('Path', $next, 'User');",
1458
+ "}"
1459
+ ].join(" ");
1460
+ const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1461
+ if (r.status !== 0) {
1462
+ throw new Error("写入用户 PATH 失败:无法把 OpenCode Langfuse shim 加入 PATH。");
1463
+ }
1464
+ const currentParts = String(process.env.PATH || "").split(path.delimiter);
1465
+ const normalized = dir.replace(/[\\/]+$/, "").toLowerCase();
1466
+ if (!currentParts.some((part) => part && part.replace(/[\\/]+$/, "").toLowerCase() === normalized)) {
1467
+ process.env.PATH = `${dir}${path.delimiter}${process.env.PATH || ""}`;
1468
+ }
1469
+ return true;
1470
+ }
1471
+
1472
+ function getShellConfigPath() {
1473
+ const shell = process.env.SHELL || "/bin/bash";
1474
+ if (shell.includes("zsh")) {
1475
+ return path.join(os.homedir(), ".zshrc");
1476
+ }
1477
+ return path.join(os.homedir(), ".bashrc");
1478
+ }
1479
+
1480
+ function updateShellConfig({ publicKey, secretKey, baseUrl, userId, pathPrepend = "" }) {
1481
+ const configPath = getShellConfigPath();
1482
+ const marker = "# === Langfuse OpenCode Setup ===";
1483
+ const endMarker = "# === End Langfuse OpenCode Setup ===";
1484
+
1485
+ let content = "";
1486
+ if (fs.existsSync(configPath)) {
1487
+ content = fs.readFileSync(configPath, "utf8");
1488
+ }
1489
+
1490
+ // 移除旧的配置块
1491
+ const startIdx = content.indexOf(marker);
1492
+ if (startIdx !== -1) {
1493
+ const endIdx = content.indexOf(endMarker, startIdx);
1494
+ if (endIdx !== -1) {
1495
+ content = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length);
1496
+ }
1497
+ }
1498
+
1499
+ // 构建新的配置块
1500
+ const envBlock = [
1501
+ "",
1502
+ marker,
1503
+ `export LANGFUSE_PUBLIC_KEY="${publicKey}"`,
1504
+ `export LANGFUSE_SECRET_KEY="${secretKey}"`,
1505
+ `export LANGFUSE_BASEURL="${baseUrl}"`,
1506
+ userId ? `export LANGFUSE_USER_ID="${userId}"` : null,
1507
+ pathPrepend ? `export PATH="${pathPrepend}:$PATH"` : null,
1508
+ endMarker,
1509
+ ""
1510
+ ].filter(Boolean).join("\n");
1511
+
1512
+ // 追加到文件末尾
1513
+ content = content.trimEnd() + "\n" + envBlock + "\n";
1514
+ fs.writeFileSync(configPath, content, "utf8");
1515
+
1516
+ return configPath;
1517
+ }
1518
+
1519
+ async function main() {
1520
+ const args = parseArgs(process.argv.slice(2));
1521
+
1522
+ const setEnv = !args["no-set-env"];
1523
+ const skipPluginInstall =
1524
+ !!(args["skip-plugin-install"] || args.skipNpmInstall || process.env.OPENCODE_SKIP_PLUGIN_INSTALL);
1525
+
1526
+ const publicKey =
1527
+ args.publicKey || process.env.LANGFUSE_PUBLIC_KEY || "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
1528
+ const secretKey =
1529
+ args.secretKey || process.env.LANGFUSE_SECRET_KEY || "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
1530
+ const baseUrl = normalizeLegacyBaseUrl(
1531
+ args.langfuseBaseUrl || process.env.LANGFUSE_BASEURL || DEFAULT_LANGFUSE_BASE_URL
1339
1532
  );
1340
- }
1341
-
1342
- function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
1343
- const cmd = [
1344
- "$ErrorActionPreference = 'Stop';",
1345
- `[Environment]::SetEnvironmentVariable('LANGFUSE_PUBLIC_KEY', ${psQuote(publicKey)}, 'User');`,
1346
- `[Environment]::SetEnvironmentVariable('LANGFUSE_SECRET_KEY', ${psQuote(secretKey)}, 'User');`,
1347
- `[Environment]::SetEnvironmentVariable('LANGFUSE_BASEURL', ${psQuote(baseUrl)}, 'User');`
1348
- ].join(" ");
1349
- const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1350
- if (r.status !== 0) {
1351
- throw new Error("写入用户级环境变量失败。");
1352
- }
1353
- }
1354
-
1355
- function prependWindowsUserPath(dir) {
1356
- if (process.platform !== "win32") return false;
1357
- const cmd = [
1358
- "$ErrorActionPreference = 'Stop';",
1359
- `$dir = ${psQuote(dir)};`,
1360
- "$current = [Environment]::GetEnvironmentVariable('Path', 'User');",
1361
- "$parts = @();",
1362
- "if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() } }",
1363
- "$exists = $false;",
1364
- "foreach ($part in $parts) { if ([string]::Equals($part.TrimEnd('\\'), $dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase)) { $exists = $true } }",
1365
- "if (-not $exists) {",
1366
- " $next = (@($dir) + $parts) -join ';';",
1367
- " [Environment]::SetEnvironmentVariable('Path', $next, 'User');",
1368
- "}"
1369
- ].join(" ");
1370
- const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1371
- if (r.status !== 0) {
1372
- throw new Error("写入用户 PATH 失败:无法把 OpenCode Langfuse shim 加入 PATH。");
1373
- }
1374
- const currentParts = String(process.env.PATH || "").split(path.delimiter);
1375
- const normalized = dir.replace(/[\\/]+$/, "").toLowerCase();
1376
- if (!currentParts.some((part) => part && part.replace(/[\\/]+$/, "").toLowerCase() === normalized)) {
1377
- process.env.PATH = `${dir}${path.delimiter}${process.env.PATH || ""}`;
1378
- }
1379
- return true;
1380
- }
1381
-
1382
- function getShellConfigPath() {
1383
- const shell = process.env.SHELL || "/bin/bash";
1384
- if (shell.includes("zsh")) {
1385
- return path.join(os.homedir(), ".zshrc");
1386
- }
1387
- return path.join(os.homedir(), ".bashrc");
1388
- }
1389
-
1390
- function updateShellConfig({ publicKey, secretKey, baseUrl, userId, pathPrepend = "" }) {
1391
- const configPath = getShellConfigPath();
1392
- const marker = "# === Langfuse OpenCode Setup ===";
1393
- const endMarker = "# === End Langfuse OpenCode Setup ===";
1394
-
1395
- let content = "";
1396
- if (fs.existsSync(configPath)) {
1397
- content = fs.readFileSync(configPath, "utf8");
1398
- }
1399
-
1400
- // 移除旧的配置块
1401
- const startIdx = content.indexOf(marker);
1402
- if (startIdx !== -1) {
1403
- const endIdx = content.indexOf(endMarker, startIdx);
1404
- if (endIdx !== -1) {
1405
- content = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length);
1406
- }
1407
- }
1408
-
1409
- // 构建新的配置块
1410
- const envBlock = [
1411
- "",
1412
- marker,
1413
- `export LANGFUSE_PUBLIC_KEY="${publicKey}"`,
1414
- `export LANGFUSE_SECRET_KEY="${secretKey}"`,
1415
- `export LANGFUSE_BASEURL="${baseUrl}"`,
1416
- userId ? `export LANGFUSE_USER_ID="${userId}"` : null,
1417
- pathPrepend ? `export PATH="${pathPrepend}:$PATH"` : null,
1418
- endMarker,
1419
- ""
1420
- ].filter(Boolean).join("\n");
1421
-
1422
- // 追加到文件末尾
1423
- content = content.trimEnd() + "\n" + envBlock + "\n";
1424
- fs.writeFileSync(configPath, content, "utf8");
1425
-
1426
- return configPath;
1427
- }
1428
-
1429
- async function main() {
1430
- const args = parseArgs(process.argv.slice(2));
1431
-
1432
- const setEnv = !args["no-set-env"];
1433
- const skipPluginInstall =
1434
- !!(args["skip-plugin-install"] || args.skipNpmInstall || process.env.OPENCODE_SKIP_PLUGIN_INSTALL);
1435
-
1436
- const publicKey =
1437
- args.publicKey || process.env.LANGFUSE_PUBLIC_KEY || "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
1438
- const secretKey =
1439
- args.secretKey || process.env.LANGFUSE_SECRET_KEY || "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
1440
- const baseUrl =
1441
- args.langfuseBaseUrl || process.env.LANGFUSE_BASEURL || "http://120.46.221.227:3000";
1442
- const userId = normalizeUserId(args.userId || args.userid || "");
1443
- if (!userId || typeof userId !== "string") {
1444
- throw new Error("缺少参数:--userId=你的工号");
1445
- }
1446
- assertValidUserId(userId);
1447
- const npmRegistry = args.npmRegistry || process.env.OPENCODE_NPM_REGISTRY || "";
1448
-
1449
- const home = os.homedir();
1450
- const opencodeDir = path.join(home, ".config", "opencode");
1451
- const pluginsDir = path.join(opencodeDir, "plugins");
1452
- const pkgDir = path.join(opencodeDir, "node_modules", "opencode-plugin-langfuse");
1453
- const pkgJsonPath = path.join(pkgDir, "package.json");
1454
- const pluginDest = path.join(pluginsDir, "opencode-plugin-langfuse");
1455
- const opencodeJsonPath = path.join(opencodeDir, "opencode.json");
1456
-
1457
- ensureDir(opencodeDir);
1458
-
1459
- console.log(`OpenCode 配置目录:${opencodeDir}`);
1460
-
1461
- if (skipPluginInstall && fs.existsSync(pkgJsonPath)) {
1462
- console.log(`已跳过 npm install(--skip-plugin-install 且已存在):${pkgDir}`);
1463
- } else {
1464
- console.log("安装 npm 包:opencode-plugin-langfuse …");
1465
- await runNpmInstallOrThrow({ opencodeDir, npmRegistry });
1466
- }
1467
-
1468
- if (!fs.existsSync(pkgJsonPath)) {
1469
- throw new Error(
1470
- `未找到已安装包目录:${pkgDir}。请先解决上方 npm 报错,或手动在此目录执行 npm install opencode-plugin-langfuse。`
1471
- );
1472
- }
1473
-
1474
- const existing = readJsonIfExists(opencodeJsonPath) ?? {};
1475
- // Patch the official plugin to inject userId and make behavior consistent.
1476
- const distIndexInNodeModules = path.join(pkgDir, "dist", "index.js");
1477
- if (fs.existsSync(distIndexInNodeModules)) {
1478
- patchDistIndexJs(distIndexInNodeModules);
1479
- }
1480
-
1481
- console.log("Syncing plugin into ~/.config/opencode/plugins/ ...");
1482
- syncPluginPackageToLocalPlugins({ pkgDir, pluginDest, pluginsDir });
1483
- console.log(`Plugin ready at: ${pluginDest}`);
1484
-
1485
- const distIndexInPlugins = path.join(pluginDest, "dist", "index.js");
1486
- if (fs.existsSync(distIndexInPlugins)) {
1487
- patchDistIndexJs(distIndexInPlugins);
1488
- }
1489
-
1490
- // Write plugin user config to support userId injection.
1491
- if (userId) {
1492
- const cfg = writeLangfusePluginUserConfig({ userId });
1493
- console.log(`已更新:${cfg}`);
1494
- }
1495
-
1496
- const langfusePluginPath = "./plugins/opencode-plugin-langfuse";
1497
- const basePlugins = removePlugins(existing.plugin, [
1498
- "opencode-plugin-langfuse-userid",
1499
- "opencode-plugin-langfuse",
1500
- langfusePluginPath
1501
- ]);
1502
- const desired = {
1503
- experimental: {
1504
- openTelemetry: true
1505
- },
1506
- plugin: mergePluginList(basePlugins, langfusePluginPath)
1507
- };
1508
- const merged = deepMerge(existing, desired);
1509
- writeJsonPretty(opencodeJsonPath, merged);
1510
- let realOpencodeCli = resolveOpencodeCli(args.cmd);
1511
- const shimDir = path.join(opencodeDir, "bin");
1533
+ const userId = normalizeUserId(args.userId || args.userid || "");
1534
+ if (!userId || typeof userId !== "string") {
1535
+ throw new Error("缺少参数:--userId=你的工号");
1536
+ }
1537
+ assertValidUserId(userId);
1538
+ const npmRegistry = args.npmRegistry || process.env.OPENCODE_NPM_REGISTRY || "";
1539
+
1540
+ const home = os.homedir();
1541
+ const opencodeDir = path.join(home, ".config", "opencode");
1542
+ const pluginsDir = path.join(opencodeDir, "plugins");
1543
+ const pkgDir = path.join(opencodeDir, "node_modules", "opencode-plugin-langfuse");
1544
+ const pkgJsonPath = path.join(pkgDir, "package.json");
1545
+ const pluginDest = path.join(pluginsDir, "opencode-plugin-langfuse");
1546
+ const opencodeJsonPath = path.join(opencodeDir, "opencode.json");
1547
+
1548
+ ensureDir(opencodeDir);
1549
+
1550
+ console.log(`OpenCode 配置目录:${opencodeDir}`);
1551
+
1552
+ if (skipPluginInstall && fs.existsSync(pkgJsonPath)) {
1553
+ console.log(`已跳过 npm install(--skip-plugin-install 且已存在):${pkgDir}`);
1554
+ } else {
1555
+ console.log("安装 npm 包:opencode-plugin-langfuse …");
1556
+ await runNpmInstallOrThrow({ opencodeDir, npmRegistry });
1557
+ }
1558
+
1559
+ if (!fs.existsSync(pkgJsonPath)) {
1560
+ throw new Error(
1561
+ `未找到已安装包目录:${pkgDir}。请先解决上方 npm 报错,或手动在此目录执行 npm install opencode-plugin-langfuse。`
1562
+ );
1563
+ }
1564
+
1565
+ const existing = readJsonIfExists(opencodeJsonPath) ?? {};
1566
+ // Patch the official plugin to inject userId and make behavior consistent.
1567
+ const distIndexInNodeModules = path.join(pkgDir, "dist", "index.js");
1568
+ if (fs.existsSync(distIndexInNodeModules)) {
1569
+ patchDistIndexJs(distIndexInNodeModules);
1570
+ }
1571
+
1572
+ console.log("Syncing plugin into ~/.config/opencode/plugins/ ...");
1573
+ syncPluginPackageToLocalPlugins({ pkgDir, pluginDest, pluginsDir });
1574
+ console.log(`Plugin ready at: ${pluginDest}`);
1575
+
1576
+ const distIndexInPlugins = path.join(pluginDest, "dist", "index.js");
1577
+ if (fs.existsSync(distIndexInPlugins)) {
1578
+ patchDistIndexJs(distIndexInPlugins);
1579
+ }
1580
+
1581
+ // Write plugin user config to support userId injection.
1582
+ if (userId) {
1583
+ const cfg = writeLangfusePluginUserConfig({ userId });
1584
+ console.log(`已更新:${cfg}`);
1585
+ }
1586
+
1587
+ const langfusePluginPath = "./plugins/opencode-plugin-langfuse";
1588
+ const basePlugins = removePlugins(existing.plugin, [
1589
+ "opencode-plugin-langfuse-userid",
1590
+ "opencode-plugin-langfuse",
1591
+ langfusePluginPath
1592
+ ]);
1593
+ const desired = {
1594
+ experimental: {
1595
+ openTelemetry: true
1596
+ },
1597
+ plugin: mergePluginList(basePlugins, langfusePluginPath)
1598
+ };
1599
+ const merged = deepMerge(existing, desired);
1600
+ writeJsonPretty(opencodeJsonPath, merged);
1601
+ let realOpencodeCli = resolveOpencodeCli(args.cmd);
1602
+ const shimDir = path.join(opencodeDir, "bin");
1512
1603
  if (realOpencodeCli) {
1513
1604
  const relativeToShim = path.relative(path.resolve(shimDir), path.resolve(realOpencodeCli));
1514
1605
  if (relativeToShim && !relativeToShim.startsWith("..") && !path.isAbsolute(relativeToShim)) {
1515
- realOpencodeCli = "";
1606
+ realOpencodeCli = readRealOpencodeFromManagedShim(realOpencodeCli);
1516
1607
  }
1517
1608
  }
1518
- console.log(`已更新:${opencodeJsonPath}`);
1519
-
1520
- const launcher = writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, userId });
1521
- if (launcher) {
1522
- console.log(`已生成快捷启动脚本(含 LANGFUSE 环境变量):${launcher}`);
1523
- console.log("若不从当前终端启动,也可双击或用该脚本启动 OpenCode(避免插件读不到密钥)。");
1524
- }
1525
- const unixLauncher = writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userId });
1526
- if (unixLauncher) {
1527
- console.log(`已生成启动脚本(含 LANGFUSE 环境变量):${unixLauncher}`);
1528
- printCommandHint("如果新终端仍读不到环境变量,可运行:", unixLauncher);
1529
- }
1530
-
1531
- let opencodeShim = null;
1532
- if (realOpencodeCli) {
1533
- opencodeShim = writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli });
1534
- console.log(`OpenCode command shim ready: ${opencodeShim.shim}`);
1535
- console.log(`Real OpenCode CLI: ${realOpencodeCli}`);
1536
- } else {
1537
- console.log("OpenCode CLI not found; skipped opencode command shim.");
1538
- }
1539
-
1540
- let shellConfigPathWritten = "";
1541
- if (setEnv) {
1542
- if (process.platform === "win32") {
1543
- console.log("写入用户级环境变量:LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASEURL …");
1544
- setWindowsUserEnv({ publicKey, secretKey, baseUrl: baseUrl });
1545
- if (userId) {
1546
- const cmd = [
1547
- "$ErrorActionPreference = 'Stop';",
1548
- `[Environment]::SetEnvironmentVariable('LANGFUSE_USER_ID', ${psQuote(userId)}, 'User');`
1549
- ].join(" ");
1550
- spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1551
- }
1552
- if (opencodeShim?.shimDir) {
1553
- prependWindowsUserPath(opencodeShim.shimDir);
1554
- console.log(`已将 OpenCode Langfuse shim 加入用户 PATH:${opencodeShim.shimDir}`);
1555
- }
1556
- console.log("提示:新开一个终端后环境变量才会对应用生效。");
1557
- } else {
1558
- console.log("正在写入环境变量到 shell 配置文件 …");
1559
- const configPath = updateShellConfig({ publicKey, secretKey, baseUrl, userId, pathPrepend: opencodeShim?.shimDir || "" });
1560
- shellConfigPathWritten = configPath;
1561
- console.log(`已更新:${configPath}`);
1562
-
1563
- // 自动 source 配置文件
1564
- console.log("正在 source 配置文件使环境变量生效 …");
1565
- const r = spawnSync("bash", ["-c", `source "${configPath}" && env | grep -E '^LANGFUSE_'`], {
1566
- encoding: "utf8",
1567
- stdio: "pipe"
1568
- });
1569
- if (r.status === 0) {
1570
- console.log("环境变量已生效:");
1571
- console.log(r.stdout);
1572
- } else {
1573
- printCommandHint("提示:环境变量已写入配置文件,请运行以下命令使其生效:", `source ${configPath}`);
1574
- }
1575
- }
1576
- }
1577
-
1578
- console.log(paint("完成。", colors.green, colors.bold));
1579
- const runtimeState = writeRuntimeInstallRecord("opencode", {
1580
- packageName: packageJson.name,
1581
- packageVersion: packageJson.version,
1582
- });
1583
- console.log(`Runtime version recorded: ${runtimeState}`);
1584
- printCommandHint("可运行以下命令校验:", "npx oh-langfuse@latest check opencode");
1585
- if (process.platform !== "win32") {
1586
- console.log("");
1587
- console.log("Important for WSL/Linux:");
1588
- console.log("This installer wrote LANGFUSE_* to your shell rc file, but it cannot update an already-open parent shell.");
1589
- if (shellConfigPathWritten) {
1590
- printCommandHint("Before starting OpenCode from this same terminal, run:", `source ${shellConfigPathWritten}`);
1591
- }
1592
- if (unixLauncher) {
1593
- printCommandHint("Or start OpenCode with the generated launcher:", unixLauncher);
1594
- }
1595
- console.log("Opening a new WSL terminal also loads the variables for normal shell setups.");
1596
- }
1597
- }
1598
-
1599
- try {
1600
- await main();
1601
- } catch (e) {
1602
- console.error(e?.message || String(e));
1603
- process.exit(1);
1604
- }
1609
+ console.log(`已更新:${opencodeJsonPath}`);
1610
+
1611
+ const launcher = writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, userId });
1612
+ if (launcher) {
1613
+ console.log(`已生成快捷启动脚本(含 LANGFUSE 环境变量):${launcher}`);
1614
+ console.log("若不从当前终端启动,也可双击或用该脚本启动 OpenCode(避免插件读不到密钥)。");
1615
+ }
1616
+ const unixLauncher = writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userId });
1617
+ if (unixLauncher) {
1618
+ console.log(`已生成启动脚本(含 LANGFUSE 环境变量):${unixLauncher}`);
1619
+ printCommandHint("如果新终端仍读不到环境变量,可运行:", unixLauncher);
1620
+ }
1621
+
1622
+ let opencodeShim = null;
1623
+ if (realOpencodeCli) {
1624
+ opencodeShim = writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli });
1625
+ console.log(`OpenCode command shim ready: ${opencodeShim.shim}`);
1626
+ console.log(`Real OpenCode CLI: ${realOpencodeCli}`);
1627
+ } else {
1628
+ console.log("OpenCode CLI not found; skipped opencode command shim.");
1629
+ }
1630
+
1631
+ let shellConfigPathWritten = "";
1632
+ if (setEnv) {
1633
+ if (process.platform === "win32") {
1634
+ console.log("写入用户级环境变量:LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASEURL …");
1635
+ setWindowsUserEnv({ publicKey, secretKey, baseUrl: baseUrl });
1636
+ if (userId) {
1637
+ const cmd = [
1638
+ "$ErrorActionPreference = 'Stop';",
1639
+ `[Environment]::SetEnvironmentVariable('LANGFUSE_USER_ID', ${psQuote(userId)}, 'User');`
1640
+ ].join(" ");
1641
+ spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
1642
+ }
1643
+ if (opencodeShim?.shimDir) {
1644
+ prependWindowsUserPath(opencodeShim.shimDir);
1645
+ console.log(`已将 OpenCode Langfuse shim 加入用户 PATH:${opencodeShim.shimDir}`);
1646
+ }
1647
+ console.log("提示:新开一个终端后环境变量才会对应用生效。");
1648
+ } else {
1649
+ console.log("正在写入环境变量到 shell 配置文件 …");
1650
+ const configPath = updateShellConfig({ publicKey, secretKey, baseUrl, userId, pathPrepend: opencodeShim?.shimDir || "" });
1651
+ shellConfigPathWritten = configPath;
1652
+ console.log(`已更新:${configPath}`);
1653
+
1654
+ // 自动 source 配置文件
1655
+ console.log("正在 source 配置文件使环境变量生效 …");
1656
+ const r = spawnSync("bash", ["-c", `source "${configPath}" && env | grep -E '^LANGFUSE_'`], {
1657
+ encoding: "utf8",
1658
+ stdio: "pipe"
1659
+ });
1660
+ if (r.status === 0) {
1661
+ console.log("环境变量已生效:");
1662
+ console.log(r.stdout);
1663
+ } else {
1664
+ printCommandHint("提示:环境变量已写入配置文件,请运行以下命令使其生效:", `source ${configPath}`);
1665
+ }
1666
+ }
1667
+ }
1668
+
1669
+ console.log(paint("完成。", colors.green, colors.bold));
1670
+ const runtimeState = writeRuntimeInstallRecord("opencode", {
1671
+ packageName: packageJson.name,
1672
+ packageVersion: packageJson.version,
1673
+ });
1674
+ console.log(`Runtime version recorded: ${runtimeState}`);
1675
+ printCommandHint("可运行以下命令校验:", "npx oh-langfuse@latest check opencode");
1676
+ if (process.platform !== "win32") {
1677
+ console.log("");
1678
+ console.log("Important for WSL/Linux:");
1679
+ console.log("This installer wrote LANGFUSE_* to your shell rc file, but it cannot update an already-open parent shell.");
1680
+ if (shellConfigPathWritten) {
1681
+ printCommandHint("Before starting OpenCode from this same terminal, run:", `source ${shellConfigPathWritten}`);
1682
+ }
1683
+ if (unixLauncher) {
1684
+ printCommandHint("Or start OpenCode with the generated launcher:", unixLauncher);
1685
+ }
1686
+ console.log("Opening a new WSL terminal also loads the variables for normal shell setups.");
1687
+ }
1688
+ }
1689
+
1690
+ if (!process.env.OH_LANGFUSE_REGEN_DIST) {
1691
+ try {
1692
+ await main();
1693
+ } catch (e) {
1694
+ console.error(e?.message || String(e));
1695
+ process.exit(1);
1696
+ }
1697
+ }