pi-langfuse 1.0.0
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/.agents/skills/langfuse/SKILL.md +140 -0
- package/.agents/skills/langfuse/references/cli.md +51 -0
- package/.agents/skills/langfuse/references/error-analysis.md +100 -0
- package/.agents/skills/langfuse/references/instrumentation.md +140 -0
- package/.agents/skills/langfuse/references/prompt-migration.md +234 -0
- package/.agents/skills/langfuse/references/sdk-upgrade.md +181 -0
- package/.agents/skills/langfuse/references/skill-feedback.md +52 -0
- package/.agents/skills/langfuse/references/user-feedback.md +88 -0
- package/AGENTS.md +57 -0
- package/AGENTS_CN.md +57 -0
- package/README.md +121 -0
- package/README_CN.md +120 -0
- package/index.ts +552 -0
- package/package.json +38 -0
- package/skills-lock.json +11 -0
- package/tsconfig.json +15 -0
- package/types/node-shims.d.ts +20 -0
- package/types/pi-coding-agent.d.ts +9 -0
package/index.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Langfuse Observability Extension for Pi Coding Agent
|
|
3
|
+
*
|
|
4
|
+
* Sends traces to Langfuse for monitoring tokens, costs, latency, and tool calls.
|
|
5
|
+
* Uses dynamic import to load the langfuse SDK properly.
|
|
6
|
+
*
|
|
7
|
+
* Scores tracked:
|
|
8
|
+
* - tool_call_count: Total number of tool calls
|
|
9
|
+
* - turn_count: Number of turns in the session
|
|
10
|
+
* - total_tool_errors: Number of tools that returned errors
|
|
11
|
+
* - tool_success_rate: Success rate of tool calls (0-1)
|
|
12
|
+
* - session_had_errors: Boolean indicating if any tool error occurred
|
|
13
|
+
* - tool_is_error: Per-tool score indicating if that specific tool call errored
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve, dirname, basename } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// Configuration
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
interface Config {
|
|
26
|
+
publicKey: string;
|
|
27
|
+
secretKey: string;
|
|
28
|
+
host: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EXT_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
32
|
+
const CONFIG_PATH = resolve(EXT_DIR, "config.json");
|
|
33
|
+
const DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com";
|
|
34
|
+
|
|
35
|
+
function loadConfigFromFile(): Config | null {
|
|
36
|
+
if (existsSync(CONFIG_PATH)) {
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(CONFIG_PATH, "utf-8");
|
|
39
|
+
const config = JSON.parse(content) as Config;
|
|
40
|
+
if (config.publicKey && config.secretKey) {
|
|
41
|
+
return {
|
|
42
|
+
publicKey: config.publicKey,
|
|
43
|
+
secretKey: config.secretKey,
|
|
44
|
+
host: config.host || DEFAULT_LANGFUSE_HOST,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.warn("📊 Langfuse: Failed to load config.json", e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadConfigFromEnv(): Config | null {
|
|
56
|
+
const publicKey = process.env.LANGFUSE_PUBLIC_KEY || "";
|
|
57
|
+
const secretKey = process.env.LANGFUSE_SECRET_KEY || "";
|
|
58
|
+
if (!publicKey || !secretKey) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
publicKey,
|
|
64
|
+
secretKey,
|
|
65
|
+
host: process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveConfig(config: Config) {
|
|
70
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================
|
|
74
|
+
// Langfuse Client (lazy-loaded via dynamic import)
|
|
75
|
+
// ============================================
|
|
76
|
+
|
|
77
|
+
interface LangfuseSpan {
|
|
78
|
+
id: string;
|
|
79
|
+
end(body?: { metadata?: Record<string, unknown>; isError?: boolean; output?: unknown }): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface LangfuseGeneration {
|
|
83
|
+
id: string;
|
|
84
|
+
end(body?: {
|
|
85
|
+
metadata?: Record<string, unknown>;
|
|
86
|
+
usage?: unknown;
|
|
87
|
+
output?: unknown;
|
|
88
|
+
costDetails?: unknown;
|
|
89
|
+
}): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface LangfuseClient {
|
|
93
|
+
trace(body?: { name: string; metadata?: Record<string, unknown>; input?: unknown; output?: unknown; sessionId?: string }): {
|
|
94
|
+
id: string;
|
|
95
|
+
update(body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }): void;
|
|
96
|
+
};
|
|
97
|
+
span(body: { name: string; traceId: string; metadata?: Record<string, unknown>; input?: unknown }): LangfuseSpan;
|
|
98
|
+
generation(body: {
|
|
99
|
+
name: string;
|
|
100
|
+
traceId: string;
|
|
101
|
+
metadata?: Record<string, unknown>;
|
|
102
|
+
input?: unknown;
|
|
103
|
+
output?: unknown;
|
|
104
|
+
usage?: unknown;
|
|
105
|
+
model?: string;
|
|
106
|
+
costDetails?: unknown;
|
|
107
|
+
}): LangfuseGeneration;
|
|
108
|
+
score(body: { name: string; value: number; traceId?: string; observationId?: string }): void;
|
|
109
|
+
shutdownAsync(): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let client: LangfuseClient | null = null;
|
|
113
|
+
let config: Config | null = loadConfigFromFile() ?? loadConfigFromEnv();
|
|
114
|
+
let setupAttemptedThisSession = false;
|
|
115
|
+
|
|
116
|
+
async function getClient(): Promise<LangfuseClient> {
|
|
117
|
+
if (!config) {
|
|
118
|
+
throw new Error("Langfuse config is not set");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!client) {
|
|
122
|
+
const lib = await import(`${EXT_DIR}/node_modules/langfuse/lib/index.mjs`) as {
|
|
123
|
+
Langfuse: new (options: { publicKey: string; secretKey?: string; baseUrl?: string }) => LangfuseClient;
|
|
124
|
+
};
|
|
125
|
+
client = new lib.Langfuse({
|
|
126
|
+
publicKey: config.publicKey,
|
|
127
|
+
secretKey: config.secretKey,
|
|
128
|
+
baseUrl: config.host,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================
|
|
135
|
+
// State
|
|
136
|
+
// ============================================
|
|
137
|
+
|
|
138
|
+
interface TraceData {
|
|
139
|
+
id: string;
|
|
140
|
+
update?: (body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }) => void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SpanData {
|
|
144
|
+
span: LangfuseSpan;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let currentTrace: TraceData | null = null;
|
|
148
|
+
let currentUserPrompt: string = "";
|
|
149
|
+
let currentSessionId: string = "";
|
|
150
|
+
let currentModel: string = "";
|
|
151
|
+
let currentProvider: string = "";
|
|
152
|
+
const activeSpans: Map<string, SpanData> = new Map();
|
|
153
|
+
|
|
154
|
+
// Evaluation tracking state
|
|
155
|
+
let toolCallCount: number = 0;
|
|
156
|
+
let errorCount: number = 0;
|
|
157
|
+
let turnCount: number = 0;
|
|
158
|
+
|
|
159
|
+
function truncate(value: string, maxLength: number): string {
|
|
160
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function safeSerialize(value: unknown, maxLength: number): string {
|
|
164
|
+
try {
|
|
165
|
+
return truncate(JSON.stringify(value, null, 2), maxLength);
|
|
166
|
+
} catch {
|
|
167
|
+
return `[unserializable ${typeof value}]`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractTextContent(
|
|
172
|
+
content: Array<{ type: string; text?: string; thinking?: string }> | undefined,
|
|
173
|
+
maxLength?: number,
|
|
174
|
+
): string | undefined {
|
|
175
|
+
if (!content?.length) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const text = content
|
|
180
|
+
.filter((item) => item.type === "text" && item.text)
|
|
181
|
+
.map((item) => item.text)
|
|
182
|
+
.join("\n");
|
|
183
|
+
|
|
184
|
+
if (!text) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return maxLength ? truncate(text, maxLength) : text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resetSessionState() {
|
|
192
|
+
toolCallCount = 0;
|
|
193
|
+
errorCount = 0;
|
|
194
|
+
turnCount = 0;
|
|
195
|
+
activeSpans.clear();
|
|
196
|
+
currentTrace = null;
|
|
197
|
+
currentUserPrompt = "";
|
|
198
|
+
currentModel = "";
|
|
199
|
+
currentProvider = "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function ensureConfig(ctx: any): Promise<boolean> {
|
|
203
|
+
if (config) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (setupAttemptedThisSession) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
setupAttemptedThisSession = true;
|
|
211
|
+
|
|
212
|
+
if (!ctx.hasUI) {
|
|
213
|
+
console.log("📊 Langfuse: Missing config. Run this extension in Pi UI to complete setup, or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST.");
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ctx.ui.notify("Langfuse setup required. Enter your API keys to enable tracing.", "info");
|
|
218
|
+
|
|
219
|
+
const publicKey = (await ctx.ui.input("Langfuse public key:", "pk-lf-..."))?.trim();
|
|
220
|
+
if (!publicKey) {
|
|
221
|
+
ctx.ui.notify("Langfuse setup cancelled.", "warning");
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const secretKey = (await ctx.ui.input("Langfuse secret key:", "sk-lf-..."))?.trim();
|
|
226
|
+
if (!secretKey) {
|
|
227
|
+
ctx.ui.notify("Langfuse setup cancelled.", "warning");
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const hostInput = (await ctx.ui.input("Langfuse host:", DEFAULT_LANGFUSE_HOST))?.trim();
|
|
232
|
+
config = {
|
|
233
|
+
publicKey,
|
|
234
|
+
secretKey,
|
|
235
|
+
host: hostInput || DEFAULT_LANGFUSE_HOST,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
saveConfig(config);
|
|
240
|
+
ctx.ui.notify(`Langfuse config saved to ${CONFIG_PATH}`, "info");
|
|
241
|
+
return true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.warn("📊 Langfuse: Failed to save config.json", error);
|
|
244
|
+
ctx.ui.notify("Failed to save Langfuse config.json. Check extension directory permissions.", "error");
|
|
245
|
+
config = null;
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function promptForConfig(ctx: any): Promise<boolean> {
|
|
251
|
+
setupAttemptedThisSession = false;
|
|
252
|
+
config = null;
|
|
253
|
+
client = null;
|
|
254
|
+
return ensureConfig(ctx);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function computeEvaluationScores() {
|
|
258
|
+
const toolSuccessRate = toolCallCount > 0
|
|
259
|
+
? (toolCallCount - errorCount) / toolCallCount
|
|
260
|
+
: 1;
|
|
261
|
+
const sessionHadErrors = errorCount > 0;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
tool_call_count: toolCallCount,
|
|
265
|
+
turn_count: turnCount,
|
|
266
|
+
total_tool_errors: errorCount,
|
|
267
|
+
tool_success_rate: toolSuccessRate,
|
|
268
|
+
session_had_errors: sessionHadErrors ? 1 : 0, // 1 for true, 0 for false
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================
|
|
273
|
+
// Extension
|
|
274
|
+
// ============================================
|
|
275
|
+
|
|
276
|
+
export default async function (pi: ExtensionAPI) {
|
|
277
|
+
if (config) {
|
|
278
|
+
console.log("📊 Langfuse: Tracing enabled →", config.host);
|
|
279
|
+
} else {
|
|
280
|
+
console.log("📊 Langfuse: Waiting for first-run setup");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
pi.registerCommand("langfuse-setup", {
|
|
284
|
+
description: "Configure Langfuse API keys for this extension",
|
|
285
|
+
handler: async (_args, ctx) => {
|
|
286
|
+
await promptForConfig(ctx);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Capture session ID on session start
|
|
291
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
292
|
+
setupAttemptedThisSession = false;
|
|
293
|
+
await ensureConfig(ctx);
|
|
294
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
295
|
+
if (sessionFile) {
|
|
296
|
+
// Extract session ID from file path (format: 2026-04-25T13-23-54-756Z_<uuid>.jsonl)
|
|
297
|
+
currentSessionId = basename(sessionFile, ".jsonl");
|
|
298
|
+
}
|
|
299
|
+
// Reset state for new session
|
|
300
|
+
resetSessionState();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Capture model info on model select
|
|
304
|
+
pi.on("model_select", async (event, ctx) => {
|
|
305
|
+
currentModel = event.model?.id || '';
|
|
306
|
+
currentProvider = event.model?.provider || '';
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Use before_agent_start to capture user prompt and create trace
|
|
310
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
311
|
+
if (!(await ensureConfig(ctx))) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const lf = await getClient();
|
|
317
|
+
const cwd = event.systemPromptOptions?.cwd || process.cwd();
|
|
318
|
+
currentUserPrompt = event.prompt;
|
|
319
|
+
|
|
320
|
+
// Fallback to ctx.model if not captured via model_select
|
|
321
|
+
if (!currentModel && ctx.model) {
|
|
322
|
+
currentModel = ctx.model.id || '';
|
|
323
|
+
currentProvider = ctx.model.provider || '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Create trace with user input and session ID
|
|
327
|
+
currentTrace = lf.trace({
|
|
328
|
+
name: "pi-agent",
|
|
329
|
+
input: event.prompt,
|
|
330
|
+
metadata: {
|
|
331
|
+
cwd,
|
|
332
|
+
model: currentModel,
|
|
333
|
+
provider: currentProvider
|
|
334
|
+
},
|
|
335
|
+
sessionId: currentSessionId || undefined
|
|
336
|
+
});
|
|
337
|
+
} catch (e) {
|
|
338
|
+
console.warn("📊 Langfuse: Failed to create trace", e);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
pi.on("agent_end", async (event) => {
|
|
343
|
+
if (currentTrace) {
|
|
344
|
+
// Get final response/output
|
|
345
|
+
const eventData = event as unknown as {
|
|
346
|
+
messages?: Array<{
|
|
347
|
+
role: string;
|
|
348
|
+
content: Array<{ type: string; text?: string; thinking?: string }>;
|
|
349
|
+
}>;
|
|
350
|
+
};
|
|
351
|
+
const messages = eventData.messages || [];
|
|
352
|
+
const lastAssistant = messages.filter(m => m.role === "assistant").pop();
|
|
353
|
+
|
|
354
|
+
// Extract text from content array (filter out thinking blocks)
|
|
355
|
+
const output = extractTextContent(lastAssistant?.content);
|
|
356
|
+
|
|
357
|
+
// Compute evaluation scores
|
|
358
|
+
const scores = computeEvaluationScores();
|
|
359
|
+
|
|
360
|
+
currentTrace.update?.({
|
|
361
|
+
output: output || undefined,
|
|
362
|
+
metadata: {
|
|
363
|
+
completed: true,
|
|
364
|
+
totalTools: toolCallCount,
|
|
365
|
+
model: currentModel,
|
|
366
|
+
provider: currentProvider,
|
|
367
|
+
...scores
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Send evaluation scores to Langfuse
|
|
372
|
+
try {
|
|
373
|
+
const lf = await getClient();
|
|
374
|
+
|
|
375
|
+
// Trace-level evaluation scores
|
|
376
|
+
lf.score({ name: "tool_call_count", value: scores.tool_call_count, traceId: currentTrace.id });
|
|
377
|
+
lf.score({ name: "turn_count", value: scores.turn_count, traceId: currentTrace.id });
|
|
378
|
+
lf.score({ name: "total_tool_errors", value: scores.total_tool_errors, traceId: currentTrace.id });
|
|
379
|
+
lf.score({ name: "tool_success_rate", value: scores.tool_success_rate, traceId: currentTrace.id });
|
|
380
|
+
lf.score({ name: "session_had_errors", value: scores.session_had_errors, traceId: currentTrace.id });
|
|
381
|
+
|
|
382
|
+
console.log("📊 Langfuse: Evaluation scores sent:", scores);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.warn("📊 Langfuse: Failed to send evaluation scores", e);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
currentTrace = null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Reset for next session
|
|
391
|
+
resetSessionState();
|
|
392
|
+
|
|
393
|
+
if (client) {
|
|
394
|
+
await client.shutdownAsync();
|
|
395
|
+
client = null;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Track tool calls and create spans
|
|
400
|
+
pi.on("tool_call", async (event) => {
|
|
401
|
+
if (!currentTrace) return;
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const lf = await getClient();
|
|
405
|
+
|
|
406
|
+
// Increment tool call counter
|
|
407
|
+
toolCallCount++;
|
|
408
|
+
|
|
409
|
+
// Format input nicely
|
|
410
|
+
const inputStr = event.input ? safeSerialize(event.input, 1000) : "";
|
|
411
|
+
|
|
412
|
+
const span = lf.span({
|
|
413
|
+
name: `tool:${event.toolName}`,
|
|
414
|
+
traceId: currentTrace.id,
|
|
415
|
+
input: inputStr,
|
|
416
|
+
metadata: { tool: event.toolName }
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
activeSpans.set(event.toolCallId, { span });
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.warn("📊 Langfuse: Failed to create span", e);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Track tool results and errors
|
|
426
|
+
pi.on("tool_result", async (event) => {
|
|
427
|
+
const spanData = activeSpans.get(event.toolCallId);
|
|
428
|
+
if (spanData) {
|
|
429
|
+
const { span } = spanData;
|
|
430
|
+
|
|
431
|
+
// Format output nicely
|
|
432
|
+
const outputStr = extractTextContent(event.content, 2000);
|
|
433
|
+
|
|
434
|
+
span.end({
|
|
435
|
+
isError: event.isError,
|
|
436
|
+
output: outputStr || undefined
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Track errors and send per-tool score
|
|
440
|
+
if (event.isError) {
|
|
441
|
+
errorCount++;
|
|
442
|
+
try {
|
|
443
|
+
const lf = await getClient();
|
|
444
|
+
// Per-tool error score (observation level)
|
|
445
|
+
lf.score({
|
|
446
|
+
name: "tool_is_error",
|
|
447
|
+
value: 1,
|
|
448
|
+
traceId: currentTrace?.id,
|
|
449
|
+
observationId: span.id,
|
|
450
|
+
});
|
|
451
|
+
} catch (e) {
|
|
452
|
+
console.warn("📊 Langfuse: Failed to send tool error score", e);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
activeSpans.delete(event.toolCallId);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Track turns and generations
|
|
461
|
+
pi.on("turn_end", async (event) => {
|
|
462
|
+
if (!currentTrace) return;
|
|
463
|
+
|
|
464
|
+
// Increment turn counter
|
|
465
|
+
turnCount++;
|
|
466
|
+
|
|
467
|
+
const eventData = event as unknown as {
|
|
468
|
+
message?: {
|
|
469
|
+
role: string;
|
|
470
|
+
content: Array<{ type: string; text?: string }>;
|
|
471
|
+
model?: string;
|
|
472
|
+
cost?: { input: number; output: number; total: number };
|
|
473
|
+
usage?: {
|
|
474
|
+
input: number;
|
|
475
|
+
output: number;
|
|
476
|
+
cacheRead: number;
|
|
477
|
+
cacheWrite: number;
|
|
478
|
+
totalTokens: number;
|
|
479
|
+
cost?: { input: number; output: number; total: number };
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
toolResults?: Array<{ toolName: string; toolCallId: string }>;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const message = eventData.message;
|
|
486
|
+
if (!message || message.role !== "assistant") return;
|
|
487
|
+
|
|
488
|
+
const usage = message.usage;
|
|
489
|
+
const modelId = message.model || currentModel;
|
|
490
|
+
const provider = currentProvider;
|
|
491
|
+
const cost = usage?.cost;
|
|
492
|
+
|
|
493
|
+
if (usage) {
|
|
494
|
+
try {
|
|
495
|
+
const lf = await getClient();
|
|
496
|
+
|
|
497
|
+
// Extract output text
|
|
498
|
+
const outputText = extractTextContent(message.content)?.slice(0, 1000) || "";
|
|
499
|
+
|
|
500
|
+
// Create generation for the LLM response with model info
|
|
501
|
+
// Note: usage and costDetails go in the generation observation, NOT as scores
|
|
502
|
+
const gen = lf.generation({
|
|
503
|
+
name: "llm-response",
|
|
504
|
+
traceId: currentTrace.id,
|
|
505
|
+
input: currentUserPrompt.slice(0, 500),
|
|
506
|
+
output: outputText,
|
|
507
|
+
model: modelId,
|
|
508
|
+
metadata: {
|
|
509
|
+
provider: provider,
|
|
510
|
+
inputTokens: usage.input || 0,
|
|
511
|
+
outputTokens: usage.output || 0,
|
|
512
|
+
cachedTokens: usage.cacheRead || 0,
|
|
513
|
+
},
|
|
514
|
+
usage: {
|
|
515
|
+
input: usage.input || 0,
|
|
516
|
+
output: usage.output || 0,
|
|
517
|
+
total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
|
|
518
|
+
},
|
|
519
|
+
costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined
|
|
520
|
+
});
|
|
521
|
+
gen.end({
|
|
522
|
+
costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined,
|
|
523
|
+
usage: {
|
|
524
|
+
input: usage.input || 0,
|
|
525
|
+
output: usage.output || 0,
|
|
526
|
+
total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.warn("📊 Langfuse: Failed to create generation", e);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// NOTE: We no longer send token counts or cost as scores
|
|
534
|
+
// Those belong in usage/costDetails on the generation observation
|
|
535
|
+
// Scores are for EVALUATION metrics (success rates, error counts, etc.)
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
pi.on("session_shutdown", async () => {
|
|
540
|
+
if (currentTrace) {
|
|
541
|
+
if (currentTrace.update) {
|
|
542
|
+
currentTrace.update({ metadata: { completed: true } });
|
|
543
|
+
}
|
|
544
|
+
currentTrace = null;
|
|
545
|
+
}
|
|
546
|
+
if (client) {
|
|
547
|
+
await client.shutdownAsync();
|
|
548
|
+
client = null;
|
|
549
|
+
}
|
|
550
|
+
resetSessionState();
|
|
551
|
+
});
|
|
552
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-langfuse",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Langfuse extension for Pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"langfuse",
|
|
13
|
+
"observability",
|
|
14
|
+
"tracing",
|
|
15
|
+
"monitoring",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"extension"
|
|
18
|
+
],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"langfuse": "^3.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/skills-lock.json
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true
|
|
10
|
+
},
|
|
11
|
+
"include": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"types/**/*.d.ts"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
declare module "node:fs" {
|
|
2
|
+
export function readFileSync(path: string, encoding: string): string;
|
|
3
|
+
export function existsSync(path: string): boolean;
|
|
4
|
+
export function writeFileSync(path: string, data: string, encoding: string): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module "node:path" {
|
|
8
|
+
export function resolve(...paths: string[]): string;
|
|
9
|
+
export function dirname(path: string): string;
|
|
10
|
+
export function basename(path: string, suffix?: string): string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare module "node:url" {
|
|
14
|
+
export function fileURLToPath(url: string | URL): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare const process: {
|
|
18
|
+
cwd(): string;
|
|
19
|
+
env: Record<string, string | undefined>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare module "@earendil-works/pi-coding-agent" {
|
|
2
|
+
export interface ExtensionAPI {
|
|
3
|
+
on(event: string, handler: (event: any, ctx: any) => unknown): void;
|
|
4
|
+
registerCommand(
|
|
5
|
+
name: string,
|
|
6
|
+
options: { description?: string; handler: (args: string, ctx: any) => unknown },
|
|
7
|
+
): void;
|
|
8
|
+
}
|
|
9
|
+
}
|