pi-openai-toolkit 0.7.2 → 0.9.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.
@@ -1,5 +1,5 @@
1
1
  import { writeDebugArtifact } from "./debug";
2
- import { mergeProviderHeaders } from "./provider-headers";
2
+ import { buildResponsesRequestHeaders } from "./responses-headers";
3
3
  import type { NativeCompactionRuntime } from "./runtime";
4
4
  import type { NativeCompactionRequestBody } from "./serializer";
5
5
  import type { ArtifactContext, CompactionConfig } from "./types";
@@ -112,57 +112,10 @@ export function extractCompactedSummaryText(output: readonly unknown[]): string
112
112
  return joined.length > 0 ? joined : undefined;
113
113
  }
114
114
 
115
- function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
116
- const parts = token.split(".");
117
- if (parts.length !== 3) {
118
- return undefined;
119
- }
120
-
121
- try {
122
- const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
123
- const payload = JSON.parse(payloadText);
124
- return isRecord(payload) ? payload : undefined;
125
- } catch {
126
- return undefined;
127
- }
128
- }
129
-
130
- function extractCodexAccountId(token: string): string | undefined {
131
- const payload = decodeJwtPayload(token);
132
- const authClaims = payload?.["https://api.openai.com/auth"];
133
- if (!isRecord(authClaims)) {
134
- return undefined;
135
- }
136
-
137
- const accountId = authClaims.chatgpt_account_id;
138
- return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
139
- }
140
-
141
- function buildCodexUserAgent(): string {
142
- const platform = typeof process !== "undefined" ? process.platform : "browser";
143
- const arch = typeof process !== "undefined" ? process.arch : "unknown";
144
- return `pi (${platform}; ${arch})`;
145
- }
146
-
147
115
  function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
148
- const headers = new Headers(mergeProviderHeaders(runtime.currentModel.headers, runtime.headers));
149
- headers.set("accept", JSON_CONTENT_TYPE);
150
- headers.set("content-type", JSON_CONTENT_TYPE);
151
- if (!headers.has("authorization")) {
152
- headers.set("authorization", `Bearer ${runtime.apiKey}`);
153
- }
154
-
155
- if (runtime.api === "openai-codex-responses") {
156
- const accountId = extractCodexAccountId(runtime.apiKey);
157
- if (accountId) {
158
- headers.set("chatgpt-account-id", accountId);
159
- }
160
- headers.set("originator", "pi");
161
- headers.set("user-agent", buildCodexUserAgent());
162
- headers.set("openai-beta", "responses=experimental");
163
- }
164
-
165
- return Object.fromEntries(headers.entries());
116
+ return Object.fromEntries(
117
+ buildResponsesRequestHeaders(runtime, { accept: JSON_CONTENT_TYPE }).entries(),
118
+ );
166
119
  }
167
120
 
