@braintrust/pi-extension 0.1.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.
@@ -0,0 +1,131 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createStateStore } from "./state.ts";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(() => {
10
+ while (tempDirs.length > 0) {
11
+ rmSync(tempDirs.pop()!, { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ function makeTempDir(prefix: string): string {
16
+ const dir = mkdtempSync(join(tmpdir(), prefix));
17
+ tempDirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ describe("createStateStore", () => {
22
+ it("loads valid sessions and prunes expired ones on startup", async () => {
23
+ const stateDir = makeTempDir("pi-extension-state-");
24
+ const now = Date.now();
25
+ const oldTimestamp = now - 31 * 24 * 60 * 60 * 1000;
26
+
27
+ writeFileSync(
28
+ join(stateDir, "sessions.json"),
29
+ `${JSON.stringify(
30
+ {
31
+ version: 1,
32
+ sessions: {
33
+ fresh: {
34
+ rootSpanId: "fresh-root",
35
+ startedAt: now,
36
+ totalTurns: 2,
37
+ },
38
+ expired: {
39
+ rootSpanId: "old-root",
40
+ startedAt: oldTimestamp,
41
+ lastSeenAt: oldTimestamp,
42
+ },
43
+ invalid: {
44
+ startedAt: now,
45
+ },
46
+ },
47
+ },
48
+ null,
49
+ 2,
50
+ )}\n`,
51
+ "utf8",
52
+ );
53
+
54
+ const store = createStateStore(stateDir);
55
+ await store.flush();
56
+
57
+ expect(store.get("fresh")).toMatchObject({ rootSpanId: "fresh-root", totalTurns: 2 });
58
+ expect(store.get("expired")).toBeUndefined();
59
+ expect(store.get("invalid")).toBeUndefined();
60
+
61
+ const persisted = JSON.parse(readFileSync(join(stateDir, "sessions.json"), "utf8")) as {
62
+ sessions: Record<string, unknown>;
63
+ };
64
+ expect(Object.keys(persisted.sessions)).toEqual(["fresh"]);
65
+ });
66
+
67
+ it("only writes to disk when persistence is scheduled or flushed", async () => {
68
+ const stateDir = makeTempDir("pi-extension-state-");
69
+ const store = createStateStore(stateDir);
70
+
71
+ store.set("session-1", {
72
+ rootSpanId: "root-1",
73
+ startedAt: 1,
74
+ totalTurns: 1,
75
+ });
76
+
77
+ expect(existsSync(join(stateDir, "sessions.json"))).toBe(false);
78
+
79
+ store.schedulePersist(0);
80
+ await store.flush();
81
+
82
+ const persisted = JSON.parse(readFileSync(join(stateDir, "sessions.json"), "utf8")) as {
83
+ sessions: Record<string, unknown>;
84
+ };
85
+ expect(persisted.sessions["session-1"]).toEqual({
86
+ rootSpanId: "root-1",
87
+ startedAt: 1,
88
+ totalTurns: 1,
89
+ });
90
+ });
91
+
92
+ it("persists set, patch, and delete operations", async () => {
93
+ const stateDir = makeTempDir("pi-extension-state-");
94
+ const store = createStateStore(stateDir);
95
+
96
+ store.set("session-1", {
97
+ rootSpanId: "root-1",
98
+ startedAt: 1,
99
+ totalTurns: 1,
100
+ });
101
+ store.patch("session-1", {
102
+ totalTurns: 3,
103
+ totalToolCalls: 5,
104
+ });
105
+ await store.flush();
106
+
107
+ expect(store.get("session-1")).toEqual({
108
+ rootSpanId: "root-1",
109
+ startedAt: 1,
110
+ totalTurns: 3,
111
+ totalToolCalls: 5,
112
+ });
113
+
114
+ let persisted = JSON.parse(readFileSync(join(stateDir, "sessions.json"), "utf8")) as {
115
+ sessions: Record<string, unknown>;
116
+ };
117
+ expect(persisted.sessions["session-1"]).toEqual({
118
+ rootSpanId: "root-1",
119
+ startedAt: 1,
120
+ totalTurns: 3,
121
+ totalToolCalls: 5,
122
+ });
123
+
124
+ store.delete("session-1");
125
+ await store.flush();
126
+ persisted = JSON.parse(readFileSync(join(stateDir, "sessions.json"), "utf8")) as {
127
+ sessions: Record<string, unknown>;
128
+ };
129
+ expect(persisted.sessions).toEqual({});
130
+ });
131
+ });
package/src/state.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { Logger, PersistedSessionState, StateStore } from "./types.ts";
5
+ import { ensureDir, isPlainObject } from "./utils.ts";
6
+
7
+ const STATE_VERSION = 1;
8
+ const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
9
+ const DEFAULT_PERSIST_DEBOUNCE_MS = 50;
10
+
11
+ interface StoreData {
12
+ version: number;
13
+ sessions: Record<string, PersistedSessionState>;
14
+ }
15
+
16
+ interface StoreBackend {
17
+ stateFile: string;
18
+ state: StoreData;
19
+ dirty: boolean;
20
+ scheduledPersistTimer?: ReturnType<typeof setTimeout>;
21
+ pendingPersist: Promise<void>;
22
+ lastPersistedSnapshot?: string;
23
+ logger?: Logger;
24
+ }
25
+
26
+ const storeBackends = new Map<string, StoreBackend>();
27
+
28
+ function normalizeSessions(value: unknown): Record<string, PersistedSessionState> {
29
+ if (!isPlainObject(value)) return {};
30
+
31
+ const sessions: Record<string, PersistedSessionState> = {};
32
+ for (const [key, session] of Object.entries(value)) {
33
+ if (
34
+ isPlainObject(session) &&
35
+ typeof session.rootSpanId === "string" &&
36
+ typeof session.startedAt === "number"
37
+ ) {
38
+ sessions[key] = {
39
+ rootSpanId: session.rootSpanId,
40
+ rootSpanRecordId:
41
+ typeof session.rootSpanRecordId === "string" ? session.rootSpanRecordId : undefined,
42
+ traceRootSpanId:
43
+ typeof session.traceRootSpanId === "string" ? session.traceRootSpanId : undefined,
44
+ parentSpanId: typeof session.parentSpanId === "string" ? session.parentSpanId : undefined,
45
+ traceUrl: typeof session.traceUrl === "string" ? session.traceUrl : undefined,
46
+ startedAt: session.startedAt,
47
+ totalTurns: typeof session.totalTurns === "number" ? session.totalTurns : undefined,
48
+ totalToolCalls:
49
+ typeof session.totalToolCalls === "number" ? session.totalToolCalls : undefined,
50
+ lastSeenAt: typeof session.lastSeenAt === "number" ? session.lastSeenAt : undefined,
51
+ sessionFile: typeof session.sessionFile === "string" ? session.sessionFile : undefined,
52
+ };
53
+ }
54
+ }
55
+
56
+ return sessions;
57
+ }
58
+
59
+ function serializedState(backend: StoreBackend): string {
60
+ return `${JSON.stringify(backend.state, null, 2)}\n`;
61
+ }
62
+
63
+ function markDirty(backend: StoreBackend): void {
64
+ backend.dirty = true;
65
+ }
66
+
67
+ function pruneExpired(backend: StoreBackend): boolean {
68
+ const cutoff = Date.now() - RETENTION_MS;
69
+ let changed = false;
70
+
71
+ for (const [key, value] of Object.entries(backend.state.sessions)) {
72
+ if ((value.lastSeenAt ?? value.startedAt ?? 0) < cutoff) {
73
+ delete backend.state.sessions[key];
74
+ changed = true;
75
+ }
76
+ }
77
+
78
+ if (changed) markDirty(backend);
79
+ return changed;
80
+ }
81
+
82
+ async function persistDirty(backend: StoreBackend): Promise<void> {
83
+ if (!backend.dirty) {
84
+ await backend.pendingPersist.catch(() => {});
85
+ return;
86
+ }
87
+
88
+ const snapshot = serializedState(backend);
89
+ backend.dirty = false;
90
+
91
+ backend.pendingPersist = backend.pendingPersist
92
+ .catch(() => {})
93
+ .then(async () => {
94
+ if (snapshot === backend.lastPersistedSnapshot) return;
95
+
96
+ const tempFile = `${backend.stateFile}.tmp`;
97
+ try {
98
+ await writeFile(tempFile, snapshot, "utf8");
99
+ await rename(tempFile, backend.stateFile);
100
+ backend.lastPersistedSnapshot = snapshot;
101
+ } catch (error) {
102
+ markDirty(backend);
103
+ backend.logger?.warn("failed to persist state store", { error: String(error) });
104
+ }
105
+ });
106
+
107
+ await backend.pendingPersist.catch(() => {});
108
+ if (backend.dirty) await persistDirty(backend);
109
+ }
110
+
111
+ function schedulePersist(backend: StoreBackend, delayMs = DEFAULT_PERSIST_DEBOUNCE_MS): void {
112
+ if (backend.scheduledPersistTimer) clearTimeout(backend.scheduledPersistTimer);
113
+ backend.scheduledPersistTimer = setTimeout(() => {
114
+ backend.scheduledPersistTimer = undefined;
115
+ void persistDirty(backend);
116
+ }, delayMs);
117
+ backend.scheduledPersistTimer.unref?.();
118
+ }
119
+
120
+ function createBackend(stateFile: string, logger?: Logger): StoreBackend {
121
+ let state: StoreData = {
122
+ version: STATE_VERSION,
123
+ sessions: {},
124
+ };
125
+
126
+ if (existsSync(stateFile)) {
127
+ try {
128
+ const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as unknown;
129
+ if (isPlainObject(parsed)) {
130
+ state = {
131
+ version: STATE_VERSION,
132
+ sessions: normalizeSessions(parsed.sessions),
133
+ };
134
+ }
135
+ } catch (error) {
136
+ logger?.warn("failed to load state store", { error: String(error) });
137
+ }
138
+ }
139
+
140
+ const backend: StoreBackend = {
141
+ stateFile,
142
+ state,
143
+ dirty: false,
144
+ pendingPersist: Promise.resolve(),
145
+ logger,
146
+ };
147
+
148
+ backend.lastPersistedSnapshot = serializedState(backend);
149
+ if (pruneExpired(backend)) schedulePersist(backend, 0);
150
+ return backend;
151
+ }
152
+
153
+ export function createStateStore(stateDir: string, logger?: Logger): StateStore {
154
+ ensureDir(stateDir);
155
+ const stateFile = join(stateDir, "sessions.json");
156
+
157
+ let backend = storeBackends.get(stateFile);
158
+ if (!backend) {
159
+ backend = createBackend(stateFile, logger);
160
+ storeBackends.set(stateFile, backend);
161
+ } else if (!backend.logger) {
162
+ backend.logger = logger;
163
+ }
164
+
165
+ return {
166
+ get(sessionKey) {
167
+ return backend.state.sessions[sessionKey];
168
+ },
169
+ set(sessionKey, value) {
170
+ backend.state.sessions[sessionKey] = value;
171
+ markDirty(backend);
172
+ return backend.state.sessions[sessionKey];
173
+ },
174
+ patch(sessionKey, patch) {
175
+ backend.state.sessions[sessionKey] = {
176
+ ...backend.state.sessions[sessionKey],
177
+ ...patch,
178
+ } as PersistedSessionState;
179
+ markDirty(backend);
180
+ return backend.state.sessions[sessionKey];
181
+ },
182
+ delete(sessionKey) {
183
+ delete backend.state.sessions[sessionKey];
184
+ markDirty(backend);
185
+ },
186
+ schedulePersist(delayMs) {
187
+ schedulePersist(backend, delayMs);
188
+ },
189
+ async flush() {
190
+ if (backend.scheduledPersistTimer) {
191
+ clearTimeout(backend.scheduledPersistTimer);
192
+ backend.scheduledPersistTimer = undefined;
193
+ }
194
+ await persistDirty(backend);
195
+ },
196
+ };
197
+ }
package/src/types.ts ADDED
@@ -0,0 +1,179 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
3
+
4
+ export interface JsonObject {
5
+ [key: string]: JsonValue | undefined;
6
+ }
7
+
8
+ export type LogLevel = "debug" | "info" | "warn" | "error";
9
+
10
+ export interface ConfigIssue {
11
+ path: string;
12
+ message: string;
13
+ severity: "error" | "warning";
14
+ }
15
+
16
+ export interface TraceConfig {
17
+ enabled: boolean;
18
+ apiKey: string;
19
+ apiUrl?: string;
20
+ appUrl: string;
21
+ orgName?: string;
22
+ projectName: string;
23
+ debug: boolean;
24
+ logFile?: string;
25
+ stateDir: string;
26
+ additionalMetadata: JsonObject;
27
+ parentSpanId?: string;
28
+ rootSpanId?: string;
29
+ configIssues: ConfigIssue[];
30
+ }
31
+
32
+ export interface Logger {
33
+ filePath: string;
34
+ debug(message: string, data?: unknown): void;
35
+ info(message: string, data?: unknown): void;
36
+ warn(message: string, data?: unknown): void;
37
+ error(message: string, data?: unknown): void;
38
+ flush(): Promise<void>;
39
+ }
40
+
41
+ export interface PersistedSessionState {
42
+ rootSpanId: string;
43
+ rootSpanRecordId?: string;
44
+ traceRootSpanId?: string;
45
+ parentSpanId?: string;
46
+ traceUrl?: string;
47
+ startedAt: number;
48
+ totalTurns?: number;
49
+ totalToolCalls?: number;
50
+ lastSeenAt?: number;
51
+ sessionFile?: string;
52
+ }
53
+
54
+ export interface StateStore {
55
+ get(sessionKey: string): PersistedSessionState | undefined;
56
+ set(sessionKey: string, value: PersistedSessionState): PersistedSessionState;
57
+ patch(sessionKey: string, patch: Partial<PersistedSessionState>): PersistedSessionState;
58
+ delete(sessionKey: string): void;
59
+ schedulePersist(delayMs?: number): void;
60
+ flush(): Promise<void>;
61
+ }
62
+
63
+ export interface TextContentLike {
64
+ type: "text";
65
+ text?: string;
66
+ }
67
+
68
+ export interface ThinkingContentLike {
69
+ type: "thinking";
70
+ thinking?: string;
71
+ redacted?: boolean;
72
+ }
73
+
74
+ export interface ToolCallContentLike {
75
+ type: "toolCall";
76
+ id: string;
77
+ name?: string;
78
+ arguments?: Record<string, unknown>;
79
+ }
80
+
81
+ export interface ImageContentLike {
82
+ type: "image";
83
+ mimeType?: string;
84
+ }
85
+
86
+ export type ContentPartLike =
87
+ | TextContentLike
88
+ | ThinkingContentLike
89
+ | ToolCallContentLike
90
+ | ImageContentLike
91
+ | {
92
+ type?: string;
93
+ [key: string]: unknown;
94
+ };
95
+
96
+ export interface ImageLike {
97
+ mimeType?: string;
98
+ source?: {
99
+ mediaType?: string;
100
+ };
101
+ }
102
+
103
+ export interface UsageLike {
104
+ input?: number;
105
+ output?: number;
106
+ cacheRead?: number;
107
+ cacheWrite?: number;
108
+ totalTokens?: number;
109
+ }
110
+
111
+ export interface UserMessageLike {
112
+ role: "user";
113
+ content?: unknown;
114
+ }
115
+
116
+ export interface AssistantMessageLike {
117
+ role: "assistant";
118
+ content?: ContentPartLike[];
119
+ api?: string;
120
+ provider?: string;
121
+ model?: string;
122
+ usage?: UsageLike;
123
+ stopReason?: string;
124
+ errorMessage?: string;
125
+ timestamp?: number;
126
+ }
127
+
128
+ export interface ToolResultMessageLike {
129
+ role: "toolResult";
130
+ toolCallId?: string;
131
+ toolName?: string;
132
+ content?: unknown;
133
+ details?: unknown;
134
+ isError?: boolean;
135
+ }
136
+
137
+ export type AgentMessageLike =
138
+ | UserMessageLike
139
+ | AssistantMessageLike
140
+ | ToolResultMessageLike
141
+ | {
142
+ role?: string;
143
+ content?: unknown;
144
+ };
145
+
146
+ export interface NormalizedUserMessage {
147
+ role: "user";
148
+ content: string;
149
+ }
150
+
151
+ export interface NormalizedAssistantMessage {
152
+ role: "assistant";
153
+ content: string;
154
+ tool_calls?: Array<{
155
+ id?: string;
156
+ type: "function";
157
+ function: {
158
+ name?: string;
159
+ arguments: string;
160
+ };
161
+ }>;
162
+ reasoning?: Array<{
163
+ id: string;
164
+ content: string;
165
+ }>;
166
+ }
167
+
168
+ export interface NormalizedToolMessage {
169
+ role: "tool";
170
+ content: string;
171
+ tool_call_id?: string;
172
+ name?: string;
173
+ is_error: boolean;
174
+ }
175
+
176
+ export type NormalizedAgentMessage =
177
+ | NormalizedUserMessage
178
+ | NormalizedAssistantMessage
179
+ | NormalizedToolMessage;
@@ -0,0 +1,163 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ buildTurnInput,
8
+ extractErrorText,
9
+ formatToolSpanName,
10
+ normalizeAssistantMessage,
11
+ normalizeContextMessages,
12
+ normalizeToolResult,
13
+ repoSlugForCwd,
14
+ rootSpanName,
15
+ } from "./utils.ts";
16
+
17
+ const tempDirs: string[] = [];
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks();
21
+ while (tempDirs.length > 0) {
22
+ rmSync(tempDirs.pop()!, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ function makeTempDir(prefix: string): string {
27
+ const dir = mkdtempSync(join(tmpdir(), prefix));
28
+ tempDirs.push(dir);
29
+ return dir;
30
+ }
31
+
32
+ describe("utils", () => {
33
+ it("normalizes assistant messages with text, reasoning, and tool calls", () => {
34
+ const normalized = normalizeAssistantMessage({
35
+ role: "assistant",
36
+ content: [
37
+ { type: "text", text: "First line" },
38
+ { type: "thinking", thinking: "Plan the next step" },
39
+ {
40
+ type: "toolCall",
41
+ id: "call-1",
42
+ name: "read",
43
+ arguments: { path: "package.json" },
44
+ },
45
+ ],
46
+ });
47
+
48
+ expect(normalized).toEqual({
49
+ role: "assistant",
50
+ content: "First line",
51
+ reasoning: [{ id: "thinking", content: "Plan the next step" }],
52
+ tool_calls: [
53
+ {
54
+ id: "call-1",
55
+ type: "function",
56
+ function: {
57
+ name: "read",
58
+ arguments: '{"path":"package.json"}',
59
+ },
60
+ },
61
+ ],
62
+ });
63
+ });
64
+
65
+ it("normalizes mixed context messages into Braintrust-friendly shapes", () => {
66
+ const messages = normalizeContextMessages([
67
+ { role: "user", content: [{ type: "text", text: "Use the docs" }] },
68
+ {
69
+ role: "assistant",
70
+ content: [
71
+ { type: "text", text: "I will inspect the config" },
72
+ { type: "thinking", redacted: true },
73
+ ],
74
+ },
75
+ {
76
+ role: "toolResult",
77
+ toolCallId: "tool-1",
78
+ toolName: "read",
79
+ isError: false,
80
+ content: [{ type: "text", text: "README contents" }],
81
+ },
82
+ { role: "system", content: "ignored" },
83
+ ]);
84
+
85
+ expect(messages).toEqual([
86
+ { role: "user", content: "Use the docs" },
87
+ {
88
+ role: "assistant",
89
+ content: "I will inspect the config",
90
+ reasoning: [{ id: "thinking", content: "[thinking redacted]" }],
91
+ },
92
+ {
93
+ role: "tool",
94
+ content: "README contents",
95
+ tool_call_id: "tool-1",
96
+ name: "read",
97
+ is_error: false,
98
+ },
99
+ ]);
100
+ });
101
+
102
+ it("normalizes structured tool results and extracts readable error text", () => {
103
+ const result = normalizeToolResult({
104
+ content: [
105
+ { type: "text", text: "command failed" },
106
+ { type: "thinking", redacted: true },
107
+ ],
108
+ details: { exitCode: 1, stderr: "boom" },
109
+ isError: true,
110
+ });
111
+
112
+ expect(result).toEqual({
113
+ content: "command failed\n[thinking redacted]",
114
+ details: { exitCode: 1, stderr: "boom" },
115
+ isError: true,
116
+ });
117
+
118
+ expect(
119
+ extractErrorText(
120
+ {
121
+ content: [{ type: "text", text: "tool exploded" }],
122
+ },
123
+ "fallback message",
124
+ ),
125
+ ).toBe("tool exploded");
126
+ });
127
+
128
+ it("formats tool span names and builds turn inputs", () => {
129
+ expect(formatToolSpanName("read", { path: "/tmp/project/package.json" })).toBe(
130
+ "read: package.json",
131
+ );
132
+ expect(formatToolSpanName("bash", { command: "npm test -- --runInBand" })).toBe(
133
+ "bash: npm test -- --runInBand",
134
+ );
135
+
136
+ expect(
137
+ buildTurnInput("Summarize these screenshots", [
138
+ { source: { mediaType: "image/png" } },
139
+ { mimeType: "image/jpeg" },
140
+ ]),
141
+ ).toBe("Summarize these screenshots\n[image/png]\n[image/jpeg]");
142
+ });
143
+
144
+ it("prefers owner/repo from git origin for the root span name", () => {
145
+ const repoDir = makeTempDir("pi-extension-git-");
146
+ execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
147
+ execFileSync(
148
+ "git",
149
+ ["remote", "add", "origin", "git@github.com:braintrustdata/braintrust-pi-extension.git"],
150
+ { cwd: repoDir, stdio: "ignore" },
151
+ );
152
+
153
+ expect(repoSlugForCwd(repoDir)).toBe("braintrustdata/braintrust-pi-extension");
154
+ expect(rootSpanName(repoDir)).toBe("pi: braintrustdata/braintrust-pi-extension");
155
+ });
156
+
157
+ it("falls back to the cwd basename when no git origin is available", () => {
158
+ const dir = makeTempDir("pi-extension-no-git-");
159
+
160
+ expect(repoSlugForCwd(dir)).toBeUndefined();
161
+ expect(rootSpanName(dir)).toBe(`pi: ${dir.split("/").at(-1)}`);
162
+ });
163
+ });