oh-langfuse 0.1.75 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +11 -9
- package/package.json +1 -1
- package/scripts/opencode-langfuse-setup.mjs +1434 -1434
- package/scripts/real-self-verify.mjs +45 -40
- package/scripts/resolve-opencode-cli.mjs +15 -11
|
@@ -1,111 +1,111 @@
|
|
|
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
|
-
const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
|
|
12
|
-
const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
|
|
13
|
-
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
-
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
15
|
-
|
|
16
|
-
function normalizeUserId(v) {
|
|
17
|
-
return String(v || "").trim();
|
|
18
|
-
}
|
|
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
|
-
|
|
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
|
+
const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
|
|
12
|
+
const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
|
|
13
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
15
|
+
|
|
16
|
+
function normalizeUserId(v) {
|
|
17
|
+
return String(v || "").trim();
|
|
18
|
+
}
|
|
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
109
|
function cmdQuote(s) {
|
|
110
110
|
return `"${String(s).replace(/"/g, '""')}"`;
|
|
111
111
|
}
|
|
@@ -114,312 +114,312 @@ function cmdSetValue(s) {
|
|
|
114
114
|
return String(s).replace(/"/g, '""');
|
|
115
115
|
}
|
|
116
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
|
-
"};",
|
|
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
416
|
"const tokenMetricsFromPart = (part) => {",
|
|
417
417
|
" const tokens = part?.tokens ?? part?.usage ?? {};",
|
|
418
418
|
" return {",
|
|
419
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),",
|
|
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
423
|
" reasoning: metricNumber(tokens.reasoning ?? tokens.reasoning_tokens ?? tokens.reasoningTokens),",
|
|
424
424
|
" };",
|
|
425
425
|
"};",
|
|
@@ -449,226 +449,226 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
449
449
|
" }",
|
|
450
450
|
" return '';",
|
|
451
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
|
-
" });",
|
|
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
672
|
" const getMetricsTracer = () => trace.getTracer('oh-langfuse-opencode-metrics');",
|
|
673
673
|
" const knownSkillNames = await collectKnownSkillNames();",
|
|
674
674
|
" const startupSkillUsages = detectOpencodeSkillUsages(process.argv.join('\\n'), knownSkillNames);",
|
|
@@ -677,75 +677,75 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
677
677
|
" const messageInputById = new Map();",
|
|
678
678
|
" const lastUserTextBySessionId = new Map();",
|
|
679
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);",
|
|
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
749
|
" const partType = part?.type ?? '';",
|
|
750
750
|
" const sessionId = pickEventString(part?.sessionID, part?.sessionId, payload?.sessionID, payload?.sessionId, payload?.session?.id, event?.sessionID, event?.sessionId);",
|
|
751
751
|
" const messageId = pickEventString(part?.messageID, part?.messageId, payload?.messageID, payload?.messageId, payload?.message?.id, event?.messageID, event?.messageId);",
|
|
@@ -757,68 +757,68 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
757
757
|
" if (sessionId) lastUserTextBySessionId.set(sessionId, inputText);",
|
|
758
758
|
" return;",
|
|
759
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');",
|
|
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
773
|
" if (eventText && messageId && (role === 'assistant' || partType === 'text')) {",
|
|
774
774
|
" messageTextById.set(messageId, limitObservationText(eventText));",
|
|
775
775
|
" const textSkillUsages = detectOpencodeSkillUsages(eventText, knownSkillNames);",
|
|
776
776
|
" rememberSkillUsages(skillUsagesByMessageId, messageId, textSkillUsages);",
|
|
777
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);",
|
|
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
785
|
" await sdkStartPromise;",
|
|
786
786
|
" const span = getMetricsTracer().startSpan('OpenCode Agent Turn');",
|
|
787
787
|
" const text = messageTextById.get(messageId) || '';",
|
|
788
788
|
" const inputText = messageInputById.get(messageId) || lastUserTextBySessionId.get(sessionId) || startupPromptText || '';",
|
|
789
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);',
|
|
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
822
|
' if (tokenMetrics.output !== undefined) span.setAttribute("langfuse.observation.metadata.output_tokens", tokenMetrics.output);',
|
|
823
823
|
' if (total !== undefined) span.setAttribute("langfuse.observation.metadata.total_tokens", total);',
|
|
824
824
|
' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
|
|
@@ -835,174 +835,174 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
835
835
|
" span.end();",
|
|
836
836
|
" messageTextById.delete(messageId);",
|
|
837
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();
|
|
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
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
|
-
|
|
974
|
-
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");
|
|
993
|
-
return p;
|
|
994
|
-
}
|
|
995
|
-
|
|
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
|
+
|
|
974
|
+
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");
|
|
993
|
+
return p;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
996
|
function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli }) {
|
|
997
997
|
const shimDir = path.join(opencodeDir, "bin");
|
|
998
998
|
ensureDir(shimDir);
|
|
999
999
|
if (process.platform === "win32") {
|
|
1000
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}`);
|
|
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
1006
|
if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
|
|
1007
1007
|
cmd.push(...windowsGitPathBootstrap());
|
|
1008
1008
|
cmd.push(windowsAutoUpdateCommand("opencode"));
|
|
@@ -1010,42 +1010,42 @@ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl,
|
|
|
1010
1010
|
cmd.push('echo "%OH_LANGFUSE_REAL_OPENCODE%" | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
|
|
1011
1011
|
cmd.push('if errorlevel 1 if exist "%OH_LANGFUSE_REAL_OPENCODE%" (');
|
|
1012
1012
|
cmd.push(' call "%OH_LANGFUSE_REAL_OPENCODE%" %*');
|
|
1013
|
-
cmd.push(" exit /b
|
|
1013
|
+
cmd.push(" exit /b");
|
|
1014
1014
|
cmd.push(")");
|
|
1015
1015
|
cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
|
|
1016
1016
|
cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
|
|
1017
|
-
cmd.push(" exit /b
|
|
1018
|
-
cmd.push(")");
|
|
1019
|
-
cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" (');
|
|
1020
|
-
cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" %*');
|
|
1021
|
-
cmd.push(" exit /b %ERRORLEVEL%");
|
|
1022
|
-
cmd.push(")");
|
|
1023
|
-
cmd.push('if exist "%APPDATA%\\npm\\opencode.cmd" (');
|
|
1024
|
-
cmd.push(' call "%APPDATA%\\npm\\opencode.cmd" %*');
|
|
1025
|
-
cmd.push(" exit /b %ERRORLEVEL%");
|
|
1017
|
+
cmd.push(" exit /b");
|
|
1026
1018
|
cmd.push(")");
|
|
1027
1019
|
cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
|
|
1028
1020
|
cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
|
|
1029
|
-
cmd.push(" exit /b
|
|
1021
|
+
cmd.push(" exit /b");
|
|
1030
1022
|
cmd.push(")");
|
|
1031
1023
|
cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
|
|
1032
1024
|
cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
|
|
1033
|
-
cmd.push(" exit /b
|
|
1025
|
+
cmd.push(" exit /b");
|
|
1034
1026
|
cmd.push(")");
|
|
1035
1027
|
cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" (');
|
|
1036
1028
|
cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe" %*');
|
|
1037
|
-
cmd.push(" exit /b
|
|
1029
|
+
cmd.push(" exit /b");
|
|
1038
1030
|
cmd.push(")");
|
|
1039
1031
|
cmd.push('if exist "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" (');
|
|
1040
1032
|
cmd.push(' call "%APPDATA%\\npm\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe" %*');
|
|
1041
|
-
cmd.push(" exit /b
|
|
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
1042
|
cmd.push(")");
|
|
1043
1043
|
cmd.push('for /f "delims=" %%O in (\'where.exe opencode 2^>nul\') do (');
|
|
1044
1044
|
cmd.push(' if /I not "%%~fO"=="%~f0" (');
|
|
1045
1045
|
cmd.push(' echo %%~fO | findstr /I "\\node_modules\\opencode-ai\\bin\\opencode.exe" >nul');
|
|
1046
1046
|
cmd.push(" if errorlevel 1 (");
|
|
1047
1047
|
cmd.push(' call "%%O" %*');
|
|
1048
|
-
cmd.push(" exit /b
|
|
1048
|
+
cmd.push(" exit /b");
|
|
1049
1049
|
cmd.push(" )");
|
|
1050
1050
|
cmd.push(" )");
|
|
1051
1051
|
cmd.push(")");
|
|
@@ -1064,7 +1064,7 @@ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl,
|
|
|
1064
1064
|
"# Auto-generated by scripts/opencode-langfuse-setup.mjs",
|
|
1065
1065
|
"set -eu",
|
|
1066
1066
|
"export OH_LANGFUSE_OPENCODE_SHIM=1",
|
|
1067
|
-
`export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
|
|
1067
|
+
`export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
|
|
1068
1068
|
`export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
|
|
1069
1069
|
`export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
|
|
1070
1070
|
userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
|
|
@@ -1097,508 +1097,508 @@ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl,
|
|
|
1097
1097
|
"exit 127",
|
|
1098
1098
|
""
|
|
1099
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());
|
|
1168
|
-
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;
|
|
1282
|
-
} catch {
|
|
1283
|
-
return null;
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
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 后重试。`
|
|
1339
|
-
);
|
|
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");
|
|
1512
|
-
if (realOpencodeCli) {
|
|
1513
|
-
const relativeToShim = path.relative(path.resolve(shimDir), path.resolve(realOpencodeCli));
|
|
1514
|
-
if (relativeToShim && !relativeToShim.startsWith("..") && !path.isAbsolute(relativeToShim)) {
|
|
1515
|
-
realOpencodeCli = "";
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
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
|
-
}
|
|
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());
|
|
1168
|
+
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;
|
|
1282
|
+
} catch {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
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 后重试。`
|
|
1339
|
+
);
|
|
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");
|
|
1512
|
+
if (realOpencodeCli) {
|
|
1513
|
+
const relativeToShim = path.relative(path.resolve(shimDir), path.resolve(realOpencodeCli));
|
|
1514
|
+
if (relativeToShim && !relativeToShim.startsWith("..") && !path.isAbsolute(relativeToShim)) {
|
|
1515
|
+
realOpencodeCli = "";
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
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
|
+
}
|