@narumitw/pi-langfuse 0.50.5 → 0.51.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/dist/index.ts CHANGED
@@ -1,232 +1,65 @@
1
1
  // @generated by scripts/build-runtime.mjs; do not edit.
2
2
  // @ts-nocheck -- the generated entry uses a .ts extension for Pi's Jiti loader.
3
+ import {
4
+ DEFAULT_BASE_URL,
5
+ createLangfuseRuntimeFromBackend,
6
+ getLangfuseRuntimeInternal,
7
+ loadLangfuseConfig,
8
+ normalizeLangfuseConfig,
9
+ writeLangfuseConfig
10
+ } from "./chunks/chunk-IASLJKKB.js";
3
11
 
4
- // src/config.ts
5
- import { randomUUID } from "node:crypto";
6
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
- import { dirname, join } from "node:path";
8
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
- var CONFIG_FILE_NAME = "pi-langfuse.json";
10
- var DEFAULT_BASE_URL = "https://us.cloud.langfuse.com";
11
- function langfuseConfigPath() {
12
- return join(getAgentDir(), CONFIG_FILE_NAME);
13
- }
14
- var configSaveQueues = /* @__PURE__ */ new Map();
15
- function writeLangfuseConfig(patch, path = langfuseConfigPath()) {
16
- const previous = configSaveQueues.get(path) ?? Promise.resolve();
17
- const operation = previous.then(() => writeLangfuseConfigNow(patch, path));
18
- const tail = operation.then(
19
- () => void 0,
20
- () => void 0
21
- );
22
- configSaveQueues.set(path, tail);
23
- void tail.then(() => {
24
- if (configSaveQueues.get(path) === tail) configSaveQueues.delete(path);
25
- });
26
- return operation;
27
- }
28
- async function writeLangfuseConfigNow(patch, path) {
29
- const currentDocument = await readLangfuseDocumentForUpdate(path);
30
- const normalized = normalizeLangfuseConfig({ ...currentDocument, ...patch });
31
- if (!normalized.ok) throw new Error(normalized.reason);
32
- const {
33
- publicKey: _publicKey,
34
- secretKey: _secretKey,
35
- baseUrl: _baseUrl,
36
- environment: _environment,
37
- release: _release,
38
- userId: _userId,
39
- captureContent: _captureContent,
40
- ...unknownFields
41
- } = currentDocument;
42
- const nextDocument = { ...unknownFields, ...normalized.config };
43
- await mkdir(dirname(path), { recursive: true, mode: 448 });
44
- const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
45
- try {
46
- await writeFile(tempPath, `${JSON.stringify(nextDocument, null, 2)}
47
- `, {
48
- encoding: "utf8",
49
- mode: 384
50
- });
51
- await chmod(tempPath, 384);
52
- await rename(tempPath, path);
53
- return normalized.config;
54
- } catch (error) {
55
- await rm(tempPath, { force: true }).catch(() => void 0);
56
- throw error;
57
- }
58
- }
59
- async function readLangfuseDocumentForUpdate(path) {
60
- let parsed;
61
- try {
62
- parsed = JSON.parse(await readFile(path, "utf8"));
63
- } catch (error) {
64
- if (isNodeError(error) && error.code === "ENOENT") return {};
65
- throw new Error(
66
- `Cannot update ${CONFIG_FILE_NAME} because the existing file is malformed or unreadable; repair it first.`
67
- );
68
- }
69
- const normalized = normalizeLangfuseConfig(parsed);
70
- if (!normalized.ok) {
71
- throw new Error(
72
- `Cannot update ${CONFIG_FILE_NAME} because the existing file has invalid recognized fields; repair it first.`
73
- );
74
- }
75
- return { ...parsed };
76
- }
77
- async function loadLangfuseConfig(path = langfuseConfigPath()) {
78
- await configSaveQueues.get(path);
79
- const warnings = [];
80
- const permissionFailure = await ensurePrivatePermissions(path, warnings);
81
- if (permissionFailure) {
82
- return {
83
- ok: false,
84
- path,
85
- warnings,
86
- reason: `Refusing to load credentials: ${permissionFailure}`
87
- };
88
- }
89
- let text;
90
- try {
91
- text = await readFile(path, "utf8");
92
- } catch (error) {
93
- if (isNodeError(error) && error.code === "ENOENT") {
94
- return { ok: false, path, warnings, reason: `Configuration file not found: ${path}` };
95
- }
96
- return {
97
- ok: false,
98
- path,
99
- warnings,
100
- reason: `Failed to read ${path}: ${formatError(error)}`
101
- };
102
- }
103
- let parsed;
104
- try {
105
- parsed = JSON.parse(text);
106
- } catch {
107
- return {
108
- ok: false,
109
- path,
110
- warnings,
111
- reason: `Failed to parse ${path} as JSON; repair the file before retrying.`
112
- };
113
- }
114
- const normalized = normalizeLangfuseConfig(parsed);
115
- if (!normalized.ok) return { ok: false, path, warnings, reason: normalized.reason };
116
- return { ok: true, config: normalized.config, path, warnings };
117
- }
118
- function normalizeLangfuseConfig(value) {
119
- if (!value || typeof value !== "object" || Array.isArray(value)) {
120
- return { ok: false, reason: "pi-langfuse.json must contain a JSON object." };
121
- }
122
- const input = value;
123
- const publicKey = normalizeString(input.publicKey);
124
- const secretKey = normalizeString(input.secretKey);
125
- if (!publicKey) {
126
- return { ok: false, reason: "pi-langfuse.json requires a literal publicKey string." };
127
- }
128
- if (!secretKey) {
129
- return { ok: false, reason: "pi-langfuse.json requires a literal secretKey string." };
130
- }
131
- if (isInterpolation(publicKey)) {
132
- return {
133
- ok: false,
134
- reason: "pi-langfuse.json publicKey must be literal; environment and command interpolation are not supported."
135
- };
136
- }
137
- if (isInterpolation(secretKey)) {
138
- return {
139
- ok: false,
140
- reason: "pi-langfuse.json secretKey must be literal; environment and command interpolation are not supported."
141
- };
142
- }
143
- const rawBaseUrl = input.baseUrl === void 0 ? DEFAULT_BASE_URL : normalizeString(input.baseUrl);
144
- if (!rawBaseUrl) {
145
- return { ok: false, reason: "pi-langfuse.json baseUrl must be a non-empty string." };
146
- }
147
- const baseUrl = normalizeBaseUrl(rawBaseUrl);
148
- if (!baseUrl) {
149
- return {
150
- ok: false,
151
- reason: "pi-langfuse.json baseUrl must use HTTP or HTTPS without credentials, a query, or a fragment."
152
- };
153
- }
154
- if (input.captureContent !== void 0 && typeof input.captureContent !== "boolean") {
155
- return { ok: false, reason: "pi-langfuse.json captureContent must be a boolean." };
156
- }
157
- const environment = optionalString(input.environment, "environment");
158
- if (!environment.ok) return environment;
159
- if (environment.value && (environment.value.length > 40 || !/^(?!langfuse)[a-z0-9_-]+$/u.test(environment.value))) {
12
+ // src/git.ts
13
+ var GIT_LOOKUP_TIMEOUT_MS = 1e3;
14
+ var MAX_GIT_BRANCH_LENGTH = 256;
15
+ async function resolveGitMetadata(exec, cwd) {
16
+ const [branchResult, commit] = await Promise.all([resolveGitBranch(exec, cwd), resolveGitCommit(exec, cwd)]);
17
+ if (!branchResult.resolved) return void 0;
18
+ if (branchResult.branch) {
160
19
  return {
161
- ok: false,
162
- reason: "pi-langfuse.json environment must be at most 40 lowercase letters, numbers, hyphens, or underscores and must not start with langfuse."
20
+ branch: branchResult.branch,
21
+ ...commit ? { commit } : {},
22
+ detached: false
163
23
  };
164
24
  }
165
- const release = optionalString(input.release, "release");
166
- if (!release.ok) return release;
167
- const userId = optionalString(input.userId, "userId");
168
- if (!userId.ok) return userId;
169
- if (userId.value && userId.value.length > 200) {
170
- return { ok: false, reason: "pi-langfuse.json userId must be at most 200 characters." };
171
- }
172
- return {
173
- ok: true,
174
- config: {
175
- publicKey,
176
- secretKey,
177
- baseUrl,
178
- ...environment.value ? { environment: environment.value } : {},
179
- ...release.value ? { release: release.value } : {},
180
- ...userId.value ? { userId: userId.value } : {},
181
- captureContent: input.captureContent ?? true
182
- }
183
- };
25
+ return commit ? { commit, detached: true } : void 0;
184
26
  }
185
- async function ensurePrivatePermissions(path, warnings) {
27
+ async function resolveGitBranch(exec, cwd) {
186
28
  try {
187
- const file = await stat(path);
188
- if ((file.mode & 511) !== 384) {
189
- await chmod(path, 384);
190
- const repaired = await stat(path);
191
- if ((repaired.mode & 511) !== 384) {
192
- throw new Error(`permissions remained ${(repaired.mode & 511).toString(8)}`);
193
- }
194
- warnings.push(`Restricted ${path} permissions to 0600.`);
195
- }
196
- } catch (error) {
197
- if (isNodeError(error) && error.code === "ENOENT") return void 0;
198
- const failure = `Failed to enforce 0600 permissions for ${path}: ${formatError(error)}`;
199
- warnings.push(failure);
200
- return failure;
29
+ const result = await exec("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], {
30
+ cwd,
31
+ timeout: GIT_LOOKUP_TIMEOUT_MS
32
+ });
33
+ if (result.killed) return { resolved: false };
34
+ if (result.code === 1) return { resolved: true };
35
+ if (result.code !== 0) return { resolved: false };
36
+ const branch = normalizeGitBranch(result.stdout);
37
+ return branch ? { resolved: true, branch } : { resolved: false };
38
+ } catch {
39
+ return { resolved: false };
201
40
  }
202
- return void 0;
203
41
  }
204
- function normalizeBaseUrl(value) {
42
+ async function resolveGitCommit(exec, cwd) {
205
43
  try {
206
- const url = new URL(value);
207
- if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
208
- if (url.username || url.password || url.search || url.hash) return void 0;
209
- return url.toString().replace(/\/+$/, "");
44
+ const result = await exec("git", ["rev-parse", "--verify", "--short=12", "HEAD"], {
45
+ cwd,
46
+ timeout: GIT_LOOKUP_TIMEOUT_MS
47
+ });
48
+ if (result.code !== 0 || result.killed) return void 0;
49
+ const commit = result.stdout.trim();
50
+ return /^[0-9a-f]{4,64}$/iu.test(commit) ? commit.toLowerCase() : void 0;
210
51
  } catch {
211
52
  return void 0;
212
53
  }
213
54
  }
214
- function optionalString(value, name) {
215
- if (value === void 0) return { ok: true };
216
- const normalized = normalizeString(value);
217
- return normalized ? { ok: true, value: normalized } : { ok: false, reason: `pi-langfuse.json ${name} must be a non-empty string.` };
218
- }
219
- function normalizeString(value) {
220
- return typeof value === "string" && value.trim() ? value.trim() : void 0;
221
- }
222
- function isInterpolation(value) {
223
- return value.startsWith("$") || value.startsWith("!");
224
- }
225
- function isNodeError(error) {
226
- return error instanceof Error && "code" in error;
227
- }
228
- function formatError(error) {
229
- return error instanceof Error ? error.message : String(error);
55
+ function normalizeGitBranch(value) {
56
+ const branch = value.trim();
57
+ if (!branch || branch.length > MAX_GIT_BRANCH_LENGTH) return void 0;
58
+ for (const character of branch) {
59
+ const code = character.codePointAt(0) ?? 0;
60
+ if (code <= 31 || code === 127) return void 0;
61
+ }
62
+ return branch;
230
63
  }
231
64
 
232
65
  // src/sanitizer.ts
@@ -249,9 +82,7 @@ function sanitize(value, active, depth, budget) {
249
82
  return consume(value, budget);
250
83
  }
251
84
  if (typeof value === "string") {
252
- const bounded = truncateString(value, Math.min(MAX_STRING_LENGTH, budget.remaining));
253
- const redacted = bounded.replace(BASE64_DATA_URI, BASE64_DATA_URI_OMITTED);
254
- return consume(redacted, budget);
85
+ return consume(sanitizeString(value, Math.min(MAX_STRING_LENGTH, budget.remaining)), budget);
255
86
  }
256
87
  if (typeof value === "bigint") return consume(value.toString(), budget);
257
88
  if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
@@ -301,12 +132,13 @@ function sanitize(value, active, depth, budget) {
301
132
  omitted = true;
302
133
  break;
303
134
  }
304
- budget.remaining -= keyBytes + 4;
135
+ const sanitizedKey = sanitizeString(key, keyBudget);
136
+ budget.remaining -= byteLength(sanitizedKey) + 4;
305
137
  const property = readProperty(record, key);
306
138
  if (redactData && key === "data") {
307
- output[key] = consume("[base64 omitted]", budget);
308
- } else if (!property.ok) output[key] = consume("[unreadable property]", budget);
309
- else output[key] = sanitize(property.value, active, depth + 1, budget);
139
+ output[sanitizedKey] = consume("[base64 omitted]", budget);
140
+ } else if (!property.ok) output[sanitizedKey] = consume("[unreadable property]", budget);
141
+ else output[sanitizedKey] = sanitize(property.value, active, depth + 1, budget);
310
142
  processed += 1;
311
143
  }
312
144
  } catch {
@@ -338,6 +170,9 @@ function consume(value, budget) {
338
170
  budget.remaining -= size;
339
171
  return value;
340
172
  }
173
+ function sanitizeString(value, maxBytes) {
174
+ return truncateString(value, maxBytes).replace(BASE64_DATA_URI, BASE64_DATA_URI_OMITTED);
175
+ }
341
176
  function truncateString(value, maxBytes) {
342
177
  const boundedPrefix = value.slice(0, maxBytes);
343
178
  if (boundedPrefix.length === value.length && byteLength(boundedPrefix) <= maxBytes) {
@@ -410,11 +245,7 @@ var TraceRecorder = class {
410
245
  counters = emptyCounters();
411
246
  startObservation(name, attributes, options) {
412
247
  const { sessionId, userId } = this.context;
413
- return this.backend.start(
414
- name,
415
- { ...attributes, sessionId, ...userId ? { userId } : {} },
416
- options
417
- );
248
+ return this.backend.start(name, { ...attributes, sessionId, ...userId ? { userId } : {} }, options);
418
249
  }
419
250
  hasActiveTrace() {
420
251
  return this.root !== void 0;
@@ -436,6 +267,7 @@ var TraceRecorder = class {
436
267
  ...hasItems(input.images) ? { images: input.images } : {}
437
268
  });
438
269
  const metadata = {
270
+ ...customTraceMetadata(this.context.metadata),
439
271
  "pi.cwd": this.context.cwd,
440
272
  ...input.git?.branch ? { "pi.git.branch": input.git.branch } : {},
441
273
  ...input.git?.commit ? { "pi.git.commit": input.git.commit } : {},
@@ -443,6 +275,7 @@ var TraceRecorder = class {
443
275
  "pi.mode": this.context.mode,
444
276
  ...input.model?.id ? { "pi.model": input.model.id } : {},
445
277
  ...input.model?.provider ? { "pi.provider": input.model.provider } : {},
278
+ ...input.requestId ? { "pi.request.id": input.requestId } : {},
446
279
  "pi.session.id": this.context.sessionId,
447
280
  "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
448
281
  ...snapshotMetadata("start", input.snapshot)
@@ -451,14 +284,21 @@ var TraceRecorder = class {
451
284
  const gitTag = input.git?.branch ? `branch:${input.git.branch}` : input.git?.detached ? "git:detached" : void 0;
452
285
  this.root = this.startObservation("pi.agent", attributes, { asType: "agent" });
453
286
  this.root.updateTrace?.({
454
- name: "pi.trace",
287
+ name: normalizeTraceName(this.context.traceName),
455
288
  sessionId: this.context.sessionId,
456
289
  ...this.context.userId ? { userId: this.context.userId } : {},
457
290
  version: TRACE_SCHEMA_VERSION,
458
291
  input: traceInput,
459
292
  metadata,
460
- tags: ["pi", ...gitTag ? [gitTag] : []]
293
+ tags: traceTags(this.context.tags, gitTag)
461
294
  });
295
+ const traceId = this.root.traceId;
296
+ if (traceId && this.context.onTraceId) {
297
+ try {
298
+ void Promise.resolve(this.context.onTraceId(traceId)).catch(() => void 0);
299
+ } catch {
300
+ }
301
+ }
462
302
  }
463
303
  beginAttempt(input = {}) {
464
304
  if (!this.root) return;
@@ -548,7 +388,11 @@ var TraceRecorder = class {
548
388
  ...Object.keys(requestMetadata).length > 0 ? { metadata: requestMetadata } : {},
549
389
  version: TRACE_SCHEMA_VERSION
550
390
  },
551
- { asType: "generation", parent: this.turn ?? this.attempt ?? this.root }
391
+ {
392
+ asType: "generation",
393
+ parent: this.turn ?? this.attempt ?? this.root,
394
+ ...input.startedAt === void 0 ? {} : { startTime: new Date(input.startedAt) }
395
+ }
552
396
  );
553
397
  this.generation = {
554
398
  observation,
@@ -682,10 +526,7 @@ var TraceRecorder = class {
682
526
  metadata: {
683
527
  "pi.tool.progress_update_count": tool.progressUpdates,
684
528
  ...tool.firstProgressAt !== void 0 ? {
685
- "pi.tool.time_to_first_progress_ms": Math.max(
686
- 0,
687
- tool.firstProgressAt - tool.startedAt
688
- )
529
+ "pi.tool.time_to_first_progress_ms": Math.max(0, tool.firstProgressAt - tool.startedAt)
689
530
  } : {}
690
531
  },
691
532
  ...result.isError ? { level: "ERROR", statusMessage: "Pi tool execution failed." } : {}
@@ -753,12 +594,8 @@ var TraceRecorder = class {
753
594
  interrupt(statusMessage, snapshot) {
754
595
  this.closeActiveTrace(statusMessage, snapshot, "interrupted");
755
596
  }
756
- async flush() {
757
- await this.backend.forceFlush();
758
- }
759
- async shutdown(snapshot) {
760
- this.closeActiveTrace("Pi shut down before the active trace settled.", snapshot, "interrupted");
761
- await this.backend.shutdown();
597
+ dispose(statusMessage = "Pi session ended before the active trace settled.", snapshot) {
598
+ this.closeActiveTrace(statusMessage, snapshot, "interrupted");
762
599
  }
763
600
  closeActiveTrace(statusMessage, snapshot, forcedOutcome) {
764
601
  this.closeTurn(statusMessage ?? "Pi turn ended when the agent run settled.");
@@ -850,10 +687,7 @@ var TraceRecorder = class {
850
687
  metadata: {
851
688
  "pi.tool.progress_update_count": tool.progressUpdates,
852
689
  ...tool.firstProgressAt !== void 0 ? {
853
- "pi.tool.time_to_first_progress_ms": Math.max(
854
- 0,
855
- tool.firstProgressAt - tool.startedAt
856
- )
690
+ "pi.tool.time_to_first_progress_ms": Math.max(0, tool.firstProgressAt - tool.startedAt)
857
691
  } : {}
858
692
  }
859
693
  });
@@ -884,6 +718,24 @@ var TraceRecorder = class {
884
718
  return this.context.captureContent ? sanitizeTraceValue(value) : CONTENT_DISABLED;
885
719
  }
886
720
  };
721
+ function normalizeTraceName(value) {
722
+ return typeof value === "string" && value.trim() ? value.trim() : "pi.trace";
723
+ }
724
+ function traceTags(customTags, gitTag) {
725
+ const tags = ["pi", ...gitTag ? [gitTag] : [], ...customTags ?? []];
726
+ return [...new Set(tags.map(sanitizeTraceTag).filter((tag) => tag !== void 0))];
727
+ }
728
+ function sanitizeTraceTag(value) {
729
+ if (typeof value !== "string" || !value.trim()) return void 0;
730
+ const sanitized = sanitizeTraceValue(value.trim());
731
+ return typeof sanitized === "string" && sanitized.trim() ? sanitized.trim() : void 0;
732
+ }
733
+ function customTraceMetadata(metadata) {
734
+ if (!metadata) return {};
735
+ const sanitized = sanitizeTraceValue(metadata);
736
+ if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) return {};
737
+ return Object.fromEntries(Object.entries(sanitized).filter(([key]) => !key.startsWith("pi.")));
738
+ }
887
739
  function generationHttpMetadata(state) {
888
740
  const finalStatus = state.statuses.at(-1);
889
741
  return {
@@ -945,9 +797,7 @@ function severityForOutcome(outcome, statusMessage) {
945
797
  };
946
798
  }
947
799
  function prefixRecord(prefix, values) {
948
- return Object.fromEntries(
949
- Object.entries(values).map(([key, value]) => [`${prefix}.${key}`, value])
950
- );
800
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [`${prefix}.${key}`, value]));
951
801
  }
952
802
  function truncateString2(value, maxBytes) {
953
803
  const boundedPrefix = value.slice(0, maxBytes);
@@ -976,9 +826,7 @@ function numericRecord(values) {
976
826
  }
977
827
  function positiveNumericRecord(values) {
978
828
  return Object.fromEntries(
979
- Object.entries(values).filter(
980
- (entry) => finiteNumber(entry[1]) && entry[1] > 0
981
- )
829
+ Object.entries(values).filter((entry) => finiteNumber(entry[1]) && entry[1] > 0)
982
830
  );
983
831
  }
984
832
  function finiteNumber(value) {
@@ -1000,235 +848,258 @@ function emptyCounters() {
1000
848
  };
1001
849
  }
1002
850
 
1003
- // src/langfuse.ts
1004
- var FLUSH_ACTION = "Flush completed traces for this session";
1005
- var SET_UP_ACTION = "Set up Langfuse for this Pi agent directory (restart required)";
1006
- var UPDATE_ACTION = "Update Langfuse for this Pi agent directory (restart required)";
1007
- var HELP_ACTION = "Show setup and privacy help";
1008
- function createLangfuseExtension(dependencies = {}) {
1009
- const loadConfig = dependencies.loadConfig ?? loadLangfuseConfig;
1010
- const writeConfig = dependencies.writeConfig ?? writeLangfuseConfig;
1011
- const createBackend = dependencies.createBackend ?? (async (config) => {
1012
- const { createProductionBackend } = await import("./chunks/runtime-247EMSG3.js");
1013
- return createProductionBackend(config);
851
+ // src/pi-session.ts
852
+ function createPiLangfuseSession(runtime, options = {}) {
853
+ const sessionOptions = snapshotSessionOptions(options);
854
+ return createPiLangfuseSessionController({
855
+ resolveSession: async () => ({ runtime, options: sessionOptions })
1014
856
  });
1015
- return function langfuse(pi) {
1016
- const resolveGit = dependencies.resolveGitMetadata ?? ((cwd) => resolveGitMetadata((command, args, options) => pi.exec(command, args, options), cwd));
1017
- let recorder;
1018
- let activeConfig;
1019
- let configPath;
1020
- let initializationError;
1021
- let hasStoredConfig = false;
1022
- let configurationNotice;
1023
- let sessionGeneration = 0;
1024
- let menuController = new AbortController();
1025
- let configWriteQueue = Promise.resolve();
1026
- let nextAttemptReason;
1027
- async function showLangfuseMenu(ctx) {
1028
- if (!ctx.hasUI) {
1029
- ctx.ui.notify(
1030
- formatNonInteractiveStatus(activeConfig, configPath, initializationError),
1031
- "warning"
1032
- );
1033
- return;
857
+ }
858
+ function createPiLangfuseSessionController(options) {
859
+ let binding;
860
+ let runtimeForShutdown;
861
+ let ownerRegistration;
862
+ let pendingInitialization;
863
+ const outstandingInitializations = /* @__PURE__ */ new Set();
864
+ let disposePromise;
865
+ let disposed = false;
866
+ let sessionGeneration = 0;
867
+ let requestId;
868
+ let requestIdRevision = 0;
869
+ let nextAttemptReason;
870
+ let lastSnapshot;
871
+ let pendingProviderGeneration;
872
+ const controller = {
873
+ extension(pi) {
874
+ if (disposed) throw new Error("A disposed Pi Langfuse session controller cannot be bound.");
875
+ registerHooks(pi);
876
+ },
877
+ setRequestId(value) {
878
+ requestId = normalizeOptionalString(value);
879
+ requestIdRevision += 1;
880
+ },
881
+ dispose() {
882
+ if (disposePromise) return disposePromise;
883
+ disposed = true;
884
+ const initializations = invalidateAllInitializations("disposed");
885
+ ownerRegistration = void 0;
886
+ sessionGeneration += 1;
887
+ closeBinding(binding, "Pi Langfuse session controller was disposed.", lastSnapshot);
888
+ runtimeForShutdown = void 0;
889
+ disposePromise = joinInitializations(initializations);
890
+ return disposePromise;
891
+ },
892
+ get active() {
893
+ return binding !== void 0;
894
+ },
895
+ get runtime() {
896
+ return binding?.runtime;
897
+ },
898
+ async flush() {
899
+ const runtime = binding?.runtime;
900
+ if (!runtime) throw new Error("Langfuse tracing is not enabled for this session.");
901
+ await runtime.flush();
902
+ }
903
+ };
904
+ function closeBinding(target, statusMessage, snapshot) {
905
+ if (!target) return;
906
+ const wasCurrent = binding === target;
907
+ if (wasCurrent) {
908
+ binding = void 0;
909
+ pendingProviderGeneration = void 0;
910
+ }
911
+ target.releaseRuntime();
912
+ target.recorder.dispose(statusMessage, snapshot);
913
+ if (wasCurrent) nextAttemptReason = void 0;
914
+ }
915
+ function invalidatePendingInitialization(reason) {
916
+ if (!pendingInitialization) return;
917
+ pendingInitialization.current = false;
918
+ pendingInitialization.reason = reason;
919
+ pendingInitialization = void 0;
920
+ }
921
+ function invalidateAllInitializations(reason) {
922
+ const initializations = [...outstandingInitializations];
923
+ for (const initialization of initializations) {
924
+ initialization.current = false;
925
+ initialization.reason = reason;
926
+ }
927
+ pendingInitialization = void 0;
928
+ return initializations;
929
+ }
930
+ function createPendingInitialization() {
931
+ let complete;
932
+ const completion = new Promise((resolve) => {
933
+ complete = resolve;
934
+ });
935
+ const initialization = { current: true, completion, complete };
936
+ outstandingInitializations.add(initialization);
937
+ return initialization;
938
+ }
939
+ async function joinInitializations(initializations) {
940
+ const results = await Promise.all(initializations.map(({ completion }) => completion));
941
+ const errors = results.flatMap((result) => result.ok ? [] : [result.error]);
942
+ if (errors.length === 1) throw errors[0];
943
+ if (errors.length > 1) throw new AggregateError(errors, "Pi Langfuse session initialization cleanup failed.");
944
+ }
945
+ async function initializeSession(registration, initialization, ctx) {
946
+ const generation = ++sessionGeneration;
947
+ closeBinding(binding, "Pi session was replaced before shutdown completed.", lastSnapshot);
948
+ lastSnapshot = contextSnapshot(ctx);
949
+ nextAttemptReason = void 0;
950
+ options.onSessionStart?.();
951
+ const isCurrent = () => !disposed && initialization.current && ownerRegistration === registration && generation === sessionGeneration;
952
+ let resolved;
953
+ try {
954
+ resolved = await options.resolveSession(ctx, isCurrent);
955
+ } catch (error) {
956
+ if (isCurrent()) options.onInitializationError?.(error, ctx);
957
+ return;
958
+ }
959
+ if (!isCurrent()) {
960
+ const reason = initialization.reason ?? "replaced";
961
+ if (resolved && options.shutdownRuntimeOnQuit && reason !== "quit" && reason !== "disposed" && (!runtimeForShutdown || runtimeForShutdown.closed)) {
962
+ runtimeForShutdown = resolved.runtime;
1034
963
  }
1035
- const menuGeneration = sessionGeneration;
1036
- const menuSignal = menuController.signal;
1037
- const isCurrent = () => menuGeneration === sessionGeneration && !menuSignal.aborted;
1038
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
1039
- if (!isCurrent()) return;
1040
- const menu = defineMenu({
1041
- start: "main",
1042
- screens: {
1043
- main: () => ({
1044
- kind: "actions",
1045
- title: "Langfuse",
1046
- lines: formatMenuTitle(
1047
- activeConfig,
1048
- configPath,
1049
- initializationError,
1050
- configurationNotice
1051
- ).split("\n").slice(1),
1052
- items: [
1053
- ...recorder ? [{ id: "flush", label: FLUSH_ACTION, action: "flush" }] : [],
1054
- {
1055
- id: "configure",
1056
- label: hasStoredConfig ? UPDATE_ACTION : SET_UP_ACTION,
1057
- action: "configure"
1058
- },
1059
- { id: "help", label: HELP_ACTION, action: "help" }
1060
- ],
1061
- hint: "close"
1062
- })
1063
- },
1064
- actions: {
1065
- flush: async () => {
1066
- const menuRecorder = recorder;
1067
- const menuConfig = activeConfig;
1068
- if (!menuRecorder) {
1069
- ctx.ui.notify("Langfuse tracing is not enabled for this session.", "warning");
1070
- return { kind: "close" };
1071
- }
1072
- try {
1073
- await menuRecorder.flush();
1074
- if (menuGeneration !== sessionGeneration) return { kind: "close" };
1075
- ctx.ui.notify("Langfuse traces flushed for this session.", "info");
1076
- } catch (error) {
1077
- if (menuGeneration !== sessionGeneration) return { kind: "close" };
1078
- ctx.ui.notify(`Langfuse flush failed: ${formatError2(error, menuConfig)}`, "error");
1079
- }
1080
- return { kind: "close" };
1081
- },
1082
- configure: async () => {
1083
- const loaded = await loadConfig(configPath);
1084
- if (menuGeneration !== sessionGeneration) return { kind: "close" };
1085
- configPath = loaded.path;
1086
- const next = await promptForConfig(
1087
- ctx,
1088
- loaded.ok ? loaded.config : void 0,
1089
- () => menuGeneration === sessionGeneration
1090
- );
1091
- if (!next || menuGeneration !== sessionGeneration) return { kind: "close" };
1092
- try {
1093
- const write = configWriteQueue.then(() => writeConfig(next, loaded.path));
1094
- configWriteQueue = write.then(
1095
- () => void 0,
1096
- () => void 0
1097
- );
1098
- await write;
1099
- if (menuGeneration !== sessionGeneration) return { kind: "close" };
1100
- hasStoredConfig = true;
1101
- configurationNotice = "Saved; restart each Pi process to use it in subsequent sessions.";
1102
- ctx.ui.notify(
1103
- `Saved Langfuse config to ${loaded.path} for this Pi agent directory. Restart each Pi process to apply it to subsequent sessions.`,
1104
- "info"
1105
- );
1106
- } catch (error) {
1107
- if (menuGeneration !== sessionGeneration) return { kind: "close" };
1108
- ctx.ui.notify(`Failed to save Langfuse config: ${formatError2(error, next)}`, "error");
1109
- }
1110
- return { kind: "close" };
1111
- },
1112
- help: async () => {
1113
- ctx.ui.notify(formatHelp(configPath), "info");
1114
- return { kind: "close" };
1115
- }
1116
- }
1117
- });
1118
- await runMenu(ctx, menu, {
1119
- getState: () => void 0,
1120
- signal: menuSignal,
1121
- isCurrent
964
+ await resolved?.releaseIfStale?.(reason);
965
+ return;
966
+ }
967
+ if (!resolved) {
968
+ options.onSessionUnavailable?.();
969
+ return;
970
+ }
971
+ try {
972
+ const internal = getLangfuseRuntimeInternal(resolved.runtime);
973
+ const recorderOptions = createRecorderOptions(ctx, resolved.options);
974
+ const recorder = new TraceRecorder(internal.backend, recorderOptions);
975
+ const nextBinding = {};
976
+ const releaseRuntime = internal.registerSession((statusMessage) => {
977
+ closeBinding(nextBinding, statusMessage, lastSnapshot);
1122
978
  });
979
+ Object.assign(nextBinding, { registration, recorder, runtime: resolved.runtime, releaseRuntime });
980
+ if (!isCurrent()) {
981
+ releaseRuntime();
982
+ return;
983
+ }
984
+ binding = nextBinding;
985
+ if (options.shutdownRuntimeOnQuit) runtimeForShutdown = resolved.runtime;
986
+ options.onSessionReady?.(resolved);
987
+ } catch (error) {
988
+ if (isCurrent()) options.onInitializationError?.(error, ctx);
1123
989
  }
1124
- pi.registerCommand("langfuse", {
1125
- description: "Open interactive Langfuse tracing controls",
1126
- handler: async (_args, ctx) => showLangfuseMenu(ctx)
1127
- });
990
+ }
991
+ function registerHooks(pi) {
992
+ const registration = {};
993
+ const activeRecorder = () => activeRecorderFor(registration);
1128
994
  pi.on("session_start", async (_event, ctx) => {
1129
- const generation = ++sessionGeneration;
1130
- menuController.abort(new DOMException("Langfuse session replaced", "AbortError"));
1131
- menuController = new AbortController();
1132
- recorder = void 0;
1133
- activeConfig = void 0;
1134
- configPath = void 0;
1135
- initializationError = void 0;
1136
- hasStoredConfig = false;
1137
- configurationNotice = void 0;
1138
- nextAttemptReason = void 0;
1139
- await configWriteQueue;
1140
- if (generation !== sessionGeneration) return;
1141
- const result = await loadConfig();
1142
- if (generation !== sessionGeneration) return;
1143
- configPath = result.path;
1144
- for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
1145
- if (!result.ok) {
1146
- initializationError = formatConfigError(result);
1147
- ctx.ui.notify(initializationError, "warning");
995
+ if (disposed) return;
996
+ pendingProviderGeneration = void 0;
997
+ if (ownerRegistration && ownerRegistration !== registration) {
998
+ options.onInitializationError?.(
999
+ new Error("A Pi Langfuse session controller cannot manage multiple active Pi sessions."),
1000
+ ctx
1001
+ );
1148
1002
  return;
1149
1003
  }
1150
- hasStoredConfig = true;
1004
+ invalidatePendingInitialization("replaced");
1005
+ ownerRegistration = registration;
1006
+ const initialization = createPendingInitialization();
1007
+ pendingInitialization = initialization;
1008
+ let result = { ok: true };
1151
1009
  try {
1152
- const backend = await createBackend(result.config);
1153
- if (generation !== sessionGeneration) {
1154
- await backend.shutdown().catch(() => void 0);
1155
- return;
1156
- }
1157
- activeConfig = result.config;
1158
- recorder = new TraceRecorder(backend, {
1159
- sessionId: ctx.sessionManager.getSessionId(),
1160
- ...result.config.userId ? { userId: result.config.userId } : {},
1161
- cwd: ctx.cwd,
1162
- mode: ctx.mode,
1163
- captureContent: result.config.captureContent
1164
- });
1010
+ await initializeSession(registration, initialization, ctx);
1165
1011
  } catch (error) {
1166
- if (generation !== sessionGeneration) return;
1167
- initializationError = `Langfuse tracing could not start: ${formatError2(error, result.config)}`;
1168
- ctx.ui.notify(initializationError, "warning");
1012
+ result = { ok: false, error };
1013
+ throw error;
1014
+ } finally {
1015
+ initialization.complete(result);
1016
+ outstandingInitializations.delete(initialization);
1017
+ if (pendingInitialization === initialization) pendingInitialization = void 0;
1169
1018
  }
1170
1019
  });
1171
1020
  pi.on("before_agent_start", async (event, ctx) => {
1021
+ pendingProviderGeneration = void 0;
1022
+ const active = ownerRegistration === registration ? binding : void 0;
1023
+ if (!active || active.runtime.closed) return;
1172
1024
  nextAttemptReason = void 0;
1173
- const activeRecorder = recorder;
1174
- if (!activeRecorder) return;
1175
- const git = await resolveGit(ctx.cwd).catch(() => void 0);
1176
- if (recorder !== activeRecorder) return;
1177
- activeRecorder.beginAgent({
1178
- prompt: event.prompt,
1179
- images: event.images,
1180
- model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : void 0,
1181
- git,
1182
- snapshot: contextSnapshot(ctx)
1025
+ const git = await (options.resolveGitMetadata ? options.resolveGitMetadata(pi, ctx.cwd) : resolveGitMetadata((command, args, execOptions) => pi.exec(command, args, execOptions), ctx.cwd)).catch(() => void 0);
1026
+ if (ownerRegistration !== registration || binding !== active || active.runtime.closed) return;
1027
+ lastSnapshot = contextSnapshot(ctx);
1028
+ startRootTrace((nextRequestId) => {
1029
+ active.recorder.beginAgent({
1030
+ prompt: event.prompt,
1031
+ images: event.images,
1032
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : void 0,
1033
+ git,
1034
+ snapshot: lastSnapshot,
1035
+ ...nextRequestId ? { requestId: nextRequestId } : {}
1036
+ });
1183
1037
  });
1184
1038
  });
1185
1039
  pi.on("agent_start", () => {
1040
+ const recorder = activeRecorder();
1186
1041
  if (!recorder) return;
1187
1042
  recorder.beginAttempt(nextAttemptReason ? { reason: nextAttemptReason } : void 0);
1188
1043
  nextAttemptReason = void 0;
1189
1044
  });
1190
1045
  pi.on("turn_start", (event, ctx) => {
1046
+ const recorder = activeRecorder();
1191
1047
  if (!recorder) return;
1192
- ensureActiveRun(recorder, ctx);
1048
+ lastSnapshot = contextSnapshot(ctx);
1049
+ ensureActiveRun(recorder, ctx, startRootTrace);
1193
1050
  recorder.beginTurn(event.turnIndex);
1194
1051
  });
1195
1052
  pi.on("before_provider_request", (event, ctx) => {
1196
- if (!recorder) return;
1197
- ensureActiveRun(recorder, ctx);
1198
- recorder.beginGeneration({
1053
+ if (!activeRecorder()) return;
1054
+ lastSnapshot = contextSnapshot(ctx);
1055
+ pendingProviderGeneration = {
1056
+ startedAt: Date.now(),
1199
1057
  payload: event.payload,
1200
- payloadStage: "before_provider_request",
1201
1058
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : void 0,
1202
- thinkingLevel: pi.getThinkingLevel()
1203
- });
1059
+ thinkingLevel: pi.getThinkingLevel(),
1060
+ responses: []
1061
+ };
1204
1062
  });
1205
1063
  pi.on("after_provider_response", (event) => {
1206
- recorder?.recordProviderResponse(event.status, event.headers);
1064
+ pendingProviderGeneration?.responses.push({ status: event.status, headers: event.headers });
1065
+ });
1066
+ pi.on("message_start", (event, ctx) => {
1067
+ if (event.message.role === "assistant") claimPendingProviderGeneration(ctx, activeRecorder());
1207
1068
  });
1208
- pi.on("message_update", (event) => {
1209
- if (isRealOutputDelta(event.assistantMessageEvent)) recorder?.markGenerationFirstOutput();
1069
+ pi.on("message_update", (event, ctx) => {
1070
+ if (!isRealOutputDelta(event.assistantMessageEvent)) return;
1071
+ const recorder = activeRecorder();
1072
+ claimPendingProviderGeneration(ctx, recorder);
1073
+ recorder?.markGenerationFirstOutput();
1210
1074
  });
1211
- pi.on("message_end", (event) => {
1212
- if (event.message.role === "assistant") recorder?.markGenerationEnd();
1075
+ pi.on("message_end", (event, ctx) => {
1076
+ if (event.message.role !== "assistant") return;
1077
+ const recorder = activeRecorder();
1078
+ claimPendingProviderGeneration(ctx, recorder);
1079
+ recorder?.markGenerationEnd();
1213
1080
  });
1214
- pi.on("turn_end", (event) => {
1215
- if (event.message.role === "assistant") recorder?.finishAssistant(event.message);
1216
- recorder?.finishTurn(event.turnIndex, {
1081
+ pi.on("turn_end", (event, ctx) => {
1082
+ const recorder = activeRecorder();
1083
+ if (!recorder) return;
1084
+ if (event.message.role === "assistant") claimPendingProviderGeneration(ctx, recorder);
1085
+ lastSnapshot = contextSnapshot(ctx);
1086
+ if (event.message.role === "assistant") recorder.finishAssistant(event.message);
1087
+ recorder.finishTurn(event.turnIndex, {
1217
1088
  message: event.message,
1218
1089
  toolResultCount: event.toolResults.length
1219
1090
  });
1220
1091
  });
1221
1092
  pi.on("tool_execution_start", (event) => {
1222
- recorder?.beginTool(event.toolCallId, event.toolName, event.args);
1093
+ activeRecorder()?.beginTool(event.toolCallId, event.toolName, event.args);
1223
1094
  });
1224
1095
  pi.on("tool_execution_update", (event) => {
1225
- recorder?.recordToolProgress(event.toolCallId);
1096
+ activeRecorder()?.recordToolProgress(event.toolCallId);
1226
1097
  });
1227
1098
  pi.on("tool_result", (event) => {
1228
- recorder?.recordToolInput(event.toolCallId, event.input);
1099
+ activeRecorder()?.recordToolInput(event.toolCallId, event.input);
1229
1100
  });
1230
1101
  pi.on("tool_execution_end", (event) => {
1231
- recorder?.finishTool(event.toolCallId, {
1102
+ activeRecorder()?.finishTool(event.toolCallId, {
1232
1103
  content: event.result.content,
1233
1104
  details: event.result.details,
1234
1105
  isError: event.isError
@@ -1236,10 +1107,10 @@ function createLangfuseExtension(dependencies = {}) {
1236
1107
  });
1237
1108
  pi.on("agent_end", (event) => {
1238
1109
  const message = findLastAssistant(event.messages);
1239
- recorder?.finishAttempt(message);
1110
+ activeRecorder()?.finishAttempt(message);
1240
1111
  });
1241
1112
  pi.on("session_before_compact", (event) => {
1242
- recorder?.beginCompaction({
1113
+ activeRecorder()?.beginCompaction({
1243
1114
  reason: event.reason,
1244
1115
  willRetry: event.willRetry,
1245
1116
  tokensBefore: event.preparation.tokensBefore,
@@ -1250,6 +1121,7 @@ function createLangfuseExtension(dependencies = {}) {
1250
1121
  });
1251
1122
  });
1252
1123
  pi.on("session_compact", (event) => {
1124
+ const recorder = activeRecorder();
1253
1125
  const entry = event.compactionEntry;
1254
1126
  recorder?.finishCompaction({
1255
1127
  reason: event.reason,
@@ -1262,45 +1134,102 @@ function createLangfuseExtension(dependencies = {}) {
1262
1134
  if (event.willRetry && recorder?.hasActiveTrace()) nextAttemptReason = "post_compaction";
1263
1135
  });
1264
1136
  pi.on("agent_settled", (_event, ctx) => {
1265
- recorder?.settle(contextSnapshot(ctx));
1137
+ const recorder = activeRecorder();
1138
+ if (!recorder) return;
1139
+ lastSnapshot = contextSnapshot(ctx);
1140
+ recorder.settle(lastSnapshot);
1141
+ pendingProviderGeneration = void 0;
1266
1142
  nextAttemptReason = void 0;
1267
1143
  });
1268
1144
  pi.on("session_shutdown", async (event, ctx) => {
1145
+ if (ownerRegistration !== registration) return;
1146
+ const initializations = invalidateAllInitializations(event.reason);
1147
+ ownerRegistration = void 0;
1269
1148
  const generation = ++sessionGeneration;
1270
- menuController.abort(new DOMException("Langfuse session shut down", "AbortError"));
1271
- const activeRecorder = recorder;
1272
- const shutdownConfig = activeConfig;
1273
- const pendingConfigWrite = configWriteQueue;
1274
- const shutdownSnapshot = activeRecorder ? contextSnapshot(ctx) : void 0;
1275
- recorder = void 0;
1276
- activeConfig = void 0;
1277
- await pendingConfigWrite;
1278
- if (!activeRecorder) return;
1149
+ options.onSessionShutdown?.();
1150
+ const active = binding?.registration === registration ? binding : void 0;
1151
+ const runtime = active?.runtime ?? runtimeForShutdown;
1152
+ lastSnapshot = active ? contextSnapshot(ctx) : lastSnapshot;
1153
+ closeBinding(
1154
+ active,
1155
+ event.reason === "quit" ? "Pi shut down before the active trace settled." : `Pi session ended before settlement (${event.reason}).`,
1156
+ lastSnapshot
1157
+ );
1158
+ let beforeDisposeFailure;
1279
1159
  try {
1280
- if (event.reason === "quit") await activeRecorder.shutdown(shutdownSnapshot);
1281
- else {
1282
- activeRecorder.interrupt(
1283
- `Pi session ended before settlement (${event.reason}).`,
1284
- shutdownSnapshot
1285
- );
1286
- await activeRecorder.flush();
1160
+ await options.beforeSessionDispose?.();
1161
+ } catch (error) {
1162
+ beforeDisposeFailure = { error };
1163
+ }
1164
+ try {
1165
+ await joinInitializations(initializations);
1166
+ if (disposed || generation !== sessionGeneration) return;
1167
+ if (beforeDisposeFailure) throw beforeDisposeFailure.error;
1168
+ if (runtime) {
1169
+ if (event.reason === "quit" && options.shutdownRuntimeOnQuit) await runtime.shutdown();
1170
+ else if (options.flushOnReplacement) await runtime.flush();
1287
1171
  }
1288
1172
  } catch (error) {
1289
- if (generation !== sessionGeneration) return;
1290
- ctx.ui.notify(
1291
- `Langfuse shutdown export failed: ${formatError2(error, shutdownConfig)}`,
1292
- "error"
1293
- );
1173
+ if (!disposed && generation === sessionGeneration) options.onShutdownError?.(error, ctx);
1294
1174
  }
1295
1175
  });
1176
+ }
1177
+ function claimPendingProviderGeneration(ctx, recorder) {
1178
+ const pending = pendingProviderGeneration;
1179
+ if (!pending || !recorder) return;
1180
+ pendingProviderGeneration = void 0;
1181
+ ensureActiveRun(recorder, ctx, startRootTrace);
1182
+ recorder.beginGeneration({
1183
+ startedAt: pending.startedAt,
1184
+ payload: pending.payload,
1185
+ payloadStage: "before_provider_request",
1186
+ model: pending.model,
1187
+ thinkingLevel: pending.thinkingLevel
1188
+ });
1189
+ for (const response of pending.responses) recorder.recordProviderResponse(response.status, response.headers);
1190
+ }
1191
+ function startRootTrace(start) {
1192
+ const revision = requestIdRevision;
1193
+ const nextRequestId = requestId;
1194
+ start(nextRequestId);
1195
+ if (requestIdRevision === revision) requestId = void 0;
1196
+ }
1197
+ function activeRecorderFor(registration) {
1198
+ return ownerRegistration === registration && binding?.registration === registration && !binding.runtime.closed ? binding.recorder : void 0;
1199
+ }
1200
+ return controller;
1201
+ }
1202
+ function createRecorderOptions(ctx, options = {}) {
1203
+ const userId = normalizeUserId(options.userId);
1204
+ return {
1205
+ sessionId: normalizeOptionalString(options.sessionId) ?? ctx.sessionManager.getSessionId(),
1206
+ ...userId ? { userId } : {},
1207
+ cwd: ctx.cwd,
1208
+ mode: ctx.mode,
1209
+ captureContent: options.captureContent ?? true,
1210
+ ...normalizeOptionalString(options.traceName) ? { traceName: normalizeOptionalString(options.traceName) } : {},
1211
+ ...options.tags ? { tags: [...options.tags] } : {},
1212
+ ...options.metadata ? { metadata: { ...options.metadata } } : {},
1213
+ ...options.onTraceId ? { onTraceId: options.onTraceId } : {}
1296
1214
  };
1297
1215
  }
1298
- function ensureActiveRun(recorder, ctx) {
1216
+ function snapshotSessionOptions(options) {
1217
+ normalizeUserId(options.userId);
1218
+ return {
1219
+ ...options,
1220
+ ...options.tags ? { tags: [...options.tags] } : {},
1221
+ ...options.metadata ? { metadata: { ...options.metadata } } : {}
1222
+ };
1223
+ }
1224
+ function ensureActiveRun(recorder, ctx, startRootTrace) {
1299
1225
  if (!recorder.hasActiveTrace()) {
1300
- recorder.beginAgent({
1301
- prompt: "[automatic continuation]",
1302
- model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : void 0,
1303
- snapshot: contextSnapshot(ctx)
1226
+ startRootTrace((nextRequestId) => {
1227
+ recorder.beginAgent({
1228
+ prompt: "[automatic continuation]",
1229
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : void 0,
1230
+ snapshot: contextSnapshot(ctx),
1231
+ ...nextRequestId ? { requestId: nextRequestId } : {}
1232
+ });
1304
1233
  });
1305
1234
  }
1306
1235
  if (!recorder.hasActiveAttempt()) recorder.beginAttempt();
@@ -1321,59 +1250,200 @@ function findLastAssistant(messages) {
1321
1250
  function isRealOutputDelta(event) {
1322
1251
  return (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") && typeof event.delta === "string" && event.delta.length > 0;
1323
1252
  }
1324
- var GIT_LOOKUP_TIMEOUT_MS = 1e3;
1325
- var MAX_GIT_BRANCH_LENGTH = 256;
1326
- async function resolveGitMetadata(exec, cwd) {
1327
- const [branchResult, commit] = await Promise.all([
1328
- resolveGitBranch(exec, cwd),
1329
- resolveGitCommit(exec, cwd)
1330
- ]);
1331
- if (!branchResult.resolved) return void 0;
1332
- if (branchResult.branch) {
1333
- return {
1334
- branch: branchResult.branch,
1335
- ...commit ? { commit } : {},
1336
- detached: false
1337
- };
1338
- }
1339
- return commit ? { commit, detached: true } : void 0;
1253
+ function normalizeUserId(value) {
1254
+ const userId = normalizeOptionalString(value);
1255
+ if (userId && userId.length > 200) throw new Error("Langfuse userId must be at most 200 characters.");
1256
+ return userId;
1340
1257
  }
1341
- async function resolveGitBranch(exec, cwd) {
1342
- try {
1343
- const result = await exec("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], {
1344
- cwd,
1345
- timeout: GIT_LOOKUP_TIMEOUT_MS
1346
- });
1347
- if (result.killed) return { resolved: false };
1348
- if (result.code === 1) return { resolved: true };
1349
- if (result.code !== 0) return { resolved: false };
1350
- const branch = normalizeGitBranch(result.stdout);
1351
- return branch ? { resolved: true, branch } : { resolved: false };
1352
- } catch {
1353
- return { resolved: false };
1354
- }
1258
+ function normalizeOptionalString(value) {
1259
+ if (typeof value !== "string" || !value.trim()) return void 0;
1260
+ const sanitized = sanitizeTraceValue(value.trim());
1261
+ return typeof sanitized === "string" && sanitized.trim() ? sanitized.trim() : void 0;
1355
1262
  }
1356
- async function resolveGitCommit(exec, cwd) {
1357
- try {
1358
- const result = await exec("git", ["rev-parse", "--verify", "--short=12", "HEAD"], {
1359
- cwd,
1360
- timeout: GIT_LOOKUP_TIMEOUT_MS
1263
+
1264
+ // src/langfuse.ts
1265
+ var FLUSH_ACTION = "Flush completed traces for this session";
1266
+ var SET_UP_ACTION = "Set up Langfuse for this Pi agent directory (restart required)";
1267
+ var UPDATE_ACTION = "Update Langfuse for this Pi agent directory (restart required)";
1268
+ var HELP_ACTION = "Show setup and privacy help";
1269
+ function createLangfuseExtension(dependencies = {}) {
1270
+ const loadConfig = dependencies.loadConfig ?? loadLangfuseConfig;
1271
+ const writeConfig = dependencies.writeConfig ?? writeLangfuseConfig;
1272
+ const resolveInjectedGitMetadata = dependencies.resolveGitMetadata;
1273
+ return function langfuse(pi) {
1274
+ let activeConfig;
1275
+ let loadingConfig;
1276
+ let runtimeConfigForShutdown;
1277
+ let shutdownConfig;
1278
+ let configPath;
1279
+ let initializationError;
1280
+ let hasStoredConfig = false;
1281
+ let configurationNotice;
1282
+ let sessionGeneration = 0;
1283
+ let menuController = new AbortController();
1284
+ let configWriteQueue = Promise.resolve();
1285
+ const tracing = createPiLangfuseSessionController({
1286
+ onSessionStart() {
1287
+ sessionGeneration += 1;
1288
+ menuController.abort(new DOMException("Langfuse session replaced", "AbortError"));
1289
+ menuController = new AbortController();
1290
+ activeConfig = void 0;
1291
+ loadingConfig = void 0;
1292
+ shutdownConfig = void 0;
1293
+ configPath = void 0;
1294
+ initializationError = void 0;
1295
+ hasStoredConfig = false;
1296
+ configurationNotice = void 0;
1297
+ },
1298
+ onSessionShutdown() {
1299
+ sessionGeneration += 1;
1300
+ menuController.abort(new DOMException("Langfuse session shut down", "AbortError"));
1301
+ shutdownConfig = activeConfig ?? runtimeConfigForShutdown ?? loadingConfig;
1302
+ activeConfig = void 0;
1303
+ },
1304
+ async resolveSession(ctx, isCurrent) {
1305
+ await configWriteQueue;
1306
+ if (!isCurrent()) return void 0;
1307
+ const result = await loadConfig();
1308
+ if (!isCurrent()) return void 0;
1309
+ configPath = result.path;
1310
+ for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
1311
+ if (!result.ok) {
1312
+ initializationError = formatConfigError(result);
1313
+ ctx.ui.notify(initializationError, "warning");
1314
+ return void 0;
1315
+ }
1316
+ hasStoredConfig = true;
1317
+ loadingConfig = result.config;
1318
+ const createBackend = dependencies.createBackend;
1319
+ const ownsRuntime = createBackend !== void 0;
1320
+ const runtime = createBackend ? createLangfuseRuntimeFromBackend(await createBackend(result.config)) : await (await import("./chunks/runtime-6MIKLWNJ.js")).createLangfuseRuntime({ config: result.config, env: false });
1321
+ if (isCurrent() || !runtimeConfigForShutdown) runtimeConfigForShutdown = result.config;
1322
+ if (isCurrent()) {
1323
+ activeConfig = result.config;
1324
+ loadingConfig = void 0;
1325
+ }
1326
+ return {
1327
+ runtime,
1328
+ releaseIfStale: async (reason) => {
1329
+ if (ownsRuntime || reason === "quit") await runtime.shutdown();
1330
+ },
1331
+ options: {
1332
+ ...result.config.userId ? { userId: result.config.userId } : {},
1333
+ captureContent: result.config.captureContent
1334
+ }
1335
+ };
1336
+ },
1337
+ ...resolveInjectedGitMetadata ? { resolveGitMetadata: (_pi, cwd) => resolveInjectedGitMetadata(cwd) } : {},
1338
+ onInitializationError(error, ctx) {
1339
+ const config = loadingConfig;
1340
+ loadingConfig = void 0;
1341
+ initializationError = `Langfuse tracing could not start: ${formatError(error, config)}`;
1342
+ ctx.ui.notify(initializationError, "warning");
1343
+ },
1344
+ beforeSessionDispose: async () => {
1345
+ await configWriteQueue;
1346
+ },
1347
+ onShutdownError(error, ctx) {
1348
+ ctx.ui.notify(`Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`, "error");
1349
+ },
1350
+ flushOnReplacement: true,
1351
+ shutdownRuntimeOnQuit: true
1361
1352
  });
1362
- if (result.code !== 0 || result.killed) return void 0;
1363
- const commit = result.stdout.trim();
1364
- return /^[0-9a-f]{4,64}$/iu.test(commit) ? commit.toLowerCase() : void 0;
1365
- } catch {
1366
- return void 0;
1367
- }
1368
- }
1369
- function normalizeGitBranch(value) {
1370
- const branch = value.trim();
1371
- if (!branch || branch.length > MAX_GIT_BRANCH_LENGTH) return void 0;
1372
- for (const character of branch) {
1373
- const code = character.codePointAt(0) ?? 0;
1374
- if (code <= 31 || code === 127) return void 0;
1375
- }
1376
- return branch;
1353
+ async function showLangfuseMenu(ctx) {
1354
+ if (!ctx.hasUI) {
1355
+ ctx.ui.notify(formatNonInteractiveStatus(activeConfig, configPath, initializationError), "warning");
1356
+ return;
1357
+ }
1358
+ const menuGeneration = sessionGeneration;
1359
+ const menuSignal = menuController.signal;
1360
+ const isCurrent = () => menuGeneration === sessionGeneration && !menuSignal.aborted;
1361
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
1362
+ if (!isCurrent()) return;
1363
+ const menu = defineMenu({
1364
+ start: "main",
1365
+ screens: {
1366
+ main: () => ({
1367
+ kind: "actions",
1368
+ title: "Langfuse",
1369
+ lines: formatMenuTitle(activeConfig, configPath, initializationError, configurationNotice).split("\n").slice(1),
1370
+ items: [
1371
+ ...tracing.active ? [{ id: "flush", label: FLUSH_ACTION, action: "flush" }] : [],
1372
+ {
1373
+ id: "configure",
1374
+ label: hasStoredConfig ? UPDATE_ACTION : SET_UP_ACTION,
1375
+ action: "configure"
1376
+ },
1377
+ { id: "help", label: HELP_ACTION, action: "help" }
1378
+ ],
1379
+ hint: "close"
1380
+ })
1381
+ },
1382
+ actions: {
1383
+ flush: async () => {
1384
+ const menuConfig = activeConfig;
1385
+ if (!tracing.active) {
1386
+ ctx.ui.notify("Langfuse tracing is not enabled for this session.", "warning");
1387
+ return { kind: "close" };
1388
+ }
1389
+ try {
1390
+ await tracing.flush();
1391
+ if (menuGeneration !== sessionGeneration) return { kind: "close" };
1392
+ ctx.ui.notify("Langfuse traces flushed for this session.", "info");
1393
+ } catch (error) {
1394
+ if (menuGeneration !== sessionGeneration) return { kind: "close" };
1395
+ ctx.ui.notify(`Langfuse flush failed: ${formatError(error, menuConfig)}`, "error");
1396
+ }
1397
+ return { kind: "close" };
1398
+ },
1399
+ configure: async () => {
1400
+ const loaded = await loadConfig(configPath);
1401
+ if (menuGeneration !== sessionGeneration) return { kind: "close" };
1402
+ configPath = loaded.path;
1403
+ const next = await promptForConfig(
1404
+ ctx,
1405
+ loaded.ok ? loaded.config : void 0,
1406
+ () => menuGeneration === sessionGeneration
1407
+ );
1408
+ if (!next || menuGeneration !== sessionGeneration) return { kind: "close" };
1409
+ try {
1410
+ const write = configWriteQueue.then(() => writeConfig(next, loaded.path));
1411
+ configWriteQueue = write.then(
1412
+ () => void 0,
1413
+ () => void 0
1414
+ );
1415
+ await write;
1416
+ if (menuGeneration !== sessionGeneration) return { kind: "close" };
1417
+ hasStoredConfig = true;
1418
+ configurationNotice = "Saved; restart each Pi process to use it in subsequent sessions.";
1419
+ ctx.ui.notify(
1420
+ `Saved Langfuse config to ${loaded.path} for this Pi agent directory. Restart each Pi process to apply it to subsequent sessions.`,
1421
+ "info"
1422
+ );
1423
+ } catch (error) {
1424
+ if (menuGeneration !== sessionGeneration) return { kind: "close" };
1425
+ ctx.ui.notify(`Failed to save Langfuse config: ${formatError(error, next)}`, "error");
1426
+ }
1427
+ return { kind: "close" };
1428
+ },
1429
+ help: async () => {
1430
+ ctx.ui.notify(formatHelp(configPath), "info");
1431
+ return { kind: "close" };
1432
+ }
1433
+ }
1434
+ });
1435
+ await runMenu(ctx, menu, {
1436
+ getState: () => void 0,
1437
+ signal: menuSignal,
1438
+ isCurrent
1439
+ });
1440
+ }
1441
+ pi.registerCommand("langfuse", {
1442
+ description: "Open interactive Langfuse tracing controls",
1443
+ handler: async (_args, ctx) => showLangfuseMenu(ctx)
1444
+ });
1445
+ tracing.extension(pi);
1446
+ };
1377
1447
  }
1378
1448
  async function promptForConfig(ctx, current, isCurrent) {
1379
1449
  const secretKey = await ctx.ui.input("Langfuse secret key (leave blank to keep existing):");
@@ -1410,12 +1480,7 @@ async function promptForConfig(ctx, current, isCurrent) {
1410
1480
  return patch;
1411
1481
  }
1412
1482
  function formatMenuTitle(activeConfig, configPath, initializationError, configurationNotice) {
1413
- const lines = [
1414
- "Langfuse",
1415
- "",
1416
- "Current session:",
1417
- ` Tracing: ${activeConfig ? "enabled" : "disabled"}`
1418
- ];
1483
+ const lines = ["Langfuse", "", "Current session:", ` Tracing: ${activeConfig ? "enabled" : "disabled"}`];
1419
1484
  if (activeConfig) {
1420
1485
  lines.push(
1421
1486
  ` Endpoint: ${activeConfig.baseUrl}`,
@@ -1463,7 +1528,7 @@ function formatConfigError(result) {
1463
1528
  const setupHint = result.reason.startsWith("Configuration file not found:") ? " Run /langfuse and choose Set up Langfuse." : "";
1464
1529
  return `Langfuse tracing is disabled: ${result.reason}${setupHint}`;
1465
1530
  }
1466
- function formatError2(error, config) {
1531
+ function formatError(error, config) {
1467
1532
  let message = error instanceof Error ? error.message : String(error);
1468
1533
  const secrets = [config?.publicKey, config?.secretKey].filter((secret) => Boolean(secret)).sort((left, right) => right.length - left.length);
1469
1534
  for (const secret of secrets) {
@@ -1472,7 +1537,15 @@ function formatError2(error, config) {
1472
1537
  return message;
1473
1538
  }
1474
1539
  var langfuse_default = createLangfuseExtension();
1540
+
1541
+ // src/public-api.ts
1542
+ async function createLangfuseRuntime(options = {}) {
1543
+ const runtime = await import("./chunks/runtime-6MIKLWNJ.js");
1544
+ return runtime.createLangfuseRuntime(options);
1545
+ }
1475
1546
  export {
1547
+ createLangfuseRuntime,
1548
+ createPiLangfuseSession,
1476
1549
  langfuse_default as default
1477
1550
  };
1478
1551
  //# sourceMappingURL=index.ts.map