@narumitw/pi-langfuse 0.50.5 → 0.50.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -61
- package/dist/chunks/{runtime-247EMSG3.js → runtime-X75FRK2E.js} +2 -5
- package/dist/chunks/runtime-X75FRK2E.js.map +7 -0
- package/dist/index.ts +12 -48
- package/dist/index.ts.map +2 -2
- package/package.json +55 -59
- package/src/config.ts +211 -232
- package/src/langfuse.ts +484 -536
- package/src/runtime.ts +201 -218
- package/src/sanitizer.ts +112 -117
- package/src/tracing.ts +787 -840
- package/dist/chunks/runtime-247EMSG3.js.map +0 -7
package/src/langfuse.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionCommandContext,
|
|
4
|
-
ExtensionContext,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
2
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
DEFAULT_BASE_URL,
|
|
4
|
+
type LangfuseConfig,
|
|
5
|
+
type LangfuseConfigPatch,
|
|
6
|
+
type LangfuseConfigResult,
|
|
7
|
+
loadLangfuseConfig,
|
|
8
|
+
normalizeLangfuseConfig,
|
|
9
|
+
writeLangfuseConfig,
|
|
14
10
|
} from "./config.js";
|
|
15
|
-
import {
|
|
16
|
-
type ContextSnapshot,
|
|
17
|
-
type GitMetadata,
|
|
18
|
-
type TraceBackend,
|
|
19
|
-
TraceRecorder,
|
|
20
|
-
} from "./tracing.js";
|
|
11
|
+
import { type ContextSnapshot, type GitMetadata, type TraceBackend, TraceRecorder } from "./tracing.js";
|
|
21
12
|
|
|
22
13
|
interface ExtensionDependencies {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
loadConfig(path?: string): Promise<LangfuseConfigResult>;
|
|
15
|
+
writeConfig(config: LangfuseConfigPatch, path?: string): Promise<unknown>;
|
|
16
|
+
createBackend(config: LangfuseConfig): Promise<TraceBackend>;
|
|
17
|
+
resolveGitMetadata(cwd: string): Promise<GitMetadata | undefined>;
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
const FLUSH_ACTION = "Flush completed traces for this session";
|
|
@@ -31,377 +22,348 @@ const SET_UP_ACTION = "Set up Langfuse for this Pi agent directory (restart requ
|
|
|
31
22
|
const UPDATE_ACTION = "Update Langfuse for this Pi agent directory (restart required)";
|
|
32
23
|
const HELP_ACTION = "Show setup and privacy help";
|
|
33
24
|
|
|
34
|
-
export function createLangfuseExtension(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
recorder = undefined;
|
|
343
|
-
activeConfig = undefined;
|
|
344
|
-
await pendingConfigWrite;
|
|
345
|
-
if (!activeRecorder) return;
|
|
346
|
-
try {
|
|
347
|
-
if (event.reason === "quit") await activeRecorder.shutdown(shutdownSnapshot);
|
|
348
|
-
else {
|
|
349
|
-
activeRecorder.interrupt(
|
|
350
|
-
`Pi session ended before settlement (${event.reason}).`,
|
|
351
|
-
shutdownSnapshot,
|
|
352
|
-
);
|
|
353
|
-
await activeRecorder.flush();
|
|
354
|
-
}
|
|
355
|
-
} catch (error) {
|
|
356
|
-
if (generation !== sessionGeneration) return;
|
|
357
|
-
ctx.ui.notify(
|
|
358
|
-
`Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`,
|
|
359
|
-
"error",
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
};
|
|
25
|
+
export function createLangfuseExtension(dependencies: Partial<ExtensionDependencies> = {}): (pi: ExtensionAPI) => void {
|
|
26
|
+
const loadConfig = dependencies.loadConfig ?? loadLangfuseConfig;
|
|
27
|
+
const writeConfig = dependencies.writeConfig ?? writeLangfuseConfig;
|
|
28
|
+
const createBackend =
|
|
29
|
+
dependencies.createBackend ??
|
|
30
|
+
(async (config) => {
|
|
31
|
+
const { createProductionBackend } = await import("./runtime.js");
|
|
32
|
+
return createProductionBackend(config);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return function langfuse(pi: ExtensionAPI) {
|
|
36
|
+
const resolveGit =
|
|
37
|
+
dependencies.resolveGitMetadata ??
|
|
38
|
+
((cwd: string) => resolveGitMetadata((command, args, options) => pi.exec(command, args, options), cwd));
|
|
39
|
+
let recorder: TraceRecorder | undefined;
|
|
40
|
+
let activeConfig: LangfuseConfig | undefined;
|
|
41
|
+
let configPath: string | undefined;
|
|
42
|
+
let initializationError: string | undefined;
|
|
43
|
+
let hasStoredConfig = false;
|
|
44
|
+
let configurationNotice: string | undefined;
|
|
45
|
+
let sessionGeneration = 0;
|
|
46
|
+
let menuController = new AbortController();
|
|
47
|
+
let configWriteQueue = Promise.resolve();
|
|
48
|
+
let nextAttemptReason: string | undefined;
|
|
49
|
+
|
|
50
|
+
async function showLangfuseMenu(ctx: ExtensionCommandContext) {
|
|
51
|
+
if (!ctx.hasUI) {
|
|
52
|
+
ctx.ui.notify(formatNonInteractiveStatus(activeConfig, configPath, initializationError), "warning");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const menuGeneration = sessionGeneration;
|
|
56
|
+
const menuSignal = menuController.signal;
|
|
57
|
+
const isCurrent = () => menuGeneration === sessionGeneration && !menuSignal.aborted;
|
|
58
|
+
const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
|
|
59
|
+
if (!isCurrent()) return;
|
|
60
|
+
type Screen = "main";
|
|
61
|
+
type Action = "flush" | "configure" | "help";
|
|
62
|
+
const menu = defineMenu<undefined, Screen, Action>({
|
|
63
|
+
start: "main",
|
|
64
|
+
screens: {
|
|
65
|
+
main: () => ({
|
|
66
|
+
kind: "actions",
|
|
67
|
+
title: "Langfuse",
|
|
68
|
+
lines: formatMenuTitle(activeConfig, configPath, initializationError, configurationNotice)
|
|
69
|
+
.split("\n")
|
|
70
|
+
.slice(1),
|
|
71
|
+
items: [
|
|
72
|
+
...(recorder ? [{ id: "flush", label: FLUSH_ACTION, action: "flush" as const }] : []),
|
|
73
|
+
{
|
|
74
|
+
id: "configure",
|
|
75
|
+
label: hasStoredConfig ? UPDATE_ACTION : SET_UP_ACTION,
|
|
76
|
+
action: "configure",
|
|
77
|
+
},
|
|
78
|
+
{ id: "help", label: HELP_ACTION, action: "help" },
|
|
79
|
+
],
|
|
80
|
+
hint: "close",
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
actions: {
|
|
84
|
+
flush: async () => {
|
|
85
|
+
const menuRecorder = recorder;
|
|
86
|
+
const menuConfig = activeConfig;
|
|
87
|
+
if (!menuRecorder) {
|
|
88
|
+
ctx.ui.notify("Langfuse tracing is not enabled for this session.", "warning");
|
|
89
|
+
return { kind: "close" };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await menuRecorder.flush();
|
|
93
|
+
if (menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
94
|
+
ctx.ui.notify("Langfuse traces flushed for this session.", "info");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
97
|
+
ctx.ui.notify(`Langfuse flush failed: ${formatError(error, menuConfig)}`, "error");
|
|
98
|
+
}
|
|
99
|
+
return { kind: "close" };
|
|
100
|
+
},
|
|
101
|
+
configure: async () => {
|
|
102
|
+
const loaded = await loadConfig(configPath);
|
|
103
|
+
if (menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
104
|
+
configPath = loaded.path;
|
|
105
|
+
const next = await promptForConfig(
|
|
106
|
+
ctx,
|
|
107
|
+
loaded.ok ? loaded.config : undefined,
|
|
108
|
+
() => menuGeneration === sessionGeneration,
|
|
109
|
+
);
|
|
110
|
+
if (!next || menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
111
|
+
try {
|
|
112
|
+
const write = configWriteQueue.then(() => writeConfig(next, loaded.path));
|
|
113
|
+
configWriteQueue = write.then(
|
|
114
|
+
() => undefined,
|
|
115
|
+
() => undefined,
|
|
116
|
+
);
|
|
117
|
+
await write;
|
|
118
|
+
if (menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
119
|
+
hasStoredConfig = true;
|
|
120
|
+
configurationNotice = "Saved; restart each Pi process to use it in subsequent sessions.";
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`Saved Langfuse config to ${loaded.path} for this Pi agent directory. Restart each Pi process to apply it to subsequent sessions.`,
|
|
123
|
+
"info",
|
|
124
|
+
);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (menuGeneration !== sessionGeneration) return { kind: "close" };
|
|
127
|
+
ctx.ui.notify(`Failed to save Langfuse config: ${formatError(error, next)}`, "error");
|
|
128
|
+
}
|
|
129
|
+
return { kind: "close" };
|
|
130
|
+
},
|
|
131
|
+
help: async () => {
|
|
132
|
+
ctx.ui.notify(formatHelp(configPath), "info");
|
|
133
|
+
return { kind: "close" };
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await runMenu(ctx, menu, {
|
|
138
|
+
getState: () => undefined,
|
|
139
|
+
signal: menuSignal,
|
|
140
|
+
isCurrent,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pi.registerCommand("langfuse", {
|
|
145
|
+
description: "Open interactive Langfuse tracing controls",
|
|
146
|
+
handler: async (_args, ctx) => showLangfuseMenu(ctx),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
150
|
+
const generation = ++sessionGeneration;
|
|
151
|
+
menuController.abort(new DOMException("Langfuse session replaced", "AbortError"));
|
|
152
|
+
menuController = new AbortController();
|
|
153
|
+
recorder = undefined;
|
|
154
|
+
activeConfig = undefined;
|
|
155
|
+
configPath = undefined;
|
|
156
|
+
initializationError = undefined;
|
|
157
|
+
hasStoredConfig = false;
|
|
158
|
+
configurationNotice = undefined;
|
|
159
|
+
nextAttemptReason = undefined;
|
|
160
|
+
|
|
161
|
+
await configWriteQueue;
|
|
162
|
+
if (generation !== sessionGeneration) return;
|
|
163
|
+
const result = await loadConfig();
|
|
164
|
+
if (generation !== sessionGeneration) return;
|
|
165
|
+
configPath = result.path;
|
|
166
|
+
for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
|
|
167
|
+
if (!result.ok) {
|
|
168
|
+
initializationError = formatConfigError(result);
|
|
169
|
+
ctx.ui.notify(initializationError, "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
hasStoredConfig = true;
|
|
174
|
+
try {
|
|
175
|
+
const backend = await createBackend(result.config);
|
|
176
|
+
if (generation !== sessionGeneration) {
|
|
177
|
+
await backend.shutdown().catch(() => undefined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
activeConfig = result.config;
|
|
181
|
+
recorder = new TraceRecorder(backend, {
|
|
182
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
183
|
+
...(result.config.userId ? { userId: result.config.userId } : {}),
|
|
184
|
+
cwd: ctx.cwd,
|
|
185
|
+
mode: ctx.mode,
|
|
186
|
+
captureContent: result.config.captureContent,
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (generation !== sessionGeneration) return;
|
|
190
|
+
initializationError = `Langfuse tracing could not start: ${formatError(error, result.config)}`;
|
|
191
|
+
ctx.ui.notify(initializationError, "warning");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
196
|
+
nextAttemptReason = undefined;
|
|
197
|
+
const activeRecorder = recorder;
|
|
198
|
+
if (!activeRecorder) return;
|
|
199
|
+
const git = await resolveGit(ctx.cwd).catch(() => undefined);
|
|
200
|
+
if (recorder !== activeRecorder) return;
|
|
201
|
+
activeRecorder.beginAgent({
|
|
202
|
+
prompt: event.prompt,
|
|
203
|
+
images: event.images,
|
|
204
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
|
|
205
|
+
git,
|
|
206
|
+
snapshot: contextSnapshot(ctx),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on("agent_start", () => {
|
|
211
|
+
if (!recorder) return;
|
|
212
|
+
recorder.beginAttempt(nextAttemptReason ? { reason: nextAttemptReason } : undefined);
|
|
213
|
+
nextAttemptReason = undefined;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("turn_start", (event, ctx) => {
|
|
217
|
+
if (!recorder) return;
|
|
218
|
+
ensureActiveRun(recorder, ctx);
|
|
219
|
+
recorder.beginTurn(event.turnIndex);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
223
|
+
if (!recorder) return;
|
|
224
|
+
ensureActiveRun(recorder, ctx);
|
|
225
|
+
recorder.beginGeneration({
|
|
226
|
+
payload: event.payload,
|
|
227
|
+
payloadStage: "before_provider_request",
|
|
228
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
|
|
229
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
pi.on("after_provider_response", (event) => {
|
|
234
|
+
recorder?.recordProviderResponse(event.status, event.headers);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("message_update", (event) => {
|
|
238
|
+
if (isRealOutputDelta(event.assistantMessageEvent)) recorder?.markGenerationFirstOutput();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
pi.on("message_end", (event) => {
|
|
242
|
+
if (event.message.role === "assistant") recorder?.markGenerationEnd();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
pi.on("turn_end", (event) => {
|
|
246
|
+
if (event.message.role === "assistant") recorder?.finishAssistant(event.message);
|
|
247
|
+
recorder?.finishTurn(event.turnIndex, {
|
|
248
|
+
message: event.message,
|
|
249
|
+
toolResultCount: event.toolResults.length,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("tool_execution_start", (event) => {
|
|
254
|
+
recorder?.beginTool(event.toolCallId, event.toolName, event.args);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
pi.on("tool_execution_update", (event) => {
|
|
258
|
+
recorder?.recordToolProgress(event.toolCallId);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
pi.on("tool_result", (event) => {
|
|
262
|
+
recorder?.recordToolInput(event.toolCallId, event.input);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.on("tool_execution_end", (event) => {
|
|
266
|
+
recorder?.finishTool(event.toolCallId, {
|
|
267
|
+
content: event.result.content,
|
|
268
|
+
details: event.result.details,
|
|
269
|
+
isError: event.isError,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pi.on("agent_end", (event) => {
|
|
274
|
+
const message = findLastAssistant(event.messages);
|
|
275
|
+
recorder?.finishAttempt(message);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
pi.on("session_before_compact", (event) => {
|
|
279
|
+
recorder?.beginCompaction({
|
|
280
|
+
reason: event.reason,
|
|
281
|
+
willRetry: event.willRetry,
|
|
282
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
283
|
+
messagesToSummarize: event.preparation.messagesToSummarize.length,
|
|
284
|
+
turnPrefixMessages: event.preparation.turnPrefixMessages.length,
|
|
285
|
+
branchEntries: event.branchEntries.length,
|
|
286
|
+
isSplitTurn: event.preparation.isSplitTurn,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
pi.on("session_compact", (event) => {
|
|
291
|
+
const entry = event.compactionEntry as typeof event.compactionEntry & {
|
|
292
|
+
usage?: Parameters<TraceRecorder["finishCompaction"]>[0]["usage"];
|
|
293
|
+
};
|
|
294
|
+
recorder?.finishCompaction({
|
|
295
|
+
reason: event.reason,
|
|
296
|
+
willRetry: event.willRetry,
|
|
297
|
+
fromExtension: event.fromExtension,
|
|
298
|
+
tokensBefore: entry.tokensBefore,
|
|
299
|
+
details: entry.details,
|
|
300
|
+
usage: entry.usage,
|
|
301
|
+
});
|
|
302
|
+
if (event.willRetry && recorder?.hasActiveTrace()) nextAttemptReason = "post_compaction";
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
306
|
+
recorder?.settle(contextSnapshot(ctx));
|
|
307
|
+
nextAttemptReason = undefined;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
311
|
+
const generation = ++sessionGeneration;
|
|
312
|
+
menuController.abort(new DOMException("Langfuse session shut down", "AbortError"));
|
|
313
|
+
const activeRecorder = recorder;
|
|
314
|
+
const shutdownConfig = activeConfig;
|
|
315
|
+
const pendingConfigWrite = configWriteQueue;
|
|
316
|
+
const shutdownSnapshot = activeRecorder ? contextSnapshot(ctx) : undefined;
|
|
317
|
+
recorder = undefined;
|
|
318
|
+
activeConfig = undefined;
|
|
319
|
+
await pendingConfigWrite;
|
|
320
|
+
if (!activeRecorder) return;
|
|
321
|
+
try {
|
|
322
|
+
if (event.reason === "quit") await activeRecorder.shutdown(shutdownSnapshot);
|
|
323
|
+
else {
|
|
324
|
+
activeRecorder.interrupt(`Pi session ended before settlement (${event.reason}).`, shutdownSnapshot);
|
|
325
|
+
await activeRecorder.flush();
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (generation !== sessionGeneration) return;
|
|
329
|
+
ctx.ui.notify(`Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`, "error");
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
};
|
|
364
333
|
}
|
|
365
334
|
|
|
366
335
|
function ensureActiveRun(recorder: TraceRecorder, ctx: ExtensionContext): void {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
if (!recorder.hasActiveAttempt()) recorder.beginAttempt();
|
|
336
|
+
if (!recorder.hasActiveTrace()) {
|
|
337
|
+
recorder.beginAgent({
|
|
338
|
+
prompt: "[automatic continuation]",
|
|
339
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
|
|
340
|
+
snapshot: contextSnapshot(ctx),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (!recorder.hasActiveAttempt()) recorder.beginAttempt();
|
|
377
344
|
}
|
|
378
345
|
|
|
379
346
|
function contextSnapshot(ctx: ExtensionContext): ContextSnapshot {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
: undefined,
|
|
385
|
-
contextUsage: ctx.getContextUsage(),
|
|
386
|
-
};
|
|
347
|
+
return {
|
|
348
|
+
leafId: typeof ctx.sessionManager.getLeafId === "function" ? ctx.sessionManager.getLeafId() : undefined,
|
|
349
|
+
contextUsage: ctx.getContextUsage(),
|
|
350
|
+
};
|
|
387
351
|
}
|
|
388
352
|
|
|
389
353
|
function findLastAssistant<T extends { role?: string }>(messages: readonly T[]): T | undefined {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
354
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
355
|
+
const message = messages[index];
|
|
356
|
+
if (message?.role === "assistant") return message;
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
395
359
|
}
|
|
396
360
|
|
|
397
361
|
function isRealOutputDelta(event: { type: string; delta?: unknown }): boolean {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
event.delta.length > 0
|
|
404
|
-
);
|
|
362
|
+
return (
|
|
363
|
+
(event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") &&
|
|
364
|
+
typeof event.delta === "string" &&
|
|
365
|
+
event.delta.length > 0
|
|
366
|
+
);
|
|
405
367
|
}
|
|
406
368
|
|
|
407
369
|
const GIT_LOOKUP_TIMEOUT_MS = 1_000;
|
|
@@ -409,190 +371,176 @@ const MAX_GIT_BRANCH_LENGTH = 256;
|
|
|
409
371
|
|
|
410
372
|
type GitExecutor = ExtensionAPI["exec"];
|
|
411
373
|
|
|
412
|
-
export async function resolveGitMetadata(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
branch: branchResult.branch,
|
|
424
|
-
...(commit ? { commit } : {}),
|
|
425
|
-
detached: false,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
return commit ? { commit, detached: true } : undefined;
|
|
374
|
+
export async function resolveGitMetadata(exec: GitExecutor, cwd: string): Promise<GitMetadata | undefined> {
|
|
375
|
+
const [branchResult, commit] = await Promise.all([resolveGitBranch(exec, cwd), resolveGitCommit(exec, cwd)]);
|
|
376
|
+
if (!branchResult.resolved) return undefined;
|
|
377
|
+
if (branchResult.branch) {
|
|
378
|
+
return {
|
|
379
|
+
branch: branchResult.branch,
|
|
380
|
+
...(commit ? { commit } : {}),
|
|
381
|
+
detached: false,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return commit ? { commit, detached: true } : undefined;
|
|
429
385
|
}
|
|
430
386
|
|
|
431
|
-
async function resolveGitBranch(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
} catch {
|
|
446
|
-
return { resolved: false };
|
|
447
|
-
}
|
|
387
|
+
async function resolveGitBranch(exec: GitExecutor, cwd: string): Promise<{ resolved: boolean; branch?: string }> {
|
|
388
|
+
try {
|
|
389
|
+
const result = await exec("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
390
|
+
cwd,
|
|
391
|
+
timeout: GIT_LOOKUP_TIMEOUT_MS,
|
|
392
|
+
});
|
|
393
|
+
if (result.killed) return { resolved: false };
|
|
394
|
+
if (result.code === 1) return { resolved: true };
|
|
395
|
+
if (result.code !== 0) return { resolved: false };
|
|
396
|
+
const branch = normalizeGitBranch(result.stdout);
|
|
397
|
+
return branch ? { resolved: true, branch } : { resolved: false };
|
|
398
|
+
} catch {
|
|
399
|
+
return { resolved: false };
|
|
400
|
+
}
|
|
448
401
|
}
|
|
449
402
|
|
|
450
403
|
async function resolveGitCommit(exec: GitExecutor, cwd: string): Promise<string | undefined> {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
404
|
+
try {
|
|
405
|
+
const result = await exec("git", ["rev-parse", "--verify", "--short=12", "HEAD"], {
|
|
406
|
+
cwd,
|
|
407
|
+
timeout: GIT_LOOKUP_TIMEOUT_MS,
|
|
408
|
+
});
|
|
409
|
+
if (result.code !== 0 || result.killed) return undefined;
|
|
410
|
+
const commit = result.stdout.trim();
|
|
411
|
+
return /^[0-9a-f]{4,64}$/iu.test(commit) ? commit.toLowerCase() : undefined;
|
|
412
|
+
} catch {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
462
415
|
}
|
|
463
416
|
|
|
464
417
|
function normalizeGitBranch(value: string): string | undefined {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
418
|
+
const branch = value.trim();
|
|
419
|
+
if (!branch || branch.length > MAX_GIT_BRANCH_LENGTH) return undefined;
|
|
420
|
+
for (const character of branch) {
|
|
421
|
+
const code = character.codePointAt(0) ?? 0;
|
|
422
|
+
if (code <= 31 || code === 127) return undefined;
|
|
423
|
+
}
|
|
424
|
+
return branch;
|
|
472
425
|
}
|
|
473
426
|
|
|
474
427
|
async function promptForConfig(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
428
|
+
ctx: ExtensionCommandContext,
|
|
429
|
+
current: LangfuseConfig | undefined,
|
|
430
|
+
isCurrent: () => boolean,
|
|
478
431
|
): Promise<LangfuseConfigPatch | undefined> {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
432
|
+
const secretKey = await ctx.ui.input("Langfuse secret key (leave blank to keep existing):");
|
|
433
|
+
if (!isCurrent()) return undefined;
|
|
434
|
+
if (secretKey === undefined) {
|
|
435
|
+
ctx.ui.notify("Cancelled", "info");
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
const publicKey = await ctx.ui.input("Langfuse public key (leave blank to keep existing):");
|
|
439
|
+
if (!isCurrent()) return undefined;
|
|
440
|
+
if (publicKey === undefined) {
|
|
441
|
+
ctx.ui.notify("Cancelled", "info");
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
const baseUrl = await ctx.ui.input(
|
|
445
|
+
`Langfuse base URL (leave blank for default ${DEFAULT_BASE_URL}):`,
|
|
446
|
+
DEFAULT_BASE_URL,
|
|
447
|
+
);
|
|
448
|
+
if (!isCurrent()) return undefined;
|
|
449
|
+
if (baseUrl === undefined) {
|
|
450
|
+
ctx.ui.notify("Cancelled", "info");
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const patch: LangfuseConfigPatch = {
|
|
455
|
+
...(publicKey.trim() ? { publicKey: publicKey.trim() } : {}),
|
|
456
|
+
...(secretKey.trim() ? { secretKey: secretKey.trim() } : {}),
|
|
457
|
+
baseUrl: baseUrl.trim() || DEFAULT_BASE_URL,
|
|
458
|
+
};
|
|
459
|
+
const normalized = normalizeLangfuseConfig({ ...current, ...patch });
|
|
460
|
+
if (!normalized.ok) {
|
|
461
|
+
ctx.ui.notify(`Invalid Langfuse config: ${normalized.reason}`, "error");
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
return patch;
|
|
512
465
|
}
|
|
513
466
|
|
|
514
467
|
function formatMenuTitle(
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
468
|
+
activeConfig: LangfuseConfig | undefined,
|
|
469
|
+
configPath: string | undefined,
|
|
470
|
+
initializationError: string | undefined,
|
|
471
|
+
configurationNotice: string | undefined,
|
|
519
472
|
): string {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
" Scope: this Pi agent directory; restart each Pi process to apply changes.",
|
|
541
|
-
);
|
|
542
|
-
if (configurationNotice) lines.push(` Pending: ${configurationNotice}`);
|
|
543
|
-
lines.push("", "What do you want to do?");
|
|
544
|
-
return lines.join("\n");
|
|
473
|
+
const lines = ["Langfuse", "", "Current session:", ` Tracing: ${activeConfig ? "enabled" : "disabled"}`];
|
|
474
|
+
if (activeConfig) {
|
|
475
|
+
lines.push(
|
|
476
|
+
` Endpoint: ${activeConfig.baseUrl}`,
|
|
477
|
+
` Content capture: ${activeConfig.captureContent ? "enabled" : "disabled"}`,
|
|
478
|
+
);
|
|
479
|
+
} else if (configurationNotice) {
|
|
480
|
+
lines.push(" State: tracing remains disabled until Pi restarts.");
|
|
481
|
+
} else if (initializationError) {
|
|
482
|
+
lines.push(` Reason: ${initializationError}`);
|
|
483
|
+
}
|
|
484
|
+
lines.push(
|
|
485
|
+
"",
|
|
486
|
+
"Agent-directory configuration:",
|
|
487
|
+
` Configuration: ${configPath ?? "unknown"}`,
|
|
488
|
+
" Scope: this Pi agent directory; restart each Pi process to apply changes.",
|
|
489
|
+
);
|
|
490
|
+
if (configurationNotice) lines.push(` Pending: ${configurationNotice}`);
|
|
491
|
+
lines.push("", "What do you want to do?");
|
|
492
|
+
return lines.join("\n");
|
|
545
493
|
}
|
|
546
494
|
|
|
547
495
|
function formatNonInteractiveStatus(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
496
|
+
activeConfig: LangfuseConfig | undefined,
|
|
497
|
+
configPath: string | undefined,
|
|
498
|
+
initializationError: string | undefined,
|
|
551
499
|
): string {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
500
|
+
return [
|
|
501
|
+
"/langfuse requires interactive UI.",
|
|
502
|
+
`Current session tracing: ${activeConfig ? "enabled" : "disabled"}`,
|
|
503
|
+
...(activeConfig
|
|
504
|
+
? [
|
|
505
|
+
`Endpoint: ${activeConfig.baseUrl}`,
|
|
506
|
+
`Content capture: ${activeConfig.captureContent ? "enabled" : "disabled"}`,
|
|
507
|
+
]
|
|
508
|
+
: initializationError
|
|
509
|
+
? [`Reason: ${initializationError}`]
|
|
510
|
+
: []),
|
|
511
|
+
`Configuration: ${configPath ?? "unknown"}`,
|
|
512
|
+
"Edit the private config manually, then restart each Pi process to apply it to subsequent sessions.",
|
|
513
|
+
].join("\n");
|
|
566
514
|
}
|
|
567
515
|
|
|
568
516
|
function formatHelp(configPath: string | undefined): string {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
517
|
+
return [
|
|
518
|
+
"Langfuse setup and privacy:",
|
|
519
|
+
"Run /langfuse in interactive Pi to manage tracing for the current session.",
|
|
520
|
+
`Configuration: ${configPath ?? "pi-langfuse.json"}`,
|
|
521
|
+
"The file belongs to this Pi agent directory; restart each Pi process after changing it.",
|
|
522
|
+
"Trace content may contain prompts, responses, tool arguments, tool results, and source code.",
|
|
523
|
+
"Git branch, commit, cwd, model, and usage remain in metadata when content capture is disabled.",
|
|
524
|
+
'Use "captureContent": false in the private config to export metadata only.',
|
|
525
|
+
].join("\n");
|
|
578
526
|
}
|
|
579
527
|
|
|
580
528
|
function formatConfigError(result: Extract<LangfuseConfigResult, { ok: false }>): string {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
529
|
+
const setupHint = result.reason.startsWith("Configuration file not found:")
|
|
530
|
+
? " Run /langfuse and choose Set up Langfuse."
|
|
531
|
+
: "";
|
|
532
|
+
return `Langfuse tracing is disabled: ${result.reason}${setupHint}`;
|
|
585
533
|
}
|
|
586
534
|
|
|
587
535
|
function formatError(error: unknown, config?: LangfuseConfigPatch): string {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
536
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
537
|
+
const secrets = [config?.publicKey, config?.secretKey]
|
|
538
|
+
.filter((secret): secret is string => Boolean(secret))
|
|
539
|
+
.sort((left, right) => right.length - left.length);
|
|
540
|
+
for (const secret of secrets) {
|
|
541
|
+
message = message.split(secret).join("[LANGFUSE_KEY_REDACTED]");
|
|
542
|
+
}
|
|
543
|
+
return message;
|
|
596
544
|
}
|
|
597
545
|
|
|
598
546
|
export default createLangfuseExtension();
|