@narumitw/pi-langfuse 0.31.0 → 0.35.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/README.md +1 -1
- package/package.json +1 -1
- package/src/config.ts +76 -9
- package/src/langfuse.ts +42 -17
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Run the interactive manager, then choose **Set up Langfuse for this Pi agent dir
|
|
|
48
48
|
/langfuse
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
The setup flow prompts for the secret key, public key, and base URL in the same order Langfuse presents them. Leave either key blank to preserve its existing value when updating a valid config. Leave the base URL blank to use `https://us.cloud.langfuse.com`. The file
|
|
51
|
+
The setup flow prompts for the secret key, public key, and base URL in the same order Langfuse presents them. Leave either key blank to preserve its existing value when updating a valid config. Leave the base URL blank to use `https://us.cloud.langfuse.com`. The first successful setup creates the file. Within one Pi process, updates run in invocation order, reread the latest valid private document, preserve unknown fields, and save atomically with mode `0600`. Malformed JSON or invalid recognized fields block setup and update writes until repaired; errors redact entered and stored credentials. A failed save leaves the current session and prior file unchanged.
|
|
52
52
|
|
|
53
53
|
Configuration belongs to the displayed Pi agent directory, not just the current conversation. Restart each running Pi process after saving; the new connection applies to subsequent sessions in that process. `/reload` is not sufficient because the isolated Langfuse runtime is initialized once per process. In print or JSON mode, edit the file manually because the interactive manager is unavailable.
|
|
54
54
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
const CONFIG_FILE_NAME = "pi-langfuse.json";
|
|
7
7
|
export const DEFAULT_BASE_URL = "https://us.cloud.langfuse.com";
|
|
@@ -15,31 +15,66 @@ export interface LangfuseConfig {
|
|
|
15
15
|
captureContent: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type LangfuseConfigPatch = Partial<LangfuseConfig>;
|
|
19
|
+
|
|
18
20
|
export type LangfuseConfigResult =
|
|
19
21
|
| { ok: true; config: LangfuseConfig; path: string; warnings: string[] }
|
|
20
22
|
| { ok: false; path: string; warnings: string[]; reason: string };
|
|
21
23
|
|
|
22
24
|
export function langfuseConfigPath(): string {
|
|
23
|
-
return join(
|
|
25
|
+
return join(getAgentDir(), CONFIG_FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const configSaveQueues = new Map<string, Promise<void>>();
|
|
29
|
+
|
|
30
|
+
export async function waitForLangfuseConfigWrites(path = langfuseConfigPath()): Promise<void> {
|
|
31
|
+
await configSaveQueues.get(path);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
export
|
|
27
|
-
|
|
34
|
+
export function writeLangfuseConfig(
|
|
35
|
+
patch: LangfuseConfigPatch,
|
|
28
36
|
path = langfuseConfigPath(),
|
|
29
37
|
): Promise<LangfuseConfig> {
|
|
30
|
-
const
|
|
38
|
+
const previous = configSaveQueues.get(path) ?? Promise.resolve();
|
|
39
|
+
const operation = previous.then(() => writeLangfuseConfigNow(patch, path));
|
|
40
|
+
const tail = operation.then(
|
|
41
|
+
() => undefined,
|
|
42
|
+
() => undefined,
|
|
43
|
+
);
|
|
44
|
+
configSaveQueues.set(path, tail);
|
|
45
|
+
void tail.then(() => {
|
|
46
|
+
if (configSaveQueues.get(path) === tail) configSaveQueues.delete(path);
|
|
47
|
+
});
|
|
48
|
+
return operation;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function writeLangfuseConfigNow(
|
|
52
|
+
patch: LangfuseConfigPatch,
|
|
53
|
+
path: string,
|
|
54
|
+
): Promise<LangfuseConfig> {
|
|
55
|
+
const currentDocument = await readLangfuseDocumentForUpdate(path);
|
|
56
|
+
const normalized = normalizeLangfuseConfig({ ...currentDocument, ...patch });
|
|
31
57
|
if (!normalized.ok) throw new Error(normalized.reason);
|
|
58
|
+
const {
|
|
59
|
+
publicKey: _publicKey,
|
|
60
|
+
secretKey: _secretKey,
|
|
61
|
+
baseUrl: _baseUrl,
|
|
62
|
+
environment: _environment,
|
|
63
|
+
release: _release,
|
|
64
|
+
captureContent: _captureContent,
|
|
65
|
+
...unknownFields
|
|
66
|
+
} = currentDocument;
|
|
67
|
+
const nextDocument = { ...unknownFields, ...normalized.config };
|
|
32
68
|
|
|
33
69
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
70
|
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
35
71
|
try {
|
|
36
|
-
await writeFile(tempPath, `${JSON.stringify(
|
|
72
|
+
await writeFile(tempPath, `${JSON.stringify(nextDocument, null, 2)}\n`, {
|
|
37
73
|
encoding: "utf8",
|
|
38
74
|
mode: 0o600,
|
|
39
75
|
});
|
|
40
76
|
await chmod(tempPath, 0o600);
|
|
41
77
|
await rename(tempPath, path);
|
|
42
|
-
await chmod(path, 0o600);
|
|
43
78
|
return normalized.config;
|
|
44
79
|
} catch (error) {
|
|
45
80
|
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
@@ -47,9 +82,29 @@ export async function writeLangfuseConfig(
|
|
|
47
82
|
}
|
|
48
83
|
}
|
|
49
84
|
|
|
85
|
+
async function readLangfuseDocumentForUpdate(path: string): Promise<Record<string, unknown>> {
|
|
86
|
+
let parsed: unknown;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (isNodeError(error) && error.code === "ENOENT") return {};
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Cannot update ${CONFIG_FILE_NAME} because the existing file is malformed or unreadable; repair it first.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const normalized = normalizeLangfuseConfig(parsed);
|
|
96
|
+
if (!normalized.ok) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Cannot update ${CONFIG_FILE_NAME} because the existing file has invalid recognized fields; repair it first.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return { ...(parsed as Record<string, unknown>) };
|
|
102
|
+
}
|
|
103
|
+
|
|
50
104
|
export async function loadLangfuseConfig(
|
|
51
105
|
path = langfuseConfigPath(),
|
|
52
106
|
): Promise<LangfuseConfigResult> {
|
|
107
|
+
await configSaveQueues.get(path);
|
|
53
108
|
const warnings: string[] = [];
|
|
54
109
|
const permissionFailure = await ensurePrivatePermissions(path, warnings);
|
|
55
110
|
if (permissionFailure) {
|
|
@@ -61,9 +116,9 @@ export async function loadLangfuseConfig(
|
|
|
61
116
|
};
|
|
62
117
|
}
|
|
63
118
|
|
|
64
|
-
let
|
|
119
|
+
let text: string;
|
|
65
120
|
try {
|
|
66
|
-
|
|
121
|
+
text = await readFile(path, "utf8");
|
|
67
122
|
} catch (error) {
|
|
68
123
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
69
124
|
return { ok: false, path, warnings, reason: `Configuration file not found: ${path}` };
|
|
@@ -76,6 +131,18 @@ export async function loadLangfuseConfig(
|
|
|
76
131
|
};
|
|
77
132
|
}
|
|
78
133
|
|
|
134
|
+
let parsed: unknown;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(text) as unknown;
|
|
137
|
+
} catch {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
path,
|
|
141
|
+
warnings,
|
|
142
|
+
reason: `Failed to parse ${path} as JSON; repair the file before retrying.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
79
146
|
const normalized = normalizeLangfuseConfig(parsed);
|
|
80
147
|
if (!normalized.ok) return { ok: false, path, warnings, reason: normalized.reason };
|
|
81
148
|
return { ok: true, config: normalized.config, path, warnings };
|
package/src/langfuse.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import {
|
|
7
7
|
DEFAULT_BASE_URL,
|
|
8
8
|
type LangfuseConfig,
|
|
9
|
+
type LangfuseConfigPatch,
|
|
9
10
|
type LangfuseConfigResult,
|
|
10
11
|
loadLangfuseConfig,
|
|
11
12
|
normalizeLangfuseConfig,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
|
|
21
22
|
interface ExtensionDependencies {
|
|
22
23
|
loadConfig(path?: string): Promise<LangfuseConfigResult>;
|
|
23
|
-
writeConfig(config:
|
|
24
|
+
writeConfig(config: LangfuseConfigPatch, path?: string): Promise<unknown>;
|
|
24
25
|
createBackend(config: LangfuseConfig): Promise<TraceBackend>;
|
|
25
26
|
resolveGitMetadata(cwd: string): Promise<GitMetadata | undefined>;
|
|
26
27
|
}
|
|
@@ -54,6 +55,7 @@ export function createLangfuseExtension(
|
|
|
54
55
|
let hasStoredConfig = false;
|
|
55
56
|
let configurationNotice: string | undefined;
|
|
56
57
|
let sessionGeneration = 0;
|
|
58
|
+
let configWriteQueue = Promise.resolve();
|
|
57
59
|
let nextAttemptReason: string | undefined;
|
|
58
60
|
|
|
59
61
|
pi.registerCommand("langfuse", {
|
|
@@ -100,10 +102,19 @@ export function createLangfuseExtension(
|
|
|
100
102
|
const loaded = await loadConfig(configPath);
|
|
101
103
|
if (menuGeneration !== sessionGeneration) return;
|
|
102
104
|
configPath = loaded.path;
|
|
103
|
-
const next = await promptForConfig(
|
|
105
|
+
const next = await promptForConfig(
|
|
106
|
+
ctx,
|
|
107
|
+
loaded.ok ? loaded.config : undefined,
|
|
108
|
+
() => menuGeneration === sessionGeneration,
|
|
109
|
+
);
|
|
104
110
|
if (!next || menuGeneration !== sessionGeneration) return;
|
|
105
111
|
try {
|
|
106
|
-
|
|
112
|
+
const write = configWriteQueue.then(() => writeConfig(next, loaded.path));
|
|
113
|
+
configWriteQueue = write.then(
|
|
114
|
+
() => undefined,
|
|
115
|
+
() => undefined,
|
|
116
|
+
);
|
|
117
|
+
await write;
|
|
107
118
|
if (menuGeneration !== sessionGeneration) return;
|
|
108
119
|
hasStoredConfig = true;
|
|
109
120
|
configurationNotice =
|
|
@@ -126,7 +137,7 @@ export function createLangfuseExtension(
|
|
|
126
137
|
});
|
|
127
138
|
|
|
128
139
|
pi.on("session_start", async (_event, ctx) => {
|
|
129
|
-
|
|
140
|
+
const generation = ++sessionGeneration;
|
|
130
141
|
recorder = undefined;
|
|
131
142
|
activeConfig = undefined;
|
|
132
143
|
configPath = undefined;
|
|
@@ -135,7 +146,10 @@ export function createLangfuseExtension(
|
|
|
135
146
|
configurationNotice = undefined;
|
|
136
147
|
nextAttemptReason = undefined;
|
|
137
148
|
|
|
149
|
+
await configWriteQueue;
|
|
150
|
+
if (generation !== sessionGeneration) return;
|
|
138
151
|
const result = await loadConfig();
|
|
152
|
+
if (generation !== sessionGeneration) return;
|
|
139
153
|
configPath = result.path;
|
|
140
154
|
for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
|
|
141
155
|
if (!result.ok) {
|
|
@@ -147,6 +161,10 @@ export function createLangfuseExtension(
|
|
|
147
161
|
hasStoredConfig = true;
|
|
148
162
|
try {
|
|
149
163
|
const backend = await createBackend(result.config);
|
|
164
|
+
if (generation !== sessionGeneration) {
|
|
165
|
+
await backend.shutdown().catch(() => undefined);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
150
168
|
activeConfig = result.config;
|
|
151
169
|
recorder = new TraceRecorder(backend, {
|
|
152
170
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
@@ -155,6 +173,7 @@ export function createLangfuseExtension(
|
|
|
155
173
|
captureContent: result.config.captureContent,
|
|
156
174
|
});
|
|
157
175
|
} catch (error) {
|
|
176
|
+
if (generation !== sessionGeneration) return;
|
|
158
177
|
initializationError = `Langfuse tracing could not start: ${formatError(error, result.config)}`;
|
|
159
178
|
ctx.ui.notify(initializationError, "warning");
|
|
160
179
|
}
|
|
@@ -280,23 +299,26 @@ export function createLangfuseExtension(
|
|
|
280
299
|
});
|
|
281
300
|
|
|
282
301
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
283
|
-
|
|
302
|
+
const generation = ++sessionGeneration;
|
|
284
303
|
const activeRecorder = recorder;
|
|
285
304
|
const shutdownConfig = activeConfig;
|
|
305
|
+
const pendingConfigWrite = configWriteQueue;
|
|
306
|
+
const shutdownSnapshot = activeRecorder ? contextSnapshot(ctx) : undefined;
|
|
286
307
|
recorder = undefined;
|
|
287
308
|
activeConfig = undefined;
|
|
309
|
+
await pendingConfigWrite;
|
|
288
310
|
if (!activeRecorder) return;
|
|
289
311
|
try {
|
|
290
|
-
|
|
291
|
-
if (event.reason === "quit") await activeRecorder.shutdown(snapshot);
|
|
312
|
+
if (event.reason === "quit") await activeRecorder.shutdown(shutdownSnapshot);
|
|
292
313
|
else {
|
|
293
314
|
activeRecorder.interrupt(
|
|
294
315
|
`Pi session ended before settlement (${event.reason}).`,
|
|
295
|
-
|
|
316
|
+
shutdownSnapshot,
|
|
296
317
|
);
|
|
297
318
|
await activeRecorder.flush();
|
|
298
319
|
}
|
|
299
320
|
} catch (error) {
|
|
321
|
+
if (generation !== sessionGeneration) return;
|
|
300
322
|
ctx.ui.notify(
|
|
301
323
|
`Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`,
|
|
302
324
|
"error",
|
|
@@ -417,13 +439,16 @@ function normalizeGitBranch(value: string): string | undefined {
|
|
|
417
439
|
async function promptForConfig(
|
|
418
440
|
ctx: ExtensionCommandContext,
|
|
419
441
|
current: LangfuseConfig | undefined,
|
|
420
|
-
|
|
442
|
+
isCurrent: () => boolean,
|
|
443
|
+
): Promise<LangfuseConfigPatch | undefined> {
|
|
421
444
|
const secretKey = await ctx.ui.input("Langfuse secret key (leave blank to keep existing):");
|
|
445
|
+
if (!isCurrent()) return undefined;
|
|
422
446
|
if (secretKey === undefined) {
|
|
423
447
|
ctx.ui.notify("Cancelled", "info");
|
|
424
448
|
return undefined;
|
|
425
449
|
}
|
|
426
450
|
const publicKey = await ctx.ui.input("Langfuse public key (leave blank to keep existing):");
|
|
451
|
+
if (!isCurrent()) return undefined;
|
|
427
452
|
if (publicKey === undefined) {
|
|
428
453
|
ctx.ui.notify("Cancelled", "info");
|
|
429
454
|
return undefined;
|
|
@@ -432,23 +457,23 @@ async function promptForConfig(
|
|
|
432
457
|
`Langfuse base URL (leave blank for default ${DEFAULT_BASE_URL}):`,
|
|
433
458
|
DEFAULT_BASE_URL,
|
|
434
459
|
);
|
|
460
|
+
if (!isCurrent()) return undefined;
|
|
435
461
|
if (baseUrl === undefined) {
|
|
436
462
|
ctx.ui.notify("Cancelled", "info");
|
|
437
463
|
return undefined;
|
|
438
464
|
}
|
|
439
465
|
|
|
440
|
-
const
|
|
441
|
-
...
|
|
442
|
-
|
|
443
|
-
secretKey: secretKey.trim() || current?.secretKey || "",
|
|
466
|
+
const patch: LangfuseConfigPatch = {
|
|
467
|
+
...(publicKey.trim() ? { publicKey: publicKey.trim() } : {}),
|
|
468
|
+
...(secretKey.trim() ? { secretKey: secretKey.trim() } : {}),
|
|
444
469
|
baseUrl: baseUrl.trim() || DEFAULT_BASE_URL,
|
|
445
|
-
|
|
446
|
-
});
|
|
470
|
+
};
|
|
471
|
+
const normalized = normalizeLangfuseConfig({ ...current, ...patch });
|
|
447
472
|
if (!normalized.ok) {
|
|
448
473
|
ctx.ui.notify(`Invalid Langfuse config: ${normalized.reason}`, "error");
|
|
449
474
|
return undefined;
|
|
450
475
|
}
|
|
451
|
-
return
|
|
476
|
+
return patch;
|
|
452
477
|
}
|
|
453
478
|
|
|
454
479
|
function formatMenuTitle(
|
|
@@ -524,7 +549,7 @@ function formatConfigError(result: Extract<LangfuseConfigResult, { ok: false }>)
|
|
|
524
549
|
return `Langfuse tracing is disabled: ${result.reason}${setupHint}`;
|
|
525
550
|
}
|
|
526
551
|
|
|
527
|
-
function formatError(error: unknown, config?:
|
|
552
|
+
function formatError(error: unknown, config?: LangfuseConfigPatch): string {
|
|
528
553
|
let message = error instanceof Error ? error.message : String(error);
|
|
529
554
|
const secrets = [config?.publicKey, config?.secretKey]
|
|
530
555
|
.filter((secret): secret is string => Boolean(secret))
|