pi-better-openai 0.1.18 → 0.1.21

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/image.ts CHANGED
@@ -1,16 +1,29 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { extname, isAbsolute, join, resolve, sep } from "node:path";
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Box, Container, Image, Text } from "@earendil-works/pi-tui";
7
+ import sharp from "sharp";
6
8
  import type { ResolvedConfig } from "./config.ts";
7
9
  import { isRecord } from "./config.ts";
8
- import { readCodexAuth } from "./usage.ts";
10
+ import {
11
+ extractAccountIdFromJwt,
12
+ getCodexCredentials,
13
+ type CodexCredentialsWithSource,
14
+ } from "./codex-auth.ts";
15
+ import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
16
+ import { piAgentDir, resolveUserPath } from "./paths.ts";
9
17
 
10
18
  const OPENAI_IMAGE_TOOL = "openai_image";
11
19
  const OPENAI_IMAGE_COMMAND = "openai-image";
12
20
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
13
21
  const DEFAULT_TIMEOUT_MS = 180_000;
22
+ const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
23
+ const MAX_IMAGE_INPUTS = 5;
24
+ const MAX_TOTAL_IMAGE_INPUT_BYTES = 50 * 1024 * 1024;
25
+ const SUPPORTED_INPUT_IMAGE_FORMATS = new Set(["png", "jpeg", "jpg", "webp", "gif"]);
26
+ const SSE_EVENT_BOUNDARY = /\r?\n\r?\n/;
14
27
 
15
28
  export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
16
29
  export const IMAGE_ACTIONS = ["auto", "generate", "edit"] as const;
@@ -36,6 +49,7 @@ const TOOL_PARAMS = {
36
49
  },
37
50
  images: {
38
51
  type: "array",
52
+ maxItems: MAX_IMAGE_INPUTS,
39
53
  items: { type: "string" },
40
54
  description: "Local image paths to use as edit targets or references.",
41
55
  },
@@ -70,11 +84,7 @@ type ToolParams = {
70
84
  saveDir?: string;
71
85
  };
72
86
 
73
- type CodexImageCredentials = {
74
- accessToken: string;
75
- accountId: string;
76
- source: "modelRegistry" | "authFile";
77
- };
87
+ type CodexImageCredentials = CodexCredentialsWithSource;
78
88
 
