oira666_pi-image-generation 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oira666
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ pi install npm:oira666_pi-image-generation
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "oira666_pi-image-generation",
3
+ "version": "0.0.1",
4
+ "description": "Image generation and reference-image editing for pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/gee666/pi-image-generation.git"
10
+ },
11
+ "keywords": ["pi-package", "pi-extension", "image-generation"],
12
+ "files": ["src", "resources", "README.md", "LICENSE"],
13
+ "pi": {
14
+ "extensions": ["./src/index.ts"],
15
+ "skills": []
16
+ },
17
+ "engines": { "node": ">=22" },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*",
20
+ "typebox": "*"
21
+ },
22
+ "dependencies": {
23
+ "pngjs": "^7.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/pngjs": "^6.0.5",
27
+ "@earendil-works/pi-coding-agent": "0.86.0",
28
+ "@types/node": "^22.0.0",
29
+ "tsx": "^4.20.0",
30
+ "typebox": "^1.0.0",
31
+ "typescript": "^5.9.0"
32
+ },
33
+ "scripts": {
34
+ "check": "tsc --noEmit",
35
+ "test": "tsx --test test/*.test.ts",
36
+ "prepublishOnly": "npm run check && npm test"
37
+ }
38
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: imagegen
3
+ description: Generate and edit raster images with image_gen, including photos, illustrations, textures, sprites, product shots, mockups, and consistent visual variants. Use reference images to preserve identity, style, composition, or objects across edits. Prefer native edits instead when extending existing SVG, vector, HTML, CSS, or canvas assets.
4
+ ---
5
+
6
+ # Image generation
7
+
8
+ Use `image_gen` for new raster artwork, image edits, and variations. The tool is available to the current pi model regardless of its provider. It uses the configured Codex subscription for the image request; do not launch another Codex agent, request an API key, or fall back to a paid image API.
9
+
10
+ ## Choose the right operation
11
+
12
+ - New image without references: supply `prompt`.
13
+ - New image guided by existing images: supply `prompt` and reference images. Describe each reference as a style, identity, composition, or mood guide. The tool uses its image-edit endpoint for any request with references, even when the creative intent is a new scene.
14
+ - Edit an existing image: supply references and specify exactly what changes and what stays fixed.
15
+ - Several assets: make one call per asset. For a coherent series, establish an approved reference first and reuse it. Avoid generating every asset independently when consistency matters.
16
+ - Existing vector icon sets, logos, simple diagrams, or code-native visuals: edit their native files when that better fits the task. Do not replace requested raster art with SVG placeholders.
17
+
18
+ ## Tool arguments
19
+
20
+ - `prompt`: a complete brief, up to 16,000 characters. Include exact text, reference roles, required details, and exclusions.
21
+ - `referenced_image_paths`: one to five local PNG, JPEG, or WebP paths, in the same order used by the prompt. Relative paths use the project directory. These are uploaded to the image service for the requested edit.
22
+ - `num_last_images_to_include`: one to five recent images from the current conversation branch. The selected images are supplied oldest first. This includes user attachments, images read by tools, and generated images. Unrelated recent images can enter this window, so explicit paths are safer for long workflows.
23
+ - `output_path`: an optional new `.png` file path. Otherwise a unique file is saved under `output/imagegen/` in the current project.
24
+
25
+ Choose either `referenced_image_paths` or `num_last_images_to_include`, never both. Do not pass an empty reference list. References must be at most 32 MiB each and 50 MiB combined. The tool accepts local paths, not arbitrary remote URLs.
26
+
27
+ There are no separate model, size, quality, seed, mask, or batch arguments. Put framing, proportions, detail, and transparency requirements in the prompt. Do not promise exact dimensions, reproducible seeds, or pixel-perfect preservation. The backend decides the output dimensions and quality.
28
+
29
+ ## Workflow
30
+
31
+ 1. Identify whether this is new artwork, reference-guided generation, or an edit. Determine the intended use and destination.
32
+ 2. Collect the brief, literal text, must-keep details, and any references. Ask only when a missing detail blocks the task.
33
+ 3. Inspect local references with `read` before composing an edit. Label them by index and role in the prompt, such as `Image 1: edit target; Image 2: character identity reference`.
34
+ 4. Preserve detailed user instructions. For a vague brief, add useful framing, materials, or lighting, but do not invent brands, characters, props, slogans, or story requirements.
35
+ 5. Make an `image_gen` call. Prefer meaningful output filenames. Never overwrite a source image or an existing deliverable; choose a sibling filename such as `hero-v2.png`.
36
+ 6. Inspect the returned preview against the request. If necessary, use `read` to inspect the saved file. Check the subject, composition, text, identity, and constraints. A model without vision must not claim it visually inspected the output; ask the user to assess it instead.
37
+ 7. If correction is needed, use the output as an edit reference and request one specific change. Repeat the must-keep constraints. Do not generate excessive variants or retry failed requests automatically; each call can consume image allowance.
38
+ 8. Keep the final asset in the project, update consuming code when requested, and report the saved path and final prompt. A failed custom save can fall back to `output/imagegen/`; use the path actually returned by the tool.
39
+
40
+ ## Keeping images consistent
41
+
42
+ Consistency comes from explicit visual references and a repeated brief, not hidden memory between image requests. The pi model plans the request, inspects outputs, and chooses the next edit. The image service receives only the prompt and selected references, not the whole pi conversation.
43
+
44
+ For a character, product, or illustration series:
45
+
46
+ 1. Establish an anchor image and retain its saved path.
47
+ 2. Write a short visual specification covering the important proportions, features, materials, palette, and drawing style.
48
+ 3. Include the anchor in later requests. For a local correction, include the latest version as the edit target and optionally the anchor as the identity/style reference.
49
+ 4. Give each image a distinct role. For example, `Image 1: current scene to edit. Image 2: approved robot design; preserve its head shape, eye spacing, ceramic finish, and joint proportions`.
50
+ 5. Explicitly separate allowed changes from invariants. For example, `Change only the watering can to blue. Keep the robot, pose, fern, lighting, framing, and watercolor texture unchanged`.
51
+ 6. Compare each result with the anchor. If repeated edits drift, return to the anchor and describe the desired scene again.
52
+
53
+ Full-resolution generated files are reused for recent-image edits, rather than the resized preview. The current branch controls which recent images are eligible, so abandoned forks are not used. Explicit paths are useful after compaction or when the recent-image order is ambiguous.
54
+
55
+ Reference-guided generation improves continuity but cannot guarantee identical geometry, faces, typography, or individual pixels.
56
+
57
+ ## Prompt structure
58
+
59
+ Use only the lines that clarify the request:
60
+
61
+ ```text
62
+ Intended use: product photo, website hero, story illustration, sprite, etc.
63
+ Primary request: the user's desired image or edit
64
+ Input images: Image 1 and its role; Image 2 and its role
65
+ Scene and subject: environment, subject, important details
66
+ Style and materials: photo, watercolor, clay render, surface textures
67
+ Composition: viewpoint, framing, proportions, useful negative space
68
+ Lighting and palette: requested light and color constraints
69
+ Text, verbatim: "Exact words"
70
+ Change only: allowed changes for an edit
71
+ Keep unchanged: identity, layout, objects, pose, typography, and other invariants
72
+ Avoid: unwanted elements
73
+ ```
74
+
75
+ For photos, state photorealism and relevant real-world materials or textures. For text, quote the exact wording and describe placement and typography. Verify the result instead of assuming text rendered correctly. For diagrams or educational visuals, provide the actual labels and relationships; do not invent data.
76
+
77
+ ## Transparency
78
+
79
+ Ask for a genuinely transparent background, not a checkerboard painted into an opaque image. Keep the saved PNG intact so any alpha channel is preserved. Visual appearance alone does not prove transparency; check the file's alpha if transparency is required. Do not switch models or services to obtain it without the user's permission.
80
+
81
+ ## Examples
82
+
83
+ New image:
84
+
85
+ ```json
86
+ {
87
+ "prompt": "Website hero illustration of a small ceramic robot tending a fern. Watercolor on warm white paper, soft window light. Wide framing with room for page copy. No lettering or watermark.",
88
+ "output_path": "public/images/robot-hero.png"
89
+ }
90
+ ```
91
+
92
+ Targeted edit:
93
+
94
+ ```json
95
+ {
96
+ "prompt": "Image 1 is the edit target. Change only the watering can to blue. Keep the robot design, pose, fern, background, lighting, framing, and watercolor texture unchanged. No new objects or text.",
97
+ "referenced_image_paths": ["public/images/robot-hero.png"],
98
+ "output_path": "public/images/robot-hero-v2.png"
99
+ }
100
+ ```
101
+
102
+ Recent-image edit:
103
+
104
+ ```json
105
+ {
106
+ "prompt": "Change only the background of the supplied image to a plain warm white. Preserve the subject, pose, materials, and framing.",
107
+ "num_last_images_to_include": 1
108
+ }
109
+ ```
110
+
111
+ ## Authentication and failures
112
+
113
+ The tool and this skill load only when pi has an `openai-codex` OAuth subscription credential in its `auth.json`. Pi manages token refresh. After adding or removing that login, use `/reload` to update extension and skill registration. The active conversation model does not need to be OpenAI.
114
+
115
+ If authentication fails, tell the user to log in with `/login openai-codex`. For rate limits, wait rather than changing providers. After a timeout or connection failure, the image request may already have consumed allowance. Do not claim a file exists unless the tool returned a saved path.
package/src/auth.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const PROVIDER = "openai-codex";
4
+ export const OFF_WARNING = "image generation is off, no openai-codex provider configured.";
5
+
6
+ export function hasSubscription(): boolean {
7
+ const credential = readStoredCredential(PROVIDER);
8
+ return credential?.type === "oauth" && typeof credential.access === "string" &&
9
+ credential.access.length > 0 && typeof credential.refresh === "string" &&
10
+ credential.refresh.length > 0;
11
+ }
12
+
13
+ export interface ImageAuth {
14
+ accessToken: string;
15
+ accountId: string;
16
+ }
17
+
18
+ // Only extracts routing metadata. The backend, not this decoder, verifies the JWT.
19
+ export function accountIdFromToken(token: string): string | undefined {
20
+ try {
21
+ const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
22
+ const id = claims["https://api.openai.com/auth"]?.chatgpt_account_id;
23
+ return typeof id === "string" && id.length > 0 ? id : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ export async function resolveImageAuth(
30
+ registry: Pick<ExtensionContext["modelRegistry"], "getProviderAuth">,
31
+ ): Promise<ImageAuth> {
32
+ if (!hasSubscription()) throw new Error(OFF_WARNING + " Use /login openai-codex, then /reload.");
33
+
34
+ // Pi owns token refresh, file locking and persistence. Never refresh or write auth.json here.
35
+ let resolved;
36
+ try {
37
+ resolved = await registry.getProviderAuth(PROVIDER);
38
+ } catch {
39
+ throw new Error("Could not refresh the openai-codex login. Use /login openai-codex, then /reload.");
40
+ }
41
+ const credential = readStoredCredential(PROVIDER);
42
+ const accessToken = resolved?.auth.apiKey;
43
+ // Reject environment/API-key fallbacks and provider overrides that changed the credential.
44
+ if (credential?.type !== "oauth" || !accessToken || accessToken !== credential.access) {
45
+ throw new Error("Image generation requires the stored openai-codex subscription login, not an API key or override.");
46
+ }
47
+ const accountId = accountIdFromToken(accessToken) ??
48
+ (typeof credential.accountId === "string" ? credential.accountId : undefined);
49
+ if (!accountId) throw new Error("The openai-codex login has no account ID. Please log in again.");
50
+ return { accessToken, accountId };
51
+ }
package/src/backend.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PNG } from "pngjs";
3
+ import type { ImageAuth } from "./auth.ts";
4
+
5
+ export const IMAGE_MODEL = "gpt-image-2";
6
+ const BASE_URL = "https://chatgpt.com/backend-api/codex";
7
+ export const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
8
+ const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
9
+
10
+ export interface ImageRequest {
11
+ prompt: string;
12
+ images?: string[];
13
+ }
14
+
15
+ export interface GeneratedImage {
16
+ bytes: Buffer;
17
+ generationId?: string;
18
+ }
19
+
20
+ async function boundedBody(response: Response, maxBytes: number): Promise<Buffer> {
21
+ const declared = Number(response.headers.get("content-length"));
22
+ if (declared > maxBytes) {
23
+ await response.body?.cancel();
24
+ throw new Error("Image backend response exceeds the size limit.");
25
+ }
26
+ if (!response.body) throw new Error("Image backend returned an empty response.");
27
+ const reader = response.body.getReader();
28
+ const chunks: Uint8Array[] = [];
29
+ let total = 0;
30
+ try {
31
+ for (;;) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ total += value.byteLength;
35
+ if (total > maxBytes) {
36
+ await reader.cancel();
37
+ throw new Error("Image backend response exceeds the size limit.");
38
+ }
39
+ chunks.push(value);
40
+ }
41
+ } finally {
42
+ reader.releaseLock();
43
+ }
44
+ return Buffer.concat(chunks, total);
45
+ }
46
+
47
+ export function decodePng(base64: unknown): Buffer {
48
+ if (typeof base64 !== "string" || base64.length === 0 ||
49
+ base64.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 ||
50
+ base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
51
+ throw new Error("Image backend returned invalid or oversized image data.");
52
+ }
53
+ const bytes = Buffer.from(base64, "base64");
54
+ if (bytes.length > MAX_IMAGE_BYTES || bytes.length < 24 ||
55
+ bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" ||
56
+ bytes.toString("ascii", 12, 16) !== "IHDR") {
57
+ throw new Error("Image backend did not return a PNG image.");
58
+ }
59
+ const width = bytes.readUInt32BE(16);
60
+ const height = bytes.readUInt32BE(20);
61
+ if (!width || !height || width > 8192 || height > 8192 || width * height > 16_777_216) {
62
+ throw new Error("Image backend returned unsupported PNG dimensions.");
63
+ }
64
+ try {
65
+ // Validate chunks, CRCs and pixel data, but keep the original encoded bytes and alpha.
66
+ PNG.sync.read(bytes, { checkCRC: true });
67
+ } catch {
68
+ throw new Error("Image backend returned a corrupt PNG image.");
69
+ }
70
+ return bytes;
71
+ }
72
+
73
+ export async function requestImage(
74
+ auth: ImageAuth,
75
+ request: ImageRequest,
76
+ signal: AbortSignal,
77
+ fetcher: typeof fetch = fetch,
78
+ ): Promise<GeneratedImage> {
79
+ signal.throwIfAborted();
80
+ const editing = !!request.images?.length;
81
+ let response: Response;
82
+ try {
83
+ response = await fetcher(`${BASE_URL}/images/${editing ? "edits" : "generations"}`, {
84
+ method: "POST",
85
+ // Never follow a redirect with a subscription credential.
86
+ redirect: "error",
87
+ signal,
88
+ headers: {
89
+ Authorization: `Bearer ${auth.accessToken}`,
90
+ "ChatGPT-Account-ID": auth.accountId,
91
+ "Content-Type": "application/json",
92
+ originator: "pi-image-generation",
93
+ "x-codex-image-turn-id": randomUUID(),
94
+ },
95
+ body: JSON.stringify({
96
+ model: IMAGE_MODEL,
97
+ prompt: request.prompt,
98
+ background: "auto",
99
+ quality: "auto",
100
+ size: "auto",
101
+ ...(editing ? { images: request.images!.map(image_url => ({ image_url })) } : {}),
102
+ }),
103
+ });
104
+ } catch {
105
+ signal.throwIfAborted();
106
+ throw new Error("Image backend connection failed. No automatic retry was made; the request may have been processed.");
107
+ }
108
+
109
+ if (!response.ok) {
110
+ // Do not echo upstream bodies, prompts, account IDs or auth headers into the session.
111
+ await response.body?.cancel();
112
+ const hints: Record<number, string> = {
113
+ 400: "The backend rejected the image request. Check the prompt and reference images.",
114
+ 401: "The subscription login was rejected. Use /login openai-codex, then /reload.",
115
+ 403: "Image generation is not permitted for this account or request.",
116
+ 404: "The subscription image endpoint is unavailable.",
117
+ 429: "The image allowance or rate limit was reached. Wait before trying again.",
118
+ };
119
+ throw new Error(`Image generation failed (HTTP ${response.status}). ${hints[response.status] ?? "No automatic retry was made; try again later."}`);
120
+ }
121
+
122
+ let body: unknown;
123
+ try {
124
+ body = JSON.parse((await boundedBody(response, MAX_RESPONSE_BYTES)).toString("utf8"));
125
+ } catch {
126
+ signal.throwIfAborted();
127
+ throw new Error("Image backend returned an unreadable or oversized response. No automatic retry was made.");
128
+ }
129
+ const data = (body as { data?: { b64_json?: unknown; generation_id?: unknown }[] })?.data;
130
+ if (!Array.isArray(data) || data.length !== 1) {
131
+ throw new Error("Image backend did not return exactly one image.");
132
+ }
133
+ return {
134
+ bytes: decodePng(data[0]?.b64_json),
135
+ generationId: typeof data[0]?.generation_id === "string" ? data[0].generation_id : undefined,
136
+ };
137
+ }
package/src/images.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, lstat, stat, mkdir, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
6
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
+ import { MAX_IMAGE_BYTES } from "./backend.ts";
8
+
9
+ export const MAX_REFERENCES = 5;
10
+ const MAX_TOTAL_INPUT_BYTES = 50 * 1024 * 1024;
11
+
12
+ export function imagePath(input: string, cwd: string): string {
13
+ let path = input.startsWith("@") ? input.slice(1) : input;
14
+ if (path === "~") path = homedir();
15
+ else if (/^~[/\\]/.test(path)) path = join(homedir(), path.slice(2));
16
+ return isAbsolute(path) ? path : resolve(cwd, path);
17
+ }
18
+
19
+ export function mimeForImage(bytes: Buffer): string {
20
+ if (bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a") return "image/png";
21
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
22
+ if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp";
23
+ throw new Error("Reference images must be PNG, JPEG, or WebP files.");
24
+ }
25
+
26
+ async function fileImage(path: string, signal: AbortSignal): Promise<Buffer> {
27
+ signal.throwIfAborted();
28
+ if (!(await stat(path)).isFile()) throw new Error("Reference images must be regular files.");
29
+ // O_NONBLOCK prevents a FIFO swapped in after stat() from hanging open() on Unix.
30
+ const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK);
31
+ const handle = await open(path, flags);
32
+ try {
33
+ const stat = await handle.stat();
34
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) {
35
+ throw new Error("Reference images must be regular files of at most 32 MiB.");
36
+ }
37
+ // Bounded even if another process grows the file between stat and read.
38
+ const bytes = Buffer.alloc(stat.size + 1);
39
+ let read = 0;
40
+ while (read < bytes.length) {
41
+ signal.throwIfAborted();
42
+ const result = await handle.read(bytes, read, bytes.length - read, null);
43
+ if (result.bytesRead === 0) break;
44
+ read += result.bytesRead;
45
+ }
46
+ if (read > stat.size) throw new Error("Reference image changed while reading. Try again.");
47
+ return bytes.subarray(0, read);
48
+ } finally {
49
+ await handle.close();
50
+ }
51
+ }
52
+
53
+ type Reference = { path: string } | { base64: string };
54
+ const record = (value: unknown): Record<string, unknown> | undefined =>
55
+ value !== null && typeof value === "object" ? value as Record<string, unknown> : undefined;
56
+
57
+ // The caller supplies only the active branch. Never use getEntries(), which includes abandoned forks.
58
+ export function recentReferences(entries: readonly unknown[], count: number): Reference[] {
59
+ const found: Reference[] = [];
60
+ for (let index = entries.length - 1; index >= 0 && found.length < count; index--) {
61
+ const entry = record(entries[index]);
62
+ const message = entry?.type === "message" ? record(entry.message) :
63
+ entry?.type === "custom_message" ? entry : undefined;
64
+ if (!message || message.isError === true) continue;
65
+ if (message.role === "toolResult" && message.toolName === "image_gen") {
66
+ const details = record(message.details);
67
+ if (typeof details?.savedPath === "string") {
68
+ found.push({ path: details.savedPath });
69
+ continue; // Use the full-resolution artifact, not its inline preview as a second reference.
70
+ }
71
+ }
72
+ if (!Array.isArray(message.content)) continue;
73
+ for (let i = message.content.length - 1; i >= 0 && found.length < count; i--) {
74
+ const block = record(message.content[i]);
75
+ if (block?.type !== "image") continue;
76
+ if (typeof block.data === "string") found.push({ base64: block.data });
77
+ else throw new Error("A recent image is not available inline. Use referenced_image_paths instead.");
78
+ }
79
+ }
80
+ if (found.length !== count) {
81
+ throw new Error(`Requested ${count} recent images, but only ${found.length} are available on this branch. Use referenced_image_paths instead.`);
82
+ }
83
+ return found.reverse();
84
+ }
85
+
86
+ export async function loadReferences(
87
+ paths: string[] | undefined,
88
+ recentCount: number | undefined,
89
+ entries: readonly unknown[],
90
+ cwd: string,
91
+ signal: AbortSignal,
92
+ ): Promise<string[]> {
93
+ if (paths !== undefined && recentCount !== undefined) {
94
+ throw new Error("Use referenced_image_paths or num_last_images_to_include, not both.");
95
+ }
96
+ if (paths !== undefined && (paths.length < 1 || paths.length > MAX_REFERENCES)) {
97
+ throw new Error("Supply between 1 and 5 reference-image paths.");
98
+ }
99
+ if (recentCount !== undefined && (!Number.isInteger(recentCount) || recentCount < 1 || recentCount > MAX_REFERENCES)) {
100
+ throw new Error("num_last_images_to_include must be an integer between 1 and 5.");
101
+ }
102
+ const references: Reference[] = paths?.map(path => ({ path: imagePath(path, cwd) })) ??
103
+ (recentCount === undefined ? [] : recentReferences(entries, recentCount));
104
+ let total = 0;
105
+ const images: string[] = [];
106
+ for (const reference of references) {
107
+ signal.throwIfAborted();
108
+ let bytes: Buffer;
109
+ if ("path" in reference) {
110
+ bytes = await fileImage(reference.path, signal);
111
+ } else {
112
+ if (reference.base64.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 ||
113
+ reference.base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(reference.base64)) {
114
+ throw new Error("Recent image has invalid or oversized data. Use a local image path instead.");
115
+ }
116
+ bytes = Buffer.from(reference.base64, "base64");
117
+ }
118
+ total += bytes.length;
119
+ if (bytes.length > MAX_IMAGE_BYTES || total > MAX_TOTAL_INPUT_BYTES) {
120
+ throw new Error("Reference images exceed the 32 MiB per-image or 50 MiB combined limit.");
121
+ }
122
+ images.push(`data:${mimeForImage(bytes)};base64,${bytes.toString("base64")}`);
123
+ }
124
+ return images;
125
+ }
126
+
127
+ export async function prepareOutput(cwd: string, requested?: string): Promise<string> {
128
+ const path = requested === undefined
129
+ ? join(cwd, "output", "imagegen", `${Date.now()}-${randomUUID()}.png`)
130
+ : imagePath(requested, cwd);
131
+ if (extname(path).toLowerCase() !== ".png") throw new Error("output_path must end in .png.");
132
+ try {
133
+ await lstat(path);
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
136
+ throw error;
137
+ }
138
+ throw new Error("The output file already exists. Choose a new filename; images are never overwritten.");
139
+ }
140
+
141
+ export async function saveImage(path: string, bytes: Buffer): Promise<void> {
142
+ await withFileMutationQueue(path, async () => {
143
+ await mkdir(dirname(path), { recursive: true });
144
+ // Exclusive creation protects against races, hardlinks and an existing symlink at the destination.
145
+ await writeFile(path, bytes, { flag: "wx", mode: 0o600 });
146
+ });
147
+ }
package/src/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Type } from "typebox";
3
+ import { resizeImage, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { hasSubscription, OFF_WARNING, resolveImageAuth } from "./auth.ts";
5
+ import { IMAGE_MODEL, requestImage } from "./backend.ts";
6
+ import { loadReferences, prepareOutput, saveImage } from "./images.ts";
7
+
8
+ const parameters = Type.Object({
9
+ prompt: Type.String({ minLength: 1, maxLength: 16000, description: "Complete image brief, including reference-image roles and details that must not change." }),
10
+ referenced_image_paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
11
+ minItems: 1, maxItems: 5, description: "Local PNG/JPEG/WebP references, in prompt order. Relative paths resolve against the project. Do not combine with num_last_images_to_include.",
12
+ })),
13
+ num_last_images_to_include: Type.Optional(Type.Integer({
14
+ minimum: 1, maximum: 5, description: "Use the last 1-5 images on this conversation branch, oldest first. Prefer explicit paths when several unrelated images are present.",
15
+ })),
16
+ output_path: Type.Optional(Type.String({ minLength: 1, description: "New .png destination. Defaults to output/imagegen/<unique-name>.png in the project. Never overwrites an existing file." })),
17
+ }, { additionalProperties: false });
18
+
19
+ // Registry auth resolution does its own locked refresh. Stop waiting on cancellation without
20
+ // interrupting that shared refresh or letting this tool proceed to an image request afterward.
21
+ async function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
22
+ signal.throwIfAborted();
23
+ let onAbort: () => void = () => {};
24
+ const aborted = new Promise<never>((_, reject) => {
25
+ onAbort = () => reject(signal.reason);
26
+ signal.addEventListener("abort", onAbort, { once: true });
27
+ if (signal.aborted) onAbort();
28
+ });
29
+ try {
30
+ return await Promise.race([promise, aborted]);
31
+ } finally {
32
+ signal.removeEventListener("abort", onAbort);
33
+ }
34
+ }
35
+
36
+ export default function imageGeneration(pi: ExtensionAPI): void {
37
+ if (!hasSubscription()) {
38
+ pi.on("session_start", (_event, ctx) => {
39
+ if (ctx.hasUI) ctx.ui.notify(OFF_WARNING, "warning");
40
+ else console.error(OFF_WARNING);
41
+ });
42
+ return;
43
+ }
44
+
45
+ // Not listed in the package's static skill manifest, so it cannot leak into unauthenticated sessions.
46
+ pi.on("resources_discover", () => ({
47
+ skillPaths: hasSubscription() ? [fileURLToPath(new URL("../resources/imagegen/SKILL.md", import.meta.url))] : [],
48
+ }));
49
+
50
+ pi.registerTool({
51
+ name: "image_gen",
52
+ label: "Generate image",
53
+ description: "Generate or edit one raster image using the configured Codex subscription. Accepts up to five local or recent-conversation reference images for consistent edits and variants. Saves a new PNG and returns a preview when possible. No API-key fallback. Reference limits: 32 MiB each, 50 MiB combined; 4-minute request timeout. Load the imagegen skill first.",
54
+ promptSnippet: "Generate images or edit them using local or recent image references",
55
+ promptGuidelines: [
56
+ "Before using image_gen, read the imagegen skill. Reuse reference images and state what must remain unchanged for consistent edits.",
57
+ "Use image_gen for requested raster artwork, not SVG placeholders. Prefer native code edits for existing vector assets.",
58
+ ],
59
+ parameters,
60
+ async execute(_toolCallId, args, signal, onUpdate, ctx) {
61
+ const timeout = AbortSignal.timeout(240_000);
62
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
63
+ requestSignal.throwIfAborted();
64
+ if (!args.prompt.trim()) throw new Error("The image prompt must not be blank.");
65
+ if (!hasSubscription()) throw new Error(OFF_WARNING + " Use /login openai-codex, then /reload.");
66
+ const path = await prepareOutput(ctx.cwd, args.output_path);
67
+ const images = await loadReferences(args.referenced_image_paths, args.num_last_images_to_include,
68
+ ctx.sessionManager.getBranch(), ctx.cwd, requestSignal);
69
+ const auth = await abortable(resolveImageAuth(ctx.modelRegistry), requestSignal);
70
+ onUpdate?.({ content: [{ type: "text", text: images.length ? "Editing image…" : "Generating image…" }], details: {} });
71
+
72
+ let result;
73
+ try {
74
+ result = await requestImage(auth, { prompt: args.prompt, images }, requestSignal);
75
+ } catch (error) {
76
+ if (timeout.aborted && !signal?.aborted) {
77
+ throw new Error("Image generation timed out after four minutes. It may still have consumed image allowance; no automatic retry was made.");
78
+ }
79
+ throw error;
80
+ }
81
+ // Persist the original before processing a smaller preview. Never re-encode the saved PNG.
82
+ let savedPath = path;
83
+ let saveNote = "";
84
+ try {
85
+ await saveImage(savedPath, result.bytes);
86
+ } catch {
87
+ savedPath = await prepareOutput(ctx.cwd);
88
+ try {
89
+ await saveImage(savedPath, result.bytes);
90
+ } catch {
91
+ throw new Error("The image was generated, but could not be saved. Check workspace write permissions before generating again.");
92
+ }
93
+ saveNote = " The requested destination could not be written; saved to the default output directory instead.";
94
+ }
95
+ const preview = await resizeImage(result.bytes, "image/png").catch(() => null);
96
+ const content: ({ type: "text"; text: string } | { type: "image"; data: string; mimeType: string })[] = [
97
+ { type: "text", text: `Saved image: ${savedPath}.${saveNote}\nPrompt: ${args.prompt}` },
98
+ ];
99
+ if (preview) content.push({ type: "image", data: preview.data, mimeType: preview.mimeType });
100
+ else content.push({ type: "text", text: "Preview unavailable. Inspect the saved PNG with the read tool." });
101
+ return {
102
+ content,
103
+ details: {
104
+ savedPath,
105
+ prompt: args.prompt,
106
+ model: IMAGE_MODEL,
107
+ operation: images.length ? "edit" : "generate",
108
+ referenceCount: images.length,
109
+ },
110
+ };
111
+ },
112
+ });
113
+ }