@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.
package/src/utils.ts ADDED
@@ -0,0 +1,384 @@
1
+ import * as childProcess from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdirSync } from "node:fs";
4
+ import { appendFile, mkdir } from "node:fs/promises";
5
+ import { basename, dirname } from "node:path";
6
+ import type {
7
+ AgentMessageLike,
8
+ AssistantMessageLike,
9
+ ContentPartLike,
10
+ ImageLike,
11
+ NormalizedAgentMessage,
12
+ NormalizedAssistantMessage,
13
+ ToolResultMessageLike,
14
+ } from "./types.ts";
15
+
16
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
17
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ export function ensureDir(path: string | undefined): void {
21
+ if (!path) return;
22
+ mkdirSync(path, { recursive: true });
23
+ }
24
+
25
+ export function toUnixSeconds(timestampMs: number): number {
26
+ return timestampMs / 1000;
27
+ }
28
+
29
+ export function generateUuid(): string {
30
+ return randomUUID();
31
+ }
32
+
33
+ export function sessionKeyFor(
34
+ sessionFile: string | undefined,
35
+ sessionId: string | undefined,
36
+ ): string {
37
+ if (sessionFile) return `file:${sessionFile}`;
38
+ return `ephemeral:${sessionId ?? generateUuid()}`;
39
+ }
40
+
41
+ export function coerceToString(value: unknown): string | undefined {
42
+ if (typeof value === "string") return value;
43
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
44
+ return String(value);
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function shortHash(value: unknown): string {
50
+ return createHash("sha256").update(safeStringify(value)).digest("hex").slice(0, 12);
51
+ }
52
+
53
+ export function parseBoolean(value: unknown, fallback = false): boolean {
54
+ if (value === undefined || value === null || value === "") return fallback;
55
+ if (typeof value === "boolean") return value;
56
+ const normalized = coerceToString(value)?.trim().toLowerCase();
57
+ if (!normalized) return fallback;
58
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
59
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
60
+ return fallback;
61
+ }
62
+
63
+ export function safeJsonParse<T>(value: string, fallback: T): T {
64
+ try {
65
+ return JSON.parse(value) as T;
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ }
70
+
71
+ export function safeStringify(value: unknown): string {
72
+ const primitive = coerceToString(value);
73
+ if (primitive !== undefined) return primitive;
74
+ try {
75
+ return JSON.stringify(value) ?? "";
76
+ } catch {
77
+ return "";
78
+ }
79
+ }
80
+
81
+ export function truncateString(value: unknown, maxLength = 10_000): string {
82
+ const text = typeof value === "string" ? value : safeStringify(value);
83
+ if (text.length <= maxLength) return text;
84
+ return `${text.slice(0, maxLength)}… [truncated ${text.length - maxLength} chars]`;
85
+ }
86
+
87
+ export function truncateValue(value: unknown, maxLength = 10_000): unknown {
88
+ if (value === null || value === undefined) return value;
89
+ if (typeof value === "string") return truncateString(value, maxLength);
90
+ if (typeof value === "number" || typeof value === "boolean") return value;
91
+ if (Array.isArray(value)) return value.map((item) => truncateValue(item, maxLength));
92
+ if (isPlainObject(value)) {
93
+ const result: Record<string, unknown> = {};
94
+ for (const [key, item] of Object.entries(value)) {
95
+ result[key] = truncateValue(item, maxLength);
96
+ }
97
+ return result;
98
+ }
99
+ return truncateString(safeStringify(value), maxLength);
100
+ }
101
+
102
+ function contentPartToText(item: ContentPartLike): string {
103
+ if (item.type === "text") {
104
+ return typeof item.text === "string" ? item.text : "";
105
+ }
106
+
107
+ if (item.type === "thinking") {
108
+ return item.redacted
109
+ ? "[thinking redacted]"
110
+ : typeof item.thinking === "string"
111
+ ? item.thinking
112
+ : "";
113
+ }
114
+
115
+ if (item.type === "image") {
116
+ return `[image:${typeof item.mimeType === "string" ? item.mimeType : "unknown"}]`;
117
+ }
118
+
119
+ if (item.type === "toolCall") {
120
+ const toolName = typeof item.name === "string" ? item.name : "unknown";
121
+ return `[toolCall:${toolName}] ${safeStringify(item.arguments ?? {})}`;
122
+ }
123
+
124
+ return `[${typeof item.type === "string" ? item.type : "content"}] ${safeStringify(item)}`;
125
+ }
126
+
127
+ export function contentToText(content: unknown): string {
128
+ if (content === null || content === undefined) return "";
129
+ if (typeof content === "string") return truncateString(content);
130
+ if (!Array.isArray(content)) return truncateString(safeStringify(content));
131
+
132
+ const lines: string[] = [];
133
+ for (const item of content) {
134
+ if (!isPlainObject(item)) {
135
+ lines.push(safeStringify(item));
136
+ continue;
137
+ }
138
+
139
+ lines.push(contentPartToText(item as ContentPartLike));
140
+ }
141
+
142
+ return truncateString(lines.filter(Boolean).join("\n"));
143
+ }
144
+
145
+ export function normalizeUserContent(content: unknown): string {
146
+ return contentToText(content);
147
+ }
148
+
149
+ export function normalizeAssistantMessage(
150
+ message: AssistantMessageLike,
151
+ ): NormalizedAssistantMessage {
152
+ const textParts: string[] = [];
153
+ const thinkingParts: string[] = [];
154
+ const toolCalls: NonNullable<NormalizedAssistantMessage["tool_calls"]> = [];
155
+
156
+ for (const part of message.content ?? []) {
157
+ if (!isPlainObject(part)) continue;
158
+
159
+ if (part.type === "text") {
160
+ textParts.push(typeof part.text === "string" ? part.text : "");
161
+ continue;
162
+ }
163
+
164
+ if (part.type === "thinking") {
165
+ thinkingParts.push(
166
+ part.redacted
167
+ ? "[thinking redacted]"
168
+ : typeof part.thinking === "string"
169
+ ? part.thinking
170
+ : "",
171
+ );
172
+ continue;
173
+ }
174
+
175
+ if (part.type === "toolCall") {
176
+ toolCalls.push({
177
+ id: typeof part.id === "string" ? part.id : undefined,
178
+ type: "function",
179
+ function: {
180
+ name: typeof part.name === "string" ? part.name : undefined,
181
+ arguments: truncateString(safeStringify(part.arguments ?? {})),
182
+ },
183
+ });
184
+ }
185
+ }
186
+
187
+ const normalized: NormalizedAssistantMessage = {
188
+ role: "assistant",
189
+ content: truncateString(textParts.join("\n")),
190
+ };
191
+
192
+ if (toolCalls.length > 0) normalized.tool_calls = toolCalls;
193
+ if (thinkingParts.length > 0) {
194
+ normalized.reasoning = [{ id: "thinking", content: truncateString(thinkingParts.join("\n")) }];
195
+ }
196
+
197
+ return normalized;
198
+ }
199
+
200
+ export function normalizeAgentMessage(
201
+ message: AgentMessageLike,
202
+ ): NormalizedAgentMessage | undefined {
203
+ if (!message || typeof message !== "object" || !("role" in message)) return undefined;
204
+
205
+ if (message.role === "user") {
206
+ return {
207
+ role: "user",
208
+ content: normalizeUserContent(message.content),
209
+ };
210
+ }
211
+
212
+ if (message.role === "assistant") {
213
+ return normalizeAssistantMessage(message as AssistantMessageLike);
214
+ }
215
+
216
+ if (message.role === "toolResult") {
217
+ const toolMessage = message as ToolResultMessageLike;
218
+ return {
219
+ role: "tool",
220
+ content: contentToText(toolMessage.content),
221
+ tool_call_id: toolMessage.toolCallId,
222
+ name: toolMessage.toolName,
223
+ is_error: Boolean(toolMessage.isError),
224
+ };
225
+ }
226
+
227
+ return undefined;
228
+ }
229
+
230
+ export function normalizeContextMessages(
231
+ messages: readonly AgentMessageLike[] | undefined,
232
+ ): NormalizedAgentMessage[] {
233
+ return (messages ?? [])
234
+ .map(normalizeAgentMessage)
235
+ .filter((message): message is NormalizedAgentMessage => Boolean(message));
236
+ }
237
+
238
+ export function normalizeToolResult(result: unknown): unknown {
239
+ if (result === null || result === undefined) return undefined;
240
+ if (typeof result === "string") return truncateString(result);
241
+
242
+ if (isPlainObject(result)) {
243
+ const normalized: Record<string, unknown> = {};
244
+
245
+ if ("content" in result && result.content !== undefined) {
246
+ normalized.content = contentToText(result.content);
247
+ }
248
+
249
+ if ("details" in result && result.details !== undefined) {
250
+ normalized.details = truncateValue(result.details);
251
+ }
252
+
253
+ if ("isError" in result) {
254
+ normalized.isError = Boolean(result.isError);
255
+ }
256
+
257
+ if (Object.keys(normalized).length > 0) return normalized;
258
+ }
259
+
260
+ return truncateValue(result);
261
+ }
262
+
263
+ export function extractErrorText(value: unknown, fallback: string | undefined): string | undefined {
264
+ if (!value) return fallback;
265
+ if (typeof value === "string") return truncateString(value);
266
+ if (isPlainObject(value)) {
267
+ if (typeof value.errorMessage === "string") return truncateString(value.errorMessage);
268
+ if (typeof value.message === "string") return truncateString(value.message);
269
+ if (Array.isArray(value.content)) {
270
+ const text = contentToText(value.content);
271
+ if (text) return text;
272
+ }
273
+ }
274
+ return fallback;
275
+ }
276
+
277
+ export function formatToolSpanName(toolName: string, args: unknown = {}): string {
278
+ const objectArgs = isPlainObject(args) ? args : {};
279
+ const pathLike =
280
+ objectArgs.path ??
281
+ objectArgs.file ??
282
+ objectArgs.filePath ??
283
+ objectArgs.target ??
284
+ objectArgs.sessionDir;
285
+ if (typeof pathLike === "string") {
286
+ return `${toolName}: ${basename(pathLike)}`;
287
+ }
288
+
289
+ if (toolName === "bash" && typeof objectArgs.command === "string") {
290
+ const command = objectArgs.command.replace(/\s+/g, " ").trim();
291
+ return `bash: ${truncateString(command, 60)}`;
292
+ }
293
+
294
+ return toolName;
295
+ }
296
+
297
+ export function buildTurnInput(
298
+ prompt: string,
299
+ images: readonly ImageLike[] | undefined = [],
300
+ ): string {
301
+ const input = [prompt ?? ""];
302
+ for (const image of images ?? []) {
303
+ const type = image?.source?.mediaType ?? image?.mimeType ?? "image";
304
+ input.push(`[${type}]`);
305
+ }
306
+ return truncateString(input.filter(Boolean).join("\n"));
307
+ }
308
+
309
+ const gitRemoteRepoCache = new Map<string, string | undefined>();
310
+
311
+ function parseGitRemoteRepo(remoteUrl: string): string | undefined {
312
+ const trimmed = remoteUrl.trim();
313
+ if (!trimmed) return undefined;
314
+
315
+ const scpLikeMatch = trimmed.match(/^[^@\s]+@[^:\s]+:(.+)$/);
316
+ const path = scpLikeMatch
317
+ ? scpLikeMatch[1]
318
+ : (() => {
319
+ try {
320
+ return new URL(trimmed).pathname;
321
+ } catch {
322
+ return undefined;
323
+ }
324
+ })();
325
+
326
+ if (!path) return undefined;
327
+
328
+ const segments = path
329
+ .replace(/^\/+/, "")
330
+ .replace(/\.git$/i, "")
331
+ .split("/")
332
+ .filter(Boolean);
333
+
334
+ if (segments.length < 2) return undefined;
335
+ return `${segments.at(-2)}/${segments.at(-1)}`;
336
+ }
337
+
338
+ export function repoSlugForCwd(cwd: string): string | undefined {
339
+ const resolvedCwd = cwd || process.cwd();
340
+ if (gitRemoteRepoCache.has(resolvedCwd)) {
341
+ return gitRemoteRepoCache.get(resolvedCwd);
342
+ }
343
+
344
+ let repo: string | undefined;
345
+
346
+ try {
347
+ const result = childProcess.spawnSync(
348
+ "git",
349
+ ["-C", resolvedCwd, "config", "--get", "remote.origin.url"],
350
+ {
351
+ encoding: "utf8",
352
+ timeout: 500,
353
+ windowsHide: true,
354
+ },
355
+ );
356
+
357
+ if (result.status === 0 && typeof result.stdout === "string") {
358
+ repo = parseGitRemoteRepo(result.stdout);
359
+ }
360
+ } catch {
361
+ repo = undefined;
362
+ }
363
+
364
+ gitRemoteRepoCache.set(resolvedCwd, repo);
365
+ return repo;
366
+ }
367
+
368
+ export function rootSpanName(cwd: string): string {
369
+ return `pi: ${repoSlugForCwd(cwd) ?? basename(cwd || process.cwd())}`;
370
+ }
371
+
372
+ export async function writeJsonLog(
373
+ filePath: string,
374
+ level: string,
375
+ message: string,
376
+ data?: unknown,
377
+ ): Promise<void> {
378
+ await mkdir(dirname(filePath), { recursive: true });
379
+ await appendFile(
380
+ filePath,
381
+ `${JSON.stringify({ timestamp: new Date().toISOString(), level, message, data: truncateValue(data) })}\n`,
382
+ "utf8",
383
+ );
384
+ }