79
89
  type ImageInput = {
80
90
  path: string;
@@ -112,65 +122,12 @@ export type ImageGenerationDebug = {
112
122
  lastError?: string;
113
123
  };
114
124
 
115
- function decodeBase64Url(value: string): string {
116
- const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
117
- const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
118
- return Buffer.from(padded, "base64").toString("utf8");
119
- }
120
-
121
- export function extractAccountIdFromJwt(token: string): string | undefined {
122
- try {
123
- const [, payload] = token.split(".");
124
- if (!payload) return undefined;
125
- const parsed = JSON.parse(decodeBase64Url(payload)) as unknown;
126
- if (!isRecord(parsed)) return undefined;
127
- const auth = parsed["https://api.openai.com/auth"];
128
- if (!isRecord(auth)) return undefined;
129
- const accountId = auth.chatgpt_account_id;
130
- return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
131
- } catch {
132
- return undefined;
133
- }
134
- }
135
-
136
- function parseRegistryCredentials(
137
- raw: string | undefined,
138
- ): Omit<CodexImageCredentials, "source"> | undefined {
139
- const value = raw?.trim();
140
- if (!value) return undefined;
141
- try {
142
- const parsed = JSON.parse(value) as unknown;
143
- if (isRecord(parsed)) {
144
- const accessToken =
145
- typeof parsed.access === "string"
146
- ? parsed.access
147
- : typeof parsed.token === "string"
148
- ? parsed.token
149
- : undefined;
150
- const accountId =
151
- typeof parsed.accountId === "string"
152
- ? parsed.accountId
153
- : typeof parsed.account_id === "string"
154
- ? parsed.account_id
155
- : undefined;
156
- if (accessToken?.trim() && accountId?.trim())
157
- return { accessToken: accessToken.trim(), accountId: accountId.trim() };
158
- }
159
- } catch {
160
- // Plain bearer token is expected for openai-codex in pi.
161
- }
162
- const accountId = extractAccountIdFromJwt(value);
163
- return accountId ? { accessToken: value, accountId } : undefined;
164
- }
165
-
166
- async function getCredentials(ctx: ExtensionContext): Promise<CodexImageCredentials> {
167
- const registryToken = await ctx.modelRegistry
168
- .getApiKeyForProvider("openai-codex")
169
- .catch(() => undefined);
170
- const registryCredentials = parseRegistryCredentials(registryToken);
171
- if (registryCredentials) return { ...registryCredentials, source: "modelRegistry" };
172
- const auth = readCodexAuth();
173
- if (auth) return { ...auth, source: "authFile" };
125
+ async function getCredentials(
126
+ ctx: ExtensionContext,
127
+ signal?: AbortSignal,
128
+ ): Promise<CodexImageCredentials> {
129
+ const credentials = await getCodexCredentials(ctx, signal);
130
+ if (credentials) return credentials;
174
131
  throw new Error("Missing openai-codex OAuth credentials. Run /login openai-codex.");
175
132
  }
176
133
 
@@ -193,12 +150,14 @@ function resolveImageConfig(cfg: ResolvedConfig, params: ToolParams) {
193
150
  }
194
151
 
195
152
  function imageMimeType(path: string, outputFormat?: string): string {
153
+ if (outputFormat === "jpeg" || outputFormat === "jpg") return "image/jpeg";
154
+ if (outputFormat === "webp") return "image/webp";
155
+ if (outputFormat === "gif") return "image/gif";
156
+ if (outputFormat === "png") return "image/png";
196
157
  const ext = extname(path).toLowerCase();
197
158
  if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
198
159
  if (ext === ".webp") return "image/webp";
199
160
  if (ext === ".gif") return "image/gif";
200
- if (outputFormat === "jpeg") return "image/jpeg";
201
- if (outputFormat === "webp") return "image/webp";
202
161
  return "image/png";
203
162
  }
204
163
 
@@ -206,16 +165,75 @@ function extensionForFormat(format: ImageOutputFormat): string {
206
165
  return format === "jpeg" ? "jpg" : format;
207
166
  }
208
167
 
168
+ function isInsideDirectory(root: string, child: string): boolean {
169
+ const normalizedRoot = resolve(root);
170
+ const normalizedChild = resolve(child);
171
+ return (
172
+ normalizedChild !== normalizedRoot && normalizedChild.startsWith(`${normalizedRoot}${sep}`)
173
+ );
174
+ }
175
+
176
+ async function validateImageInput(
177
+ path: string,
178
+ realWorkspaceRoot: string,
179
+ ): Promise<{ mimeType: string; path: string; size: number }> {
180
+ const realInputPath = await realpath(path).catch(() => undefined);
181
+ if (!realInputPath || !isInsideDirectory(realWorkspaceRoot, realInputPath))
182
+ throw new Error(
183
+ `Image input must be a file inside the current workspace: ${displayPath(path)}`,
184
+ );
185
+
186
+ const pathStats = await stat(realInputPath).catch(() => undefined);
187
+ if (!pathStats?.isFile())
188
+ throw new Error(
189
+ `Image input must be a file inside the current workspace: ${displayPath(path)}`,
190
+ );
191
+ if (pathStats.size > MAX_IMAGE_INPUT_BYTES)
192
+ throw new Error(`Image input is too large (max 20 MB): ${displayPath(path)}`);
193
+
194
+ const metadata = await sharp(realInputPath, { animated: false })
195
+ .metadata()
196
+ .catch(() => undefined);
197
+ if (!metadata?.format || !SUPPORTED_INPUT_IMAGE_FORMATS.has(metadata.format))
198
+ throw new Error(`Image input is not a readable image: ${displayPath(path)}`);
199
+ return {
200
+ mimeType: imageMimeType(path, metadata.format),
201
+ path: realInputPath,
202
+ size: pathStats.size,
203
+ };
204
+ }
205
+
209
206
  async function readImageInputs(paths: string[] | undefined, cwd: string): Promise<ImageInput[]> {
210
- const inputs: ImageInput[] = [];
207
+ const validatedInputs: Array<{ path: string; mimeType: string }> = [];
208
+ const seenPaths = new Set<string>();
209
+ let totalBytes = 0;
210
+ const workspaceRoot = resolve(cwd);
211
+ let realWorkspaceRoot: string | undefined;
211
212
  for (const rawPath of paths ?? []) {
212
213
  const trimmed = rawPath.trim();
213
214
  if (!trimmed) continue;
214
- const path = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
215
- const data = (await readFile(path)).toString("base64");
216
- inputs.push({ path, data, mimeType: imageMimeType(path) });
215
+ const path = isAbsolute(trimmed) ? resolve(trimmed) : resolve(workspaceRoot, trimmed);
216
+ if (!isInsideDirectory(workspaceRoot, path))
217
+ throw new Error(
218
+ `Image input must be a file inside the current workspace: ${displayPath(path)}`,
219
+ );
220
+ realWorkspaceRoot ??= await realpath(workspaceRoot).catch(() => workspaceRoot);
221
+ const input = await validateImageInput(path, realWorkspaceRoot);
222
+ if (seenPaths.has(input.path)) continue;
223
+ if (validatedInputs.length >= MAX_IMAGE_INPUTS)
224
+ throw new Error(`Too many image inputs (max ${MAX_IMAGE_INPUTS}).`);
225
+ totalBytes += input.size;
226
+ if (totalBytes > MAX_TOTAL_IMAGE_INPUT_BYTES)
227
+ throw new Error("Image inputs are too large in total (max 50 MB).");
228
+ seenPaths.add(input.path);
229
+ validatedInputs.push({ path: input.path, mimeType: input.mimeType });
217
230
  }
218
- return inputs;
231
+ return Promise.all(
232
+ validatedInputs.map(async (input) => ({
233
+ ...input,
234
+ data: (await readFile(input.path)).toString("base64"),
235
+ })),
236
+ );
219
237
  }
220
238
 
221
239
  function resolveSaveDir(
@@ -225,14 +243,10 @@ function resolveSaveDir(
225
243
  ): string | undefined {
226
244
  if (mode === "none") return undefined;
227
245
  if (mode === "project") return join(cwd, ".pi", "generated-images");
228
- if (mode === "global")
229
- return join(
230
- process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
231
- "generated-images",
232
- );
246
+ if (mode === "global") return join(piAgentDir(), "generated-images");
233
247
  const dir = params.saveDir?.trim() || process.env.PI_IMAGE_SAVE_DIR?.trim();
234
248
  if (!dir) throw new Error("save=custom requires saveDir or PI_IMAGE_SAVE_DIR.");
235
- return dir;
249
+ return resolveUserPath(dir, cwd);
236
250
  }
237
251
 
238
252
  async function saveImage(
@@ -335,10 +349,15 @@ function extractImageFromEvent(
335
349
  mimeType,
336
350
  };
337
351
  }
338
- const partial = event.partial_image_b64 ?? event.b64_json;
352
+ const partial =
353
+ typeof event.partial_image_b64 === "string"
354
+ ? event.partial_image_b64
355
+ : typeof event.b64_json === "string"
356
+ ? event.b64_json
357
+ : undefined;
339
358
  if (typeof partial === "string" && partial.trim()) {
340
359
  const { data, mimeType } = dataUrlParts(partial, fallbackMimeType);
341
- return { id: `ig_${randomUUID().slice(0, 8)}`, status: "completed", data, mimeType };
360
+ return { id: `ig_${randomUUID().slice(0, 8)}`, status: "partial", data, mimeType };
342
361
  }
343
362
  return undefined;
344
363
  }
@@ -352,19 +371,19 @@ async function parseSseForImage(
352
371
  const reader = response.body.getReader();
353
372
  const decoder = new TextDecoder();
354
373
  let buffer = "";
355
- let lastImage: ExtractedImageResult | undefined;
356
374
  try {
357
375
  while (true) {
358
376
  if (signal?.aborted) throw new Error("Image request was aborted.");
359
377
  const { done, value } = await reader.read();
360
378
  if (done) break;
361
379
  buffer += decoder.decode(value, { stream: true });
362
- let idx = buffer.indexOf("\n\n");
363
- while (idx !== -1) {
380
+ let boundary = SSE_EVENT_BOUNDARY.exec(buffer);
381
+ while (boundary) {
382
+ const idx = boundary.index;
364
383
  const chunk = buffer.slice(0, idx);
365
- buffer = buffer.slice(idx + 2);
384
+ buffer = buffer.slice(idx + boundary[0].length);
366
385
  const data = chunk
367
- .split("\n")
386
+ .split(/\r?\n/)
368
387
  .filter((line) => line.startsWith("data:"))
369
388
  .map((line) => line.slice(5).trim())
370
389
  .join("\n")
@@ -377,36 +396,34 @@ async function parseSseForImage(
377
396
  event = undefined;
378
397
  }
379
398
  const image = extractImageFromEvent(event, fallbackMimeType);
380
- if (image?.data) {
381
- lastImage = image;
382
- if (image.status === "completed") {
383
- await reader.cancel().catch(() => undefined);
384
- return image;
385
- }
399
+ if (image?.data && image.status === "completed") {
400
+ await reader.cancel().catch(() => undefined);
401
+ return image;
386
402
  }
387
403
  if (isRecord(event) && event.type === "response.failed") {
388
404
  const error =
389
405
  isRecord(event.response) && isRecord(event.response.error)
390
406
  ? event.response.error
391
407
  : undefined;
392
- const message =
393
- typeof error?.message === "string" ? error.message : "Codex image request failed.";
408
+ const message = sanitizeDiagnosticError(
409
+ typeof error?.message === "string" ? error.message : "Codex image request failed.",
410
+ );
394
411
  throw new Error(message);
395
412
  }
396
413
  if (isRecord(event) && event.type === "error") {
397
- const message =
398
- typeof event.message === "string" ? event.message : JSON.stringify(event);
414
+ const message = sanitizeDiagnosticError(
415
+ typeof event.message === "string" ? event.message : JSON.stringify(event),
416
+ );
399
417
  throw new Error(`Codex image error: ${message}`);
400
418
  }
401
419
  }
402
- idx = buffer.indexOf("\n\n");
420
+ boundary = SSE_EVENT_BOUNDARY.exec(buffer);
403
421
  }
404
422
  }
405
423
  } finally {
406
424
  reader.releaseLock();
407
425
  }
408
- if (lastImage) return lastImage;
409
- throw new Error("No image_generation_call result returned by Codex.");
426
+ throw new Error("No completed image_generation_call result returned by Codex.");
410
427
  }
411
428
 
412
429
  async function requestCodexImage(
@@ -416,14 +433,16 @@ async function requestCodexImage(
416
433
  requestSignal?: AbortSignal,
417
434
  ): Promise<CodexImageResult> {
418
435
  if (!cfg.image.enabled) throw new Error("OpenAI image generation is disabled in config.");
419
- const credentials = await getCredentials(ctx);
436
+ const cwd = ctx.cwd || process.cwd();
420
437
  const model = resolveModel(params, ctx, cfg);
421
438
  const { action, outputFormat, save } = resolveImageConfig(cfg, params);
422
- const images = await readImageInputs(params.images, ctx.cwd || process.cwd());
423
- const request = buildRequest(params, model, cfg, images);
439
+ const saveDir = resolveSaveDir(save, params, cwd);
424
440
  const timeoutSignal = AbortSignal.timeout(cfg.image.timeoutMs);
425
441
  const baseSignal = requestSignal ?? ctx.signal;
426
442
  const signal = baseSignal ? AbortSignal.any([baseSignal, timeoutSignal]) : timeoutSignal;
443
+ const credentials = await getCredentials(ctx, signal);
444
+ const images = await readImageInputs(params.images, cwd);
445
+ const request = buildRequest(params, model, cfg, images);
427
446
  const response = await fetch(CODEX_RESPONSES_URL, {
428
447
  method: "POST",
429
448
  headers: {
@@ -439,17 +458,16 @@ async function requestCodexImage(
439
458
  signal,
440
459
  });
441
460
  if (!response.ok) {
442
- const text = await response.text().catch(() => "");
443
- throw new Error(
444
- `Codex image request failed (${response.status}): ${text || response.statusText}`,
445
- );
461
+ const statusText = response.statusText
462
+ ? ` ${sanitizeDiagnosticError(response.statusText, 120)}`
463
+ : "";
464
+ throw new Error(`Codex image request failed (${response.status}${statusText}).`);
446
465
  }
447
466
  const parsed = await parseSseForImage(
448
467
  response,
449
468
  imageMimeType(`image.${outputFormat}`, outputFormat),
450
469
  signal,
451
470
  );
452
- const saveDir = resolveSaveDir(save, params, ctx.cwd || process.cwd());
453
471
  const savedPath = saveDir
454
472
  ? await saveImage(parsed.data, outputFormat, saveDir, parsed.id)
455
473
  : undefined;
@@ -464,37 +482,6 @@ function displayPath(path: string): string {
464
482
  return path.startsWith(homePrefix) ? `~/${path.slice(homePrefix.length)}` : path;
465
483
  }
466
484
 
467
- function textFromMessageContent(content: unknown): string | undefined {
468
- if (typeof content === "string") return content.trim() || undefined;
469
- if (!Array.isArray(content)) return undefined;
470
- const text = content
471
- .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
472
- .map((part) => (part as { text: string }).text)
473
- .join("\n")
474
- .trim();
475
- return text || undefined;
476
- }
477
-
478
- function latestUserPromptFromEntries(entries: unknown[]): string | undefined {
479
- for (let i = entries.length - 1; i >= 0; i--) {
480
- const entry = entries[i];
481
- if (
482
- !isRecord(entry) ||
483
- entry.type !== "message" ||
484
- !isRecord(entry.message) ||
485
- entry.message.role !== "user"
486
- )
487
- continue;
488
- const text = textFromMessageContent(entry.message.content);
489
- if (text) return text;
490
- }
491
- return undefined;
492
- }
493
-
494
- function resolveToolPrompt(params: ToolParams, ctx: ExtensionContext): string {
495
- return latestUserPromptFromEntries(ctx.sessionManager.getEntries()) ?? params.prompt;
496
- }
497
-
498
485
  function resultText(result: CodexImageResult): string {
499
486
  const parts = [
500
487
  `Generated image using OpenAI image_generation tool via openai-codex/${result.model}.`,
@@ -526,7 +513,7 @@ export function registerOpenAIImage(
526
513
  return result;
527
514
  } catch (error) {
528
515
  lastStatus = "error";
529
- lastError = error instanceof Error ? error.message : String(error);
516
+ lastError = sanitizeDiagnosticError(error instanceof Error ? error.message : String(error));
530
517
  throw error;
531
518
  }
532
519
  }
@@ -542,7 +529,7 @@ export function registerOpenAIImage(
542
529
  return {
543
530
  authFound: credentials !== undefined,
544
531
  authSource: credentials?.source,
545
- accountId: credentials?.accountId,
532
+ accountId: maskIdentifier(credentials?.accountId),
546
533
  endpoint: CODEX_RESPONSES_URL,
547
534
  defaultModel: ctx.model?.provider === "openai-codex" ? ctx.model.id : cfg.image.defaultModel,
548
535
  defaultSave: cfg.image.defaultSave,
@@ -552,63 +539,57 @@ export function registerOpenAIImage(
552
539
  };
553
540
  }
554
541
 
555
- void import("@mariozechner/pi-tui")
556
- .then(({ Box, Container, Image, Text }) => {
557
- pi.registerMessageRenderer<CodexImageResult>("openai-image", (message, _options, theme) => {
558
- const result = message.details;
559
- const text =
560
- result && isRecord(result)
561
- ? resultText(result as CodexImageResult)
562
- : typeof message.content === "string"
563
- ? message.content
564
- : message.content
565
- .filter((part) => part.type === "text")
566
- .map((part) => part.text)
567
- .join("\n");
568
- let image: { data: string; mimeType: string; savedPath?: string } | undefined;
569
- if (
570
- result &&
571
- isRecord(result) &&
572
- typeof result.data === "string" &&
573
- typeof result.mimeType === "string"
574
- ) {
575
- image = {
576
- data: result.data,
577
- mimeType: result.mimeType,
578
- savedPath: typeof result.savedPath === "string" ? result.savedPath : undefined,
579
- };
580
- } else if (Array.isArray(message.content)) {
581
- const imagePart = message.content.find(isImageContent);
582
- if (imagePart) image = { data: imagePart.data, mimeType: imagePart.mimeType };
583
- }
542
+ pi.registerMessageRenderer<CodexImageResult>("openai-image", (message, _options, theme) => {
543
+ const result = message.details;
544
+ const text =
545
+ result && isRecord(result)
546
+ ? resultText(result as CodexImageResult)
547
+ : typeof message.content === "string"
548
+ ? message.content
549
+ : message.content
550
+ .filter((part) => part.type === "text")
551
+ .map((part) => part.text)
552
+ .join("\n");
553
+ let image: { data: string; mimeType: string; savedPath?: string } | undefined;
554
+ if (
555
+ result &&
556
+ isRecord(result) &&
557
+ typeof result.data === "string" &&
558
+ typeof result.mimeType === "string"
559
+ ) {
560
+ image = {
561
+ data: result.data,
562
+ mimeType: result.mimeType,
563
+ savedPath: typeof result.savedPath === "string" ? result.savedPath : undefined,
564
+ };
565
+ } else if (Array.isArray(message.content)) {
566
+ const imagePart = message.content.find(isImageContent);
567
+ if (imagePart) image = { data: imagePart.data, mimeType: imagePart.mimeType };
568
+ }
584
569
 
585
- const container = new Container();
586
- const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
587
- box.addChild(
588
- new Text(`${theme.fg("accent", theme.bold("[openai-image]"))}\n\n${text}`, 0, 0),
589
- );
590
- if (image) {
591
- box.addChild(
592
- new Image(
593
- image.data,
594
- image.mimeType,
595
- { fallbackColor: (line) => theme.fg("dim", line) },
596
- {
597
- maxWidthCells: 80,
598
- maxHeightCells: 24,
599
- filename:
600
- "savedPath" in image && typeof image.savedPath === "string"
601
- ? image.savedPath
602
- : undefined,
603
- },
604
- ),
605
- );
606
- }
607
- container.addChild(box);
608
- return container;
609
- });
610
- })
611
- .catch(() => undefined);
570
+ const container = new Container();
571
+ const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
572
+ box.addChild(new Text(`${theme.fg("accent", theme.bold("[openai-image]"))}\n\n${text}`, 0, 0));
573
+ if (image) {
574
+ box.addChild(
575
+ new Image(
576
+ image.data,
577
+ image.mimeType,
578
+ { fallbackColor: (line) => theme.fg("dim", line) },
579
+ {
580
+ maxWidthCells: 80,
581
+ maxHeightCells: 24,
582
+ filename:
583
+ "savedPath" in image && typeof image.savedPath === "string"
584
+ ? image.savedPath
585
+ : undefined,
586
+ },
587
+ ),
588
+ );
589
+ }
590
+ container.addChild(box);
591
+ return container;
592
+ });
612
593
 
613
594
  pi.registerCommand(OPENAI_IMAGE_COMMAND, {
614
595
  description: "Generate an image with OpenAI Codex image generation",
@@ -647,7 +628,6 @@ export function registerOpenAIImage(
647
628
  async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
648
629
  const cfg = getConfig(ctx);
649
630
  const model = resolveModel(params, ctx, cfg);
650
- const requestParams = { ...params, prompt: resolveToolPrompt(params, ctx) };
651
631
  onUpdate?.({
652
632
  content: [
653
633
  {
@@ -657,7 +637,7 @@ export function registerOpenAIImage(
657
637
  ],
658
638
  details: undefined,
659
639
  });
660
- const result = await generate(requestParams, ctx, signal);
640
+ const result = await generate(params, ctx, signal);
661
641
  return {
662
642
  content: [
663
643
  { type: "text", text: resultText(result) },
@@ -676,11 +656,13 @@ export const _imageTest = {
676
656
  DEFAULT_TIMEOUT_MS,
677
657
  OPENAI_IMAGE_TOOL,
678
658
  OPENAI_IMAGE_COMMAND,
659
+ MAX_IMAGE_INPUT_BYTES,
660
+ MAX_IMAGE_INPUTS,
661
+ MAX_TOTAL_IMAGE_INPUT_BYTES,
679
662
  extractAccountIdFromJwt,
680
663
  imageMimeType,
681
664
  dataUrlParts,
682
665
  extractImageFromEvent,
683
666
  displayPath,
684
- latestUserPromptFromEntries,
685
667
  buildRequest,
686
668
  };
package/src/paths.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export function expandTildePath(path: string, home = homedir()): string {
5
+ if (path === "~") return home;
6
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
7
+ return path;
8
+ }
9
+
10
+ export function piAgentDir(env = process.env, home = homedir()): string {
11
+ const configuredDir = env.PI_CODING_AGENT_DIR?.trim();
12
+ return configuredDir ? expandTildePath(configuredDir, home) : join(home, ".pi", "agent");
13
+ }
14
+
15
+ export function resolveUserPath(path: string, cwd: string, home = homedir()): string {
16
+ return resolve(cwd, expandTildePath(path, home));
17
+ }