@narumitw/pi-langfuse 0.50.6 → 0.51.1

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/src/langfuse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  DEFAULT_BASE_URL,
4
4
  type LangfuseConfig,
@@ -8,7 +8,11 @@ import {
8
8
  normalizeLangfuseConfig,
9
9
  writeLangfuseConfig,
10
10
  } from "./config.js";
11
- import { type ContextSnapshot, type GitMetadata, type TraceBackend, TraceRecorder } from "./tracing.js";
11
+ import { createPiLangfuseSessionController, type PiLangfuseSessionController } from "./pi-session.js";
12
+ import { createLangfuseRuntimeFromBackend } from "./runtime-core.js";
13
+ import type { GitMetadata, TraceBackend } from "./tracing.js";
14
+
15
+ export { resolveGitMetadata } from "./git.js";
12
16
 
13
17
  interface ExtensionDependencies {
14
18
  loadConfig(path?: string): Promise<LangfuseConfigResult>;
@@ -25,19 +29,13 @@ const HELP_ACTION = "Show setup and privacy help";
25
29
  export function createLangfuseExtension(dependencies: Partial<ExtensionDependencies> = {}): (pi: ExtensionAPI) => void {
26
30
  const loadConfig = dependencies.loadConfig ?? loadLangfuseConfig;
27
31
  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
- });
32
+ const resolveInjectedGitMetadata = dependencies.resolveGitMetadata;
34
33
 
35
34
  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
35
  let activeConfig: LangfuseConfig | undefined;
36
+ let loadingConfig: LangfuseConfig | undefined;
37
+ let runtimeConfigForShutdown: LangfuseConfig | undefined;
38
+ let shutdownConfig: LangfuseConfig | undefined;
41
39
  let configPath: string | undefined;
42
40
  let initializationError: string | undefined;
43
41
  let hasStoredConfig = false;
