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.
- package/README.md +81 -8
- package/README_zh.md +84 -8
- package/extensions/auto-mode.ts +1 -0
- package/extensions/image-generation.ts +1 -0
- package/package.json +27 -4
- package/src/auto-mode/extension.ts +273 -0
- package/src/auto-mode/policy.ts +77 -0
- package/src/auto-mode/reviewer.ts +239 -0
- package/src/auto-mode/types.ts +58 -0
- package/src/compact-client.ts +4 -51
- package/src/config.ts +128 -11
- package/src/extension-runtime.ts +12 -4
- package/src/image-generation/artifacts.ts +237 -0
- package/src/image-generation/client.ts +248 -0
- package/src/image-generation/eligibility.ts +18 -0
- package/src/image-generation/extension.ts +160 -0
- package/src/image-generation/protocol.ts +405 -0
- package/src/image-generation/references.ts +194 -0
- package/src/image-generation/render.ts +252 -0
- package/src/image-generation/service.ts +243 -0
- package/src/image-generation/types.ts +184 -0
- package/src/model-scope.ts +17 -0
- package/src/native-fallback.ts +19 -6
- package/src/remote-v2-client.ts +7 -51
- package/src/responses-headers.ts +67 -0
- package/src/runtime.ts +43 -4
- package/src/types.ts +65 -9
- package/src/web-search/types.ts +3 -5
- package/tsconfig.check.json +17 -0
package/src/compact-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeDebugArtifact } from "./debug";
|
|
2
|
-
import {
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 =
|
|
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(
|
package/src/extension-runtime.ts
CHANGED
|
@@ -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
|
|
361
|
-
|
|
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:
|
|
398
|
-
|
|
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
|
+
};
|