168
121
  function writeCompactArtifact(
package/src/config.ts CHANGED
@@ -6,12 +6,21 @@ import {
6
6
  DEFAULT_COMPACTION_CONFIG,
7
7
  DEFAULT_TOOLKIT_CONFIG,
8
8
  DEFAULT_AUTO_COMPACTION_CONFIG,
9
+ DEFAULT_AUTO_MODE_CONFIG,
10
+ DEFAULT_IMAGE_GENERATION_CONFIG,
11
+ DEFAULT_NATIVE_FALLBACK_CONFIG,
9
12
  DEFAULT_WEB_SEARCH_CONFIG,
10
13
  RESPONSES_COMPACT_CAPABLE_APIS,
14
+ REVIEWER_TIMEOUT_MAX_MS,
15
+ REVIEWER_TIMEOUT_MIN_MS,
11
16
  THINKING_LEVELS,
12
17
  TOOLKIT_ID,
18
+ type AutoModeConfig,
19
+ type AutoModeGate,
13
20
  type CompactionConfig,
21
+ type ImageGenerationConfig,
14
22
  type LoadedToolkitConfig,
23
+ type NativeFallbackConfig,
15
24
  type ToolkitConfig,
16
25
  type WebSearchConfig,
17
26
  } from "./types";
@@ -19,13 +28,12 @@ import {
19
28
  export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
20
29
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
21
30
 
22
- const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"]);
31
+ const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode"]);
23
32
  const COMPACTION_FIELDS = new Set([
24
33
  "enabled",
25
34
  "allowCompactionContinuityBreak",
26
35
  "remoteCompactModel",
27
- "model",
28
- "thinkingLevel",
36
+ "nativeFallback",
29
37
  "autoCompaction",
30
38
  "responsesApis",
31
39
  "notifyOnLoad",
@@ -35,8 +43,11 @@ const COMPACTION_FIELDS = new Set([
35
43
  "redactSensitiveData",
36
44
  "artifactRoot",
37
45
  ]);
46
+ const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
38
47
  const AUTO_COMPACTION_FIELDS = new Set(["enabled", "continuation", "unsupportedFallback", "reserveTokens"]);
39
48
  const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
49
+ const IMAGE_GENERATION_FIELDS = new Set(["enabled"]);
50
+ const AUTO_MODE_FIELDS = new Set(["enabled", "models", "reviewerModel", "gate", "extraTools", "timeoutMs"]);
40
51
 
41
52
  function isRecord(value: unknown): value is Record<string, unknown> {
42
53
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -172,7 +183,7 @@ function toSupportedApis(
172
183
  return accepted;
173
184
  }
174
185
 
175
- function toModelAllowlist(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
186
+ function toStringList(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
176
187
  if (value === undefined) return undefined;
177
188
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
178
189
  warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
@@ -182,10 +193,34 @@ function toModelAllowlist(value: unknown, fieldPath: string, warnings: string[])
182
193
  return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
183
194
  }
184
195
 
196
+ function toAutoModeGate(value: unknown, fieldPath: string, warnings: string[]): AutoModeGate | undefined {
197
+ if (value === undefined) return undefined;
198
+ if (value === "side-effect" || value === "all") return value;
199
+ warnings.push(`Ignoring ${fieldPath}: expected one of side-effect, all.`);
200
+ return undefined;
201
+ }
202
+
203
+ function toReviewerTimeoutMs(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
204
+ if (value === undefined) return undefined;
205
+ if (
206
+ typeof value === "number" &&
207
+ Number.isInteger(value) &&
208
+ value >= REVIEWER_TIMEOUT_MIN_MS &&
209
+ value <= REVIEWER_TIMEOUT_MAX_MS
210
+ ) {
211
+ return value;
212
+ }
213
+ warnings.push(
214
+ `Ignoring ${fieldPath}: expected an integer between ${REVIEWER_TIMEOUT_MIN_MS} and ${REVIEWER_TIMEOUT_MAX_MS} ms.`,
215
+ );
216
+ return undefined;
217
+ }
218
+
185
219
  function cloneDefaults(): ToolkitConfig {
186
220
  return {
187
221
  compaction: {
188
222
  ...DEFAULT_COMPACTION_CONFIG,
223
+ nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
189
224
  autoCompaction: { ...DEFAULT_AUTO_COMPACTION_CONFIG },
190
225
  responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
191
226
  },
@@ -193,9 +228,34 @@ function cloneDefaults(): ToolkitConfig {
193
228
  ...DEFAULT_WEB_SEARCH_CONFIG,
194
229
  models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
195
230
  },
231
+ imageGeneration: { ...DEFAULT_IMAGE_GENERATION_CONFIG },
232
+ autoMode: {
233
+ ...DEFAULT_AUTO_MODE_CONFIG,
234
+ models: [...DEFAULT_AUTO_MODE_CONFIG.models],
235
+ extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
236
+ },
196
237
  };
197
238
  }
198
239
 
240
+ function applyNativeFallbackConfig(
241
+ raw: Record<string, unknown>,
242
+ resolved: NativeFallbackConfig,
243
+ warnings: string[],
244
+ ): void {
245
+ warnUnknownFields(raw, NATIVE_FALLBACK_FIELDS, "compaction.nativeFallback", warnings);
246
+ resolved.enabled =
247
+ toBoolean(raw.enabled, "compaction.nativeFallback.enabled", warnings) ?? resolved.enabled;
248
+
249
+ const modelSpec = toModelSpec(raw.model, "compaction.nativeFallback.model", warnings);
250
+ if (modelSpec !== undefined) {
251
+ resolved.model = modelSpec === null ? undefined : modelSpec;
252
+ }
253
+
254
+ resolved.thinkingLevel =
255
+ toThinkingLevel(raw.thinkingLevel, "compaction.nativeFallback.thinkingLevel", warnings) ??
256
+ resolved.thinkingLevel;
257
+ }
258
+
199
259
  function applyAutoCompactionConfig(
200
260
  raw: Record<string, unknown>,
201
261
  resolved: CompactionConfig["autoCompaction"],
@@ -258,14 +318,14 @@ function applyCompactionConfig(
258
318
  resolved.remoteCompactModel = remoteCompactModelSpec === null ? undefined : remoteCompactModelSpec;
259
319
  }
260
320
 
261
- const modelSpec = toModelSpec(raw.model, "compaction.model", warnings);
262
- if (modelSpec !== undefined) {
263
- resolved.model = modelSpec === null ? undefined : modelSpec;
321
+ if (raw.nativeFallback !== undefined) {
322
+ if (isRecord(raw.nativeFallback)) {
323
+ applyNativeFallbackConfig(raw.nativeFallback, resolved.nativeFallback, warnings);
324
+ } else {
325
+ warnings.push("Ignoring compaction.nativeFallback: expected a JSON object.");
326
+ }
264
327
  }
265
328
 
266
- resolved.thinkingLevel =
267
- toThinkingLevel(raw.thinkingLevel, "compaction.thinkingLevel", warnings) ?? resolved.thinkingLevel;
268
-
269
329
  if (raw.autoCompaction !== undefined) {
270
330
  if (isRecord(raw.autoCompaction)) {
271
331
  applyAutoCompactionConfig(raw.autoCompaction, resolved.autoCompaction, warnings);
@@ -299,12 +359,53 @@ function applyWebSearchConfig(
299
359
  warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
300
360
  resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
301
361
 
302
- const models = toModelAllowlist(raw.models, "webSearch.models", warnings);
362
+ const models = toStringList(raw.models, "webSearch.models", warnings);
303
363
  if (models !== undefined) {
304
364
  resolved.models = models;
305
365
  }
306
366
  }
307
367
 
368
+ function applyImageGenerationConfig(
369
+ raw: Record<string, unknown>,
370
+ resolved: ImageGenerationConfig,
371
+ warnings: string[],
372
+ ): void {
373
+ warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
374
+ resolved.enabled =
375
+ toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
376
+ }
377
+
378
+ function applyAutoModeConfig(
379
+ raw: Record<string, unknown>,
380
+ resolved: AutoModeConfig,
381
+ warnings: string[],
382
+ ): void {
383
+ warnUnknownFields(raw, AUTO_MODE_FIELDS, "autoMode", warnings);
384
+ resolved.enabled = toBoolean(raw.enabled, "autoMode.enabled", warnings) ?? resolved.enabled;
385
+
386
+ const models = toStringList(raw.models, "autoMode.models", warnings);
387
+ if (models !== undefined) {
388
+ resolved.models = models;
389
+ }
390
+
391
+ const reviewerModel = toModelSpec(raw.reviewerModel, "autoMode.reviewerModel", warnings);
392
+ if (reviewerModel !== undefined) {
393
+ resolved.reviewerModel = reviewerModel === null ? undefined : reviewerModel;
394
+ }
395
+
396
+ resolved.gate = toAutoModeGate(raw.gate, "autoMode.gate", warnings) ?? resolved.gate;
397
+
398
+ const extraTools = toStringList(raw.extraTools, "autoMode.extraTools", warnings);
399
+ if (extraTools !== undefined) {
400
+ resolved.extraTools = extraTools;
401
+ }
402
+
403
+ const timeoutMs = toReviewerTimeoutMs(raw.timeoutMs, "autoMode.timeoutMs", warnings);
404
+ if (timeoutMs !== undefined) {
405
+ resolved.timeoutMs = timeoutMs;
406
+ }
407
+ }
408
+
308
409
  /**
309
410
  * Load the canonical toolkit config from
310
411
  * `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
@@ -335,6 +436,22 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
335
436
  warnings.push("Ignoring webSearch: expected a JSON object.");
336
437
  }
337
438
  }
439
+
440
+ if (raw.imageGeneration !== undefined) {
441
+ if (isRecord(raw.imageGeneration)) {
442
+ applyImageGenerationConfig(raw.imageGeneration, resolved.imageGeneration, warnings);
443
+ } else {
444
+ warnings.push("Ignoring imageGeneration: expected a JSON object.");
445
+ }
446
+ }
447
+
448
+ if (raw.autoMode !== undefined) {
449
+ if (isRecord(raw.autoMode)) {
450
+ applyAutoModeConfig(raw.autoMode, resolved.autoMode, warnings);
451
+ } else {
452
+ warnings.push("Ignoring autoMode: expected a JSON object.");
453
+ }
454
+ }
338
455
  }
339
456
 
340
457
  resolved.compaction.artifactRoot = resolveConfiguredPath(
@@ -316,6 +316,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
316
316
  }
317
317
 
318
318
  // Branch 1: Responses-family APIs use remote_compaction_v2 on the normal Responses stream.
319
+ let remoteAttempted = false;
319
320
  const resolution = await resolveRemoteCompactionExecution(
320
321
  ctx,
321
322
  {
@@ -325,6 +326,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
325
326
  config.remoteCompactModel,
326
327
  );
327
328
  if (resolution.ok) {
329
+ remoteAttempted = true;
328
330
  const responsesOutcome = await runResponsesNativeCompact(event, ctx, config, resolution.execution);
329
331
  if (responsesOutcome.outcome === "success") {
330
332
  return { compaction: responsesOutcome.compaction };
@@ -357,8 +359,10 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
357
359
  }
358
360
  }
359
361
 
360
- // Branch 2: run pi's native compaction method with the configured model.
361
- const fallback = await runNativeFallbackCompaction({ ctx, event, config });
362
+ // Branch 2: run pi's native compaction method. A failed remote request is compacted by the
363
+ // remote producer itself; a model that cannot use remote v2 at all uses nativeFallback.model.
364
+ const fallbackModelSpec = remoteAttempted ? config.remoteCompactModel : config.nativeFallback.model;
365
+ const fallback = await runNativeFallbackCompaction({ ctx, event, config, modelSpec: fallbackModelSpec });
362
366
  if (fallback.ok) {
363
367
  if (ctx.hasUI) {
364
368
  ctx.ui.notify(
@@ -394,8 +398,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
394
398
  ctx,
395
399
  );
396
400
 
397
- // Intentional pi-default paths: no configured model, or it matches the current one.
398
- if (fallback.reason !== "no-model-configured" && fallback.reason !== "same-as-current-model") {
401
+ // Intentional pi-default paths: feature disabled, nothing configured, or it matches the current one.
402
+ const intentionalSkip =
403
+ fallback.reason === "disabled" ||
404
+ fallback.reason === "no-model-configured" ||
405
+ fallback.reason === "same-as-current-model";
406
+ if (!intentionalSkip) {
399
407
  notifyWarning(
400
408
  ctx,
401
409
  `compaction model "${fallback.modelSpec}" unusable (${fallback.reason}${fallback.errorMessage ? `: ${fallback.errorMessage}` : ""}); using pi's default compaction`,
@@ -0,0 +1,237 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import {
3
+ link,
4
+ mkdir,
5
+ open,
6
+ realpath,
7
+ stat,
8
+ unlink,
9
+ } from "node:fs/promises";
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ basename,
13
+ dirname,
14
+ extname,
15
+ isAbsolute,
16
+ join,
17
+ relative,
18
+ resolve,
19
+ sep,
20
+ } from "node:path";
21
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ MAX_IMAGE_PATH_CHARS,
24
+ ImageGenerationError,
25
+ sanitizeImageDiagnostic,
26
+ } from "./types";
27
+
28
+ export type ExplicitOutputPlan = {
29
+ path: string;
30
+ };
31
+
32
+ function isPathInsideOrEqual(root: string, candidate: string): boolean {
33
+ const rel = relative(root, candidate);
34
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
35
+ }
36
+
37
+ function sanitizePathPart(value: string, fallback: string): string {
38
+ const sanitized = value
39
+ .trim()
40
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
41
+ .replace(/^[.-]+|[.-]+$/g, "")
42
+ .slice(0, 120);
43
+ return sanitized || fallback;
44
+ }
45
+
46
+ async function realDirectory(path: string): Promise<string> {
47
+ return realpath(path).catch(() => resolve(path));
48
+ }
49
+
50
+ async function resolveWriteTarget(candidate: string): Promise<string> {
51
+ const suffix: string[] = [];
52
+ let current = resolve(candidate);
53
+ while (true) {
54
+ try {
55
+ const info = await stat(current);
56
+ if (suffix.length > 0 && !info.isDirectory()) {
57
+ throw new ImageGenerationError(
58
+ "output-path-invalid",
59
+ `Output parent is not a directory: ${current}`,
60
+ );
61
+ }
62
+ const existing = await realpath(current);
63
+ return resolve(existing, ...suffix);
64
+ } catch (error) {
65
+ if (error instanceof ImageGenerationError) throw error;
66
+ if (
67
+ error &&
68
+ typeof error === "object" &&
69
+ "code" in error &&
70
+ error.code !== "ENOENT"
71
+ ) {
72
+ throw new ImageGenerationError(
73
+ "output-path-invalid",
74
+ `Unable to inspect output path: ${candidate}`,
75
+ );
76
+ }
77
+ const parent = dirname(current);
78
+ if (parent === current) {
79
+ throw new ImageGenerationError(
80
+ "output-path-invalid",
81
+ `Unable to resolve output path: ${candidate}`,
82
+ );
83
+ }
84
+ suffix.unshift(basename(current));
85
+ current = parent;
86
+ }
87
+ }
88
+ }
89
+
90
+ function throwIfAborted(signal: AbortSignal | undefined): void {
91
+ if (signal?.aborted) {
92
+ throw new ImageGenerationError("aborted", "Image generation was cancelled.");
93
+ }
94
+ }
95
+
96
+ export function getGeneratedImagesRoot(agentDir: string): string {
97
+ return join(resolve(agentDir), "generated-images");
98
+ }
99
+
100
+ export async function prepareExplicitOutputPath(args: {
101
+ rawPath?: string;
102
+ agentDir: string;
103
+ ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "isProjectTrusted" | "ui">;
104
+ signal?: AbortSignal;
105
+ }): Promise<ExplicitOutputPlan | undefined> {
106
+ if (args.rawPath === undefined) return undefined;
107
+ const trimmed = args.rawPath.trim();
108
+ if (!trimmed || trimmed.length > MAX_IMAGE_PATH_CHARS || extname(trimmed).toLowerCase() !== ".png") {
109
+ throw new ImageGenerationError(
110
+ "output-path-invalid",
111
+ "outputPath must be a non-empty .png file path.",
112
+ );
113
+ }
114
+
115
+ throwIfAborted(args.signal);
116
+ const lexical = isAbsolute(trimmed) ? resolve(trimmed) : resolve(args.ctx.cwd, trimmed);
117
+ const target = await resolveWriteTarget(lexical);
118
+ const targetInfo = await stat(target).catch(() => undefined);
119
+ if (targetInfo) {
120
+ throw new ImageGenerationError(
121
+ "output-path-invalid",
122
+ `Output path already exists and will not be overwritten: ${target}`,
123
+ );
124
+ }
125
+
126
+ const [agentRoot, projectRoot] = await Promise.all([
127
+ realDirectory(args.agentDir),
128
+ realDirectory(args.ctx.cwd),
129
+ ]);
130
+ if (isPathInsideOrEqual(agentRoot, target)) return { path: target };
131
+
132
+ if (isPathInsideOrEqual(projectRoot, target)) {
133
+ if (!args.ctx.isProjectTrusted()) {
134
+ throw new ImageGenerationError(
135
+ "output-path-invalid",
136
+ "Writing generated images inside an untrusted project is not allowed.",
137
+ );
138
+ }
139
+ return { path: target };
140
+ }
141
+
142
+ if (!args.ctx.hasUI) {
143
+ throw new ImageGenerationError(
144
+ "output-path-approval-required",
145
+ "Writing outside the Pi agent root or trusted project requires interactive approval.",
146
+ );
147
+ }
148
+ const approved = await args.ctx.ui.confirm(
149
+ "Write generated image outside safe roots?",
150
+ [
151
+ "The generated PNG will be copied to this external path without overwriting an existing file:",
152
+ "",
153
+ target,
154
+ "",
155
+ "Continue?",
156
+ ].join("\n"),
157
+ { signal: args.signal },
158
+ );
159
+ throwIfAborted(args.signal);
160
+ if (!approved) {
161
+ throw new ImageGenerationError("output-path-declined", "External output path was declined.");
162
+ }
163
+ return { path: target };
164
+ }
165
+
166
+ async function publishBytesNoOverwrite(bytes: Uint8Array, target: string): Promise<void> {
167
+ const parent = dirname(target);
168
+ await mkdir(parent, { recursive: true });
169
+ const tempPath = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
170
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
171
+ try {
172
+ handle = await open(tempPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
173
+ await handle.writeFile(bytes);
174
+ await handle.sync();
175
+ await handle.close();
176
+ handle = undefined;
177
+ await link(tempPath, target);
178
+ } finally {
179
+ await handle?.close().catch(() => undefined);
180
+ await unlink(tempPath).catch(() => undefined);
181
+ }
182
+ }
183
+
184
+ function isAlreadyExistsError(error: unknown): boolean {
185
+ return !!error && typeof error === "object" && "code" in error && error.code === "EEXIST";
186
+ }
187
+
188
+ export async function saveCanonicalImage(args: {
189
+ bytes: Uint8Array;
190
+ agentDir: string;
191
+ sessionId: string;
192
+ imageCallId: string;
193
+ }): Promise<string> {
194
+ const root = getGeneratedImagesRoot(args.agentDir);
195
+ const sessionPart = sanitizePathPart(args.sessionId, "session");
196
+ const imagePart = sanitizePathPart(args.imageCallId, "image_generation");
197
+ const directory = join(root, sessionPart);
198
+
199
+ for (let index = 1; index <= 1000; index += 1) {
200
+ const suffix = index === 1 ? "" : `-${index}`;
201
+ const target = join(directory, `${imagePart}${suffix}.png`);
202
+ try {
203
+ await publishBytesNoOverwrite(args.bytes, target);
204
+ return target;
205
+ } catch (error) {
206
+ if (isAlreadyExistsError(error)) continue;
207
+ throw new ImageGenerationError(
208
+ "artifact-write-failed",
209
+ sanitizeImageDiagnostic(
210
+ error instanceof Error ? error.message : error,
211
+ "Failed to persist the generated image artifact.",
212
+ ),
213
+ );
214
+ }
215
+ }
216
+ throw new ImageGenerationError(
217
+ "artifact-write-failed",
218
+ "Failed to reserve a unique generated image artifact path.",
219
+ );
220
+ }
221
+
222
+ export async function copyImageToExplicitPath(args: {
223
+ bytes: Uint8Array;
224
+ plan: ExplicitOutputPlan;
225
+ }): Promise<void> {
226
+ const currentTarget = await resolveWriteTarget(args.plan.path);
227
+ if (currentTarget !== args.plan.path) {
228
+ throw new Error("Output path changed after approval.");
229
+ }
230
+ await publishBytesNoOverwrite(args.bytes, args.plan.path);
231
+ }
232
+
233
+ export const _artifactTest = {
234
+ isPathInsideOrEqual,
235
+ publishBytesNoOverwrite,
236
+ resolveWriteTarget,
237
+ };