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/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
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "langfuse": {
5
+ "source": "langfuse/skills",
6
+ "sourceType": "github",
7
+ "skillPath": "skills/langfuse/SKILL.md",
8
+ "computedHash": "ccb3e0bee034850742b4387983e83c9cf2d8d8283a5a4264f0ecdfd03db01755"
9
+ }
10
+ }
11
+ }
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
+ }