@@ -45,7 +43,80 @@ export function createLangfuseExtension(dependencies: Partial<ExtensionDependenc
45
43
  let sessionGeneration = 0;
46
44
  let menuController = new AbortController();
47
45
  let configWriteQueue = Promise.resolve();
48
- let nextAttemptReason: string | undefined;
46
+
47
+ const tracing: PiLangfuseSessionController = createPiLangfuseSessionController({
48
+ onSessionStart() {
49
+ sessionGeneration += 1;
50
+ menuController.abort(new DOMException("Langfuse session replaced", "AbortError"));
51
+ menuController = new AbortController();
52
+ activeConfig = undefined;
53
+ loadingConfig = undefined;
54
+ shutdownConfig = undefined;
55
+ configPath = undefined;
56
+ initializationError = undefined;
57
+ hasStoredConfig = false;
58
+ configurationNotice = undefined;
59
+ },
60
+ onSessionShutdown() {
61
+ sessionGeneration += 1;
62
+ menuController.abort(new DOMException("Langfuse session shut down", "AbortError"));
63
+ shutdownConfig = activeConfig ?? runtimeConfigForShutdown ?? loadingConfig;
64
+ activeConfig = undefined;
65
+ },
66
+ async resolveSession(ctx, isCurrent) {
67
+ await configWriteQueue;
68
+ if (!isCurrent()) return undefined;
69
+ const result = await loadConfig();
70
+ if (!isCurrent()) return undefined;
71
+ configPath = result.path;
72
+ for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
73
+ if (!result.ok) {
74
+ initializationError = formatConfigError(result);
75
+ ctx.ui.notify(initializationError, "warning");
76
+ return undefined;
77
+ }
78
+
79
+ hasStoredConfig = true;
80
+ loadingConfig = result.config;
81
+ const createBackend = dependencies.createBackend;
82
+ const ownsRuntime = createBackend !== undefined;
83
+ const runtime = createBackend
84
+ ? createLangfuseRuntimeFromBackend(await createBackend(result.config))
85
+ : await (await import("./runtime.js")).createLangfuseRuntime({ config: result.config, env: false });
86
+ if (isCurrent() || !runtimeConfigForShutdown) runtimeConfigForShutdown = result.config;
87
+ if (isCurrent()) {
88
+ activeConfig = result.config;
89
+ loadingConfig = undefined;
90
+ }
91
+ return {
92
+ runtime,
93
+ releaseIfStale: async (reason) => {
94
+ if (ownsRuntime || reason === "quit") await runtime.shutdown();
95
+ },
96
+ options: {
97
+ ...(result.config.userId ? { userId: result.config.userId } : {}),
98
+ captureContent: result.config.captureContent,
99
+ },
100
+ };
101
+ },
102
+ ...(resolveInjectedGitMetadata
103
+ ? { resolveGitMetadata: (_pi: ExtensionAPI, cwd: string) => resolveInjectedGitMetadata(cwd) }
104
+ : {}),
105
+ onInitializationError(error, ctx) {
106
+ const config = loadingConfig;
107
+ loadingConfig = undefined;
108
+ initializationError = `Langfuse tracing could not start: ${formatError(error, config)}`;
109
+ ctx.ui.notify(initializationError, "warning");
110
+ },
111
+ beforeSessionDispose: async () => {
112
+ await configWriteQueue;
113
+ },
114
+ onShutdownError(error, ctx) {
115
+ ctx.ui.notify(`Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`, "error");
116
+ },
117
+ flushOnReplacement: true,
118
+ shutdownRuntimeOnQuit: true,
119
+ });
49
120
 
50
121
  async function showLangfuseMenu(ctx: ExtensionCommandContext) {
51
122
  if (!ctx.hasUI) {
@@ -69,7 +140,7 @@ export function createLangfuseExtension(dependencies: Partial<ExtensionDependenc
69
140
  .split("\n")
70
141
  .slice(1),
71
142
  items: [
72
- ...(recorder ? [{ id: "flush", label: FLUSH_ACTION, action: "flush" as const }] : []),
143
+ ...(tracing.active ? [{ id: "flush", label: FLUSH_ACTION, action: "flush" as const }] : []),
73
144
  {
74
145
  id: "configure",
75
146
  label: hasStoredConfig ? UPDATE_ACTION : SET_UP_ACTION,
@@ -82,14 +153,13 @@ export function createLangfuseExtension(dependencies: Partial<ExtensionDependenc
82
153
  },
83
154
  actions: {
84
155
  flush: async () => {
85
- const menuRecorder = recorder;
86
156
  const menuConfig = activeConfig;
87
- if (!menuRecorder) {
157
+ if (!tracing.active) {
88
158
  ctx.ui.notify("Langfuse tracing is not enabled for this session.", "warning");
89
159
  return { kind: "close" };
90
160
  }
91
161
  try {
92
- await menuRecorder.flush();
162
+ await tracing.flush();
93
163
  if (menuGeneration !== sessionGeneration) return { kind: "close" };
94
164
  ctx.ui.notify("Langfuse traces flushed for this session.", "info");
95
165
  } catch (error) {
@@ -146,284 +216,10 @@ export function createLangfuseExtension(dependencies: Partial<ExtensionDependenc
146
216
  handler: async (_args, ctx) => showLangfuseMenu(ctx),
147
217
  });
148
218
 
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
- };
333
- }
334
-
335
- function ensureActiveRun(recorder: TraceRecorder, ctx: ExtensionContext): void {
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();
344
- }
345
-
346
- function contextSnapshot(ctx: ExtensionContext): ContextSnapshot {
347
- return {
348
- leafId: typeof ctx.sessionManager.getLeafId === "function" ? ctx.sessionManager.getLeafId() : undefined,
349
- contextUsage: ctx.getContextUsage(),
219
+ tracing.extension(pi);
350
220
  };
351
221
  }
352
222
 
353
- function findLastAssistant<T extends { role?: string }>(messages: readonly T[]): T | undefined {
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;
359
- }
360
-
361
- function isRealOutputDelta(event: { type: string; delta?: unknown }): boolean {
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
- );
367
- }
368
-
369
- const GIT_LOOKUP_TIMEOUT_MS = 1_000;
370
- const MAX_GIT_BRANCH_LENGTH = 256;
371
-
372
- type GitExecutor = ExtensionAPI["exec"];
373
-
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;
385
- }
386
-
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
- }
401
- }
402
-
403
- async function resolveGitCommit(exec: GitExecutor, cwd: string): Promise<string | undefined> {
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
- }
415
- }
416
-
417
- function normalizeGitBranch(value: string): string | undefined {
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;
425
- }
426
-
427
223
  async function promptForConfig(
428
224
  ctx: ExtensionCommandContext,
429
225
  current: LangfuseConfig | undefined,