@suknna/pixelforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/adapters/fal.d.ts +11 -0
- package/dist/adapters/fal.js +56 -0
- package/dist/adapters/http.d.ts +21 -0
- package/dist/adapters/http.js +52 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/openai-images.d.ts +11 -0
- package/dist/adapters/openai-images.js +62 -0
- package/dist/adapters/replicate.d.ts +6 -0
- package/dist/adapters/replicate.js +67 -0
- package/dist/adapters/stability-ai.d.ts +11 -0
- package/dist/adapters/stability-ai.js +57 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +103 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +75 -0
- package/dist/image-input.d.ts +9 -0
- package/dist/image-input.js +28 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +42 -0
- package/dist/jobs.d.ts +28 -0
- package/dist/jobs.js +107 -0
- package/dist/notifications.d.ts +30 -0
- package/dist/notifications.js +15 -0
- package/dist/path-safety.d.ts +3 -0
- package/dist/path-safety.js +26 -0
- package/dist/runner.d.ts +16 -0
- package/dist/runner.js +88 -0
- package/dist/tools.d.ts +49 -0
- package/dist/tools.js +112 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +1 -0
- package/package.json +46 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class PixelForgeError extends Error {
|
|
2
|
+
category;
|
|
3
|
+
retryable;
|
|
4
|
+
statusCode;
|
|
5
|
+
provider;
|
|
6
|
+
constructor(input) {
|
|
7
|
+
super(input.message);
|
|
8
|
+
this.name = "PixelForgeError";
|
|
9
|
+
this.category = input.category;
|
|
10
|
+
this.retryable = input.retryable;
|
|
11
|
+
if (input.statusCode !== undefined)
|
|
12
|
+
this.statusCode = input.statusCode;
|
|
13
|
+
if (input.provider !== undefined)
|
|
14
|
+
this.provider = input.provider;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function classifyHttpStatus(statusCode, provider) {
|
|
18
|
+
if (statusCode === 429) {
|
|
19
|
+
return new PixelForgeError(withProvider({
|
|
20
|
+
category: "rate-limit",
|
|
21
|
+
message: "Provider rate limit exceeded.",
|
|
22
|
+
retryable: true,
|
|
23
|
+
statusCode,
|
|
24
|
+
}, provider));
|
|
25
|
+
}
|
|
26
|
+
if (statusCode >= 500) {
|
|
27
|
+
return new PixelForgeError(withProvider({
|
|
28
|
+
category: "server",
|
|
29
|
+
message: "Provider server error.",
|
|
30
|
+
retryable: true,
|
|
31
|
+
statusCode,
|
|
32
|
+
}, provider));
|
|
33
|
+
}
|
|
34
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
35
|
+
return new PixelForgeError(withProvider({
|
|
36
|
+
category: "auth",
|
|
37
|
+
message: "Provider authentication or authorization failed.",
|
|
38
|
+
retryable: false,
|
|
39
|
+
statusCode,
|
|
40
|
+
}, provider));
|
|
41
|
+
}
|
|
42
|
+
return new PixelForgeError(withProvider({
|
|
43
|
+
category: "bad-request",
|
|
44
|
+
message: "Provider rejected the request.",
|
|
45
|
+
retryable: false,
|
|
46
|
+
statusCode,
|
|
47
|
+
}, provider));
|
|
48
|
+
}
|
|
49
|
+
export function networkError(message, provider) {
|
|
50
|
+
return new PixelForgeError(withProvider({ category: "network", message, retryable: true }, provider));
|
|
51
|
+
}
|
|
52
|
+
export function timeoutError(provider) {
|
|
53
|
+
return new PixelForgeError(withProvider({ category: "timeout", message: "Provider request timed out.", retryable: true }, provider));
|
|
54
|
+
}
|
|
55
|
+
export function moderationError(message, provider) {
|
|
56
|
+
return new PixelForgeError(withProvider({ category: "moderation", message, retryable: false }, provider));
|
|
57
|
+
}
|
|
58
|
+
export function configurationError(message) {
|
|
59
|
+
return new PixelForgeError({ category: "configuration", message, retryable: false });
|
|
60
|
+
}
|
|
61
|
+
export function filesystemError(message) {
|
|
62
|
+
return new PixelForgeError({ category: "filesystem", message, retryable: false });
|
|
63
|
+
}
|
|
64
|
+
export function toPixelForgeError(error, provider) {
|
|
65
|
+
if (error instanceof PixelForgeError)
|
|
66
|
+
return error;
|
|
67
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
68
|
+
return timeoutError(provider);
|
|
69
|
+
if (error instanceof Error)
|
|
70
|
+
return networkError(error.message, provider);
|
|
71
|
+
return networkError("Unknown provider failure.", provider);
|
|
72
|
+
}
|
|
73
|
+
function withProvider(input, provider) {
|
|
74
|
+
return provider === undefined ? input : { ...input, provider };
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type LoadedImage = {
|
|
2
|
+
bytes: Uint8Array;
|
|
3
|
+
mimeType: string;
|
|
4
|
+
fileName: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function loadLocalImage(filePath: string): Promise<LoadedImage>;
|
|
7
|
+
export declare function toDataUri(image: LoadedImage): string;
|
|
8
|
+
export declare function mimeTypeFromPath(filePath: string): string;
|
|
9
|
+
export declare function blobFromImage(image: LoadedImage): Blob;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { filesystemError } from "./errors.js";
|
|
4
|
+
export async function loadLocalImage(filePath) {
|
|
5
|
+
const mimeType = mimeTypeFromPath(filePath);
|
|
6
|
+
const bytes = await readFile(filePath).catch((error) => {
|
|
7
|
+
throw filesystemError(`Cannot read base image: ${error instanceof Error ? error.message : String(error)}`);
|
|
8
|
+
});
|
|
9
|
+
return { bytes, mimeType, fileName: path.basename(filePath) };
|
|
10
|
+
}
|
|
11
|
+
export function toDataUri(image) {
|
|
12
|
+
return `data:${image.mimeType};base64,${Buffer.from(image.bytes).toString("base64")}`;
|
|
13
|
+
}
|
|
14
|
+
export function mimeTypeFromPath(filePath) {
|
|
15
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
16
|
+
if (extension === ".png")
|
|
17
|
+
return "image/png";
|
|
18
|
+
if (extension === ".jpg" || extension === ".jpeg")
|
|
19
|
+
return "image/jpeg";
|
|
20
|
+
if (extension === ".webp")
|
|
21
|
+
return "image/webp";
|
|
22
|
+
throw filesystemError("Base image must be a PNG, JPEG, or WebP file.");
|
|
23
|
+
}
|
|
24
|
+
export function blobFromImage(image) {
|
|
25
|
+
const arrayBuffer = new ArrayBuffer(image.bytes.byteLength);
|
|
26
|
+
new Uint8Array(arrayBuffer).set(image.bytes);
|
|
27
|
+
return new Blob([arrayBuffer], { type: image.mimeType });
|
|
28
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
/**
|
|
3
|
+
* PixelForge opencode plugin entry point.
|
|
4
|
+
*
|
|
5
|
+
* On startup it loads and validates `~/.config/opencode/pixelforge.json`,
|
|
6
|
+
* creates a process-local job registry, and registers the three image
|
|
7
|
+
* generation tools. Config validation failures surface here so a misconfigured
|
|
8
|
+
* plugin fails fast at load time rather than at first tool call.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PixelForgePlugin: Plugin;
|
|
11
|
+
export default PixelForgePlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadPixelForgeConfig } from "./config.js";
|
|
2
|
+
import { JobRegistry } from "./jobs.js";
|
|
3
|
+
import { createPixelForgeTools } from "./tools.js";
|
|
4
|
+
/**
|
|
5
|
+
* PixelForge opencode plugin entry point.
|
|
6
|
+
*
|
|
7
|
+
* On startup it loads and validates `~/.config/opencode/pixelforge.json`,
|
|
8
|
+
* creates a process-local job registry, and registers the three image
|
|
9
|
+
* generation tools. Config validation failures surface here so a misconfigured
|
|
10
|
+
* plugin fails fast at load time rather than at first tool call.
|
|
11
|
+
*/
|
|
12
|
+
export const PixelForgePlugin = async ({ client }) => {
|
|
13
|
+
const config = await loadPixelForgeConfig();
|
|
14
|
+
const jobs = new JobRegistry();
|
|
15
|
+
// Adapt the opencode SDK client to PixelForge's minimal NotificationClient
|
|
16
|
+
// boundary. Keeping this translation here means the runner and notifier never
|
|
17
|
+
// depend on the full SDK surface — only on the two calls they actually use.
|
|
18
|
+
const notificationClient = {
|
|
19
|
+
tui: {
|
|
20
|
+
async showToast(input) {
|
|
21
|
+
await client.tui.showToast({
|
|
22
|
+
body: { title: input.title, message: input.message, variant: "success" },
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
session: {
|
|
27
|
+
async prompt(input) {
|
|
28
|
+
await client.session.prompt({
|
|
29
|
+
path: { id: input.sessionID },
|
|
30
|
+
body: {
|
|
31
|
+
noReply: input.noReply ?? false,
|
|
32
|
+
parts: [{ type: "text", text: input.message }],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
tool: createPixelForgeTools({ config, jobs, client: notificationClient }),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export default PixelForgePlugin;
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ImageJob, JobKind, ProviderError, ProviderName, PublicJobStatus } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory registry of background image generation jobs.
|
|
4
|
+
*
|
|
5
|
+
* The registry is the single source of truth for job state. It deliberately
|
|
6
|
+
* exposes a model-safe projection (`publicStatus`) that never leaks the
|
|
7
|
+
* internal `profile` name — models only ever learn the resolved `provider`.
|
|
8
|
+
*/
|
|
9
|
+
export declare class JobRegistry {
|
|
10
|
+
private readonly jobs;
|
|
11
|
+
create(input: {
|
|
12
|
+
kind: JobKind;
|
|
13
|
+
prompt: string;
|
|
14
|
+
outputPath: string;
|
|
15
|
+
baseImagePath?: string;
|
|
16
|
+
}): ImageJob;
|
|
17
|
+
get(jobId: string): ImageJob | undefined;
|
|
18
|
+
startAttempt(jobId: string, provider: ProviderName): void;
|
|
19
|
+
failAttempt(jobId: string, error: ProviderError): void;
|
|
20
|
+
succeed(jobId: string, input: {
|
|
21
|
+
provider: ProviderName;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
byteLength: number;
|
|
24
|
+
}): void;
|
|
25
|
+
fail(jobId: string, error: ProviderError): void;
|
|
26
|
+
publicStatus(jobId: string): PublicJobStatus | undefined;
|
|
27
|
+
private require;
|
|
28
|
+
}
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory registry of background image generation jobs.
|
|
4
|
+
*
|
|
5
|
+
* The registry is the single source of truth for job state. It deliberately
|
|
6
|
+
* exposes a model-safe projection (`publicStatus`) that never leaks the
|
|
7
|
+
* internal `profile` name — models only ever learn the resolved `provider`.
|
|
8
|
+
*/
|
|
9
|
+
export class JobRegistry {
|
|
10
|
+
jobs = new Map();
|
|
11
|
+
create(input) {
|
|
12
|
+
const now = new Date().toISOString();
|
|
13
|
+
const job = {
|
|
14
|
+
id: `pf_${randomUUID()}`,
|
|
15
|
+
kind: input.kind,
|
|
16
|
+
status: "queued",
|
|
17
|
+
prompt: input.prompt,
|
|
18
|
+
outputPath: input.outputPath,
|
|
19
|
+
// exactOptionalPropertyTypes: only set baseImagePath when actually provided.
|
|
20
|
+
...(input.baseImagePath !== undefined ? { baseImagePath: input.baseImagePath } : {}),
|
|
21
|
+
attempts: [],
|
|
22
|
+
createdAt: now,
|
|
23
|
+
updatedAt: now,
|
|
24
|
+
};
|
|
25
|
+
this.jobs.set(job.id, job);
|
|
26
|
+
return job;
|
|
27
|
+
}
|
|
28
|
+
get(jobId) {
|
|
29
|
+
return this.jobs.get(jobId);
|
|
30
|
+
}
|
|
31
|
+
startAttempt(jobId, provider) {
|
|
32
|
+
const job = this.require(jobId);
|
|
33
|
+
job.status = "running";
|
|
34
|
+
job.provider = provider;
|
|
35
|
+
job.updatedAt = new Date().toISOString();
|
|
36
|
+
job.attempts.push({ provider, startedAt: job.updatedAt });
|
|
37
|
+
}
|
|
38
|
+
failAttempt(jobId, error) {
|
|
39
|
+
const job = this.require(jobId);
|
|
40
|
+
const attempt = job.attempts.at(-1);
|
|
41
|
+
if (attempt) {
|
|
42
|
+
attempt.finishedAt = new Date().toISOString();
|
|
43
|
+
attempt.error = error;
|
|
44
|
+
}
|
|
45
|
+
job.error = error;
|
|
46
|
+
job.updatedAt = new Date().toISOString();
|
|
47
|
+
}
|
|
48
|
+
succeed(jobId, input) {
|
|
49
|
+
const job = this.require(jobId);
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
const attempt = job.attempts.at(-1);
|
|
52
|
+
if (attempt)
|
|
53
|
+
attempt.finishedAt = now;
|
|
54
|
+
job.status = "succeeded";
|
|
55
|
+
job.provider = input.provider;
|
|
56
|
+
job.mimeType = input.mimeType;
|
|
57
|
+
job.byteLength = input.byteLength;
|
|
58
|
+
job.updatedAt = now;
|
|
59
|
+
job.completedAt = now;
|
|
60
|
+
}
|
|
61
|
+
fail(jobId, error) {
|
|
62
|
+
const job = this.require(jobId);
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const attempt = job.attempts.at(-1);
|
|
65
|
+
if (attempt && !attempt.finishedAt) {
|
|
66
|
+
attempt.finishedAt = now;
|
|
67
|
+
attempt.error = error;
|
|
68
|
+
}
|
|
69
|
+
job.status = "failed";
|
|
70
|
+
job.error = error;
|
|
71
|
+
job.updatedAt = now;
|
|
72
|
+
job.completedAt = now;
|
|
73
|
+
}
|
|
74
|
+
publicStatus(jobId) {
|
|
75
|
+
const job = this.jobs.get(jobId);
|
|
76
|
+
if (!job)
|
|
77
|
+
return undefined;
|
|
78
|
+
return {
|
|
79
|
+
jobId: job.id,
|
|
80
|
+
status: job.status,
|
|
81
|
+
outputPath: job.outputPath,
|
|
82
|
+
...(job.baseImagePath !== undefined ? { baseImagePath: job.baseImagePath } : {}),
|
|
83
|
+
...(job.provider !== undefined ? { provider: job.provider } : {}),
|
|
84
|
+
...(job.mimeType !== undefined ? { mimeType: job.mimeType } : {}),
|
|
85
|
+
...(job.byteLength !== undefined ? { byteLength: job.byteLength } : {}),
|
|
86
|
+
...(job.error !== undefined
|
|
87
|
+
? {
|
|
88
|
+
error: {
|
|
89
|
+
category: job.error.category,
|
|
90
|
+
message: job.error.message,
|
|
91
|
+
retryable: job.error.retryable,
|
|
92
|
+
...(job.error.statusCode !== undefined ? { statusCode: job.error.statusCode } : {}),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
createdAt: job.createdAt,
|
|
97
|
+
updatedAt: job.updatedAt,
|
|
98
|
+
...(job.completedAt !== undefined ? { completedAt: job.completedAt } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
require(jobId) {
|
|
102
|
+
const job = this.jobs.get(jobId);
|
|
103
|
+
if (!job)
|
|
104
|
+
throw new Error(`Unknown PixelForge job '${jobId}'.`);
|
|
105
|
+
return job;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PublicJobStatus } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal structural view of the opencode plugin client that PixelForge needs.
|
|
4
|
+
*
|
|
5
|
+
* Both members are optional: in tests and in headless `opencode serve` there
|
|
6
|
+
* may be no TUI to toast and no session to prompt. Notifications are best-effort
|
|
7
|
+
* enhancements — the reliable result channel is the check-job tool.
|
|
8
|
+
*/
|
|
9
|
+
export type NotificationClient = {
|
|
10
|
+
tui?: {
|
|
11
|
+
showToast?: (input: {
|
|
12
|
+
title: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}) => Promise<void> | void;
|
|
15
|
+
};
|
|
16
|
+
session?: {
|
|
17
|
+
prompt?: (input: {
|
|
18
|
+
sessionID: string;
|
|
19
|
+
message: string;
|
|
20
|
+
noReply?: boolean;
|
|
21
|
+
}) => Promise<void> | void;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function notifyJobCompleted(input: {
|
|
25
|
+
client: NotificationClient;
|
|
26
|
+
sessionID?: string;
|
|
27
|
+
status: PublicJobStatus;
|
|
28
|
+
tui: boolean;
|
|
29
|
+
sessionMessage: boolean;
|
|
30
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function notifyJobCompleted(input) {
|
|
2
|
+
if (input.tui && input.client.tui?.showToast) {
|
|
3
|
+
await input.client.tui.showToast({
|
|
4
|
+
title: "PixelForge image ready",
|
|
5
|
+
message: `${input.status.provider ?? "unknown provider"} wrote ${input.status.outputPath}`,
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
if (input.sessionMessage && input.sessionID && input.client.session?.prompt) {
|
|
9
|
+
await input.client.session.prompt({
|
|
10
|
+
sessionID: input.sessionID,
|
|
11
|
+
noReply: true,
|
|
12
|
+
message: `PixelForge image job ${input.status.jobId} ${input.status.status}: ${input.status.outputPath}`,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function resolveWorkspacePath(inputPath: string, directory: string, worktree?: string): string;
|
|
2
|
+
export declare function assertCanWriteOutput(filePath: string, overwrite: boolean): Promise<void>;
|
|
3
|
+
export declare function writeFileAtomic(filePath: string, bytes: Uint8Array): Promise<number>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { filesystemError } from "./errors.js";
|
|
4
|
+
export function resolveWorkspacePath(inputPath, directory, worktree) {
|
|
5
|
+
if (!inputPath || inputPath.includes("\0"))
|
|
6
|
+
throw filesystemError("Path must be a non-empty file path.");
|
|
7
|
+
const root = path.resolve(worktree || directory);
|
|
8
|
+
const resolved = path.isAbsolute(inputPath) ? path.resolve(inputPath) : path.resolve(directory, inputPath);
|
|
9
|
+
const relative = path.relative(root, resolved);
|
|
10
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
11
|
+
throw filesystemError("Path must stay inside the current workspace.");
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
export async function assertCanWriteOutput(filePath, overwrite) {
|
|
16
|
+
const existing = await stat(filePath).catch(() => undefined);
|
|
17
|
+
if (existing && !overwrite)
|
|
18
|
+
throw filesystemError("Output file already exists and overwrite is false.");
|
|
19
|
+
}
|
|
20
|
+
export async function writeFileAtomic(filePath, bytes) {
|
|
21
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
22
|
+
const tempPath = `${filePath}.pixelforge-${process.pid}-${Date.now()}.tmp`;
|
|
23
|
+
await writeFile(tempPath, bytes);
|
|
24
|
+
await rename(tempPath, filePath);
|
|
25
|
+
return bytes.byteLength;
|
|
26
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JobRegistry } from "./jobs.js";
|
|
2
|
+
import { type NotificationClient } from "./notifications.js";
|
|
3
|
+
import type { ImageJob, PixelForgeConfig } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything a background job needs to execute: validated config, the shared
|
|
6
|
+
* job registry, and the notification client. `sessionID` is optional because
|
|
7
|
+
* not every invocation path has an active session to message.
|
|
8
|
+
*/
|
|
9
|
+
export type RunnerContext = {
|
|
10
|
+
config: PixelForgeConfig;
|
|
11
|
+
jobs: JobRegistry;
|
|
12
|
+
client: NotificationClient;
|
|
13
|
+
sessionID?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function startTextToImageJob(context: RunnerContext, job: ImageJob, overwrite: boolean): void;
|
|
16
|
+
export declare function startImageToImageJob(context: RunnerContext, job: ImageJob, overwrite: boolean, strength?: number): void;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createAdapter } from "./adapters/index.js";
|
|
2
|
+
import { PixelForgeError, toPixelForgeError } from "./errors.js";
|
|
3
|
+
import { notifyJobCompleted } from "./notifications.js";
|
|
4
|
+
import { assertCanWriteOutput, writeFileAtomic } from "./path-safety.js";
|
|
5
|
+
export function startTextToImageJob(context, job, overwrite) {
|
|
6
|
+
void runJob(context, job, overwrite, async (adapter, signal) => adapter.generateImage({ prompt: job.prompt, signal }));
|
|
7
|
+
}
|
|
8
|
+
export function startImageToImageJob(context, job, overwrite, strength) {
|
|
9
|
+
void runJob(context, job, overwrite, async (adapter, signal) => {
|
|
10
|
+
if (!job.baseImagePath)
|
|
11
|
+
throw new Error("Image-to-image job is missing baseImagePath.");
|
|
12
|
+
return adapter.generateImageFromImage({
|
|
13
|
+
baseImagePath: job.baseImagePath,
|
|
14
|
+
prompt: job.prompt,
|
|
15
|
+
...(strength !== undefined ? { strength } : {}),
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Core background execution loop.
|
|
22
|
+
*
|
|
23
|
+
* Tries the default profile first, then each explicit fallback in order. Only
|
|
24
|
+
* retryable errors (network/timeout/429/5xx) advance to the next profile; every
|
|
25
|
+
* other failure is fail-closed and stops the job immediately. Output is written
|
|
26
|
+
* atomically before the job is marked succeeded, so a visible file always means
|
|
27
|
+
* a completed job.
|
|
28
|
+
*/
|
|
29
|
+
async function runJob(context, job, overwrite, execute) {
|
|
30
|
+
try {
|
|
31
|
+
await assertCanWriteOutput(job.outputPath, overwrite);
|
|
32
|
+
const profileNames = orderedProfiles(context.config);
|
|
33
|
+
let lastError;
|
|
34
|
+
for (const profileName of profileNames) {
|
|
35
|
+
const profile = context.config.profiles[profileName];
|
|
36
|
+
if (!profile)
|
|
37
|
+
continue;
|
|
38
|
+
const adapter = createAdapter(profile);
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), profile.timeoutMs);
|
|
41
|
+
context.jobs.startAttempt(job.id, profile.provider);
|
|
42
|
+
try {
|
|
43
|
+
const result = await execute(adapter, controller.signal);
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
const byteLength = await writeFileAtomic(job.outputPath, result.bytes);
|
|
46
|
+
context.jobs.succeed(job.id, {
|
|
47
|
+
provider: result.provider,
|
|
48
|
+
mimeType: result.mimeType,
|
|
49
|
+
byteLength,
|
|
50
|
+
});
|
|
51
|
+
const status = context.jobs.publicStatus(job.id);
|
|
52
|
+
if (status) {
|
|
53
|
+
await notifyJobCompleted({
|
|
54
|
+
client: context.client,
|
|
55
|
+
...(context.sessionID !== undefined ? { sessionID: context.sessionID } : {}),
|
|
56
|
+
status,
|
|
57
|
+
tui: context.config.notifications.tui,
|
|
58
|
+
sessionMessage: context.config.notifications.sessionMessage,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
const classified = toPixelForgeError(error, profile.provider);
|
|
66
|
+
lastError = classified;
|
|
67
|
+
context.jobs.failAttempt(job.id, classified);
|
|
68
|
+
if (!classified.retryable) {
|
|
69
|
+
context.jobs.fail(job.id, classified);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
context.jobs.fail(job.id, lastError ??
|
|
75
|
+
new PixelForgeError({
|
|
76
|
+
category: "provider",
|
|
77
|
+
message: "No provider profiles were available.",
|
|
78
|
+
retryable: false,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
context.jobs.fail(job.id, toPixelForgeError(error));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Default profile first, then explicit fallbacks, de-duplicated, order preserved. */
|
|
86
|
+
function orderedProfiles(config) {
|
|
87
|
+
return [...new Set([config.defaultProfile, ...config.fallbackProfiles])];
|
|
88
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type RunnerContext } from "./runner.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the three opencode tools backed by a shared runner context.
|
|
4
|
+
*
|
|
5
|
+
* The model-facing surface never mentions the internal `profile` concept: the
|
|
6
|
+
* only provider value returned is the resolved default provider name, which is
|
|
7
|
+
* informational. Provider/fallback selection stays entirely inside config.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createPixelForgeTools(context: RunnerContext): {
|
|
10
|
+
pixelforge_generate_image: {
|
|
11
|
+
description: string;
|
|
12
|
+
args: {
|
|
13
|
+
prompt: import("zod").ZodString;
|
|
14
|
+
outputPath: import("zod").ZodString;
|
|
15
|
+
overwrite: import("zod").ZodBoolean;
|
|
16
|
+
};
|
|
17
|
+
execute(args: {
|
|
18
|
+
prompt: string;
|
|
19
|
+
outputPath: string;
|
|
20
|
+
overwrite: boolean;
|
|
21
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
22
|
+
};
|
|
23
|
+
pixelforge_generate_image_from_image: {
|
|
24
|
+
description: string;
|
|
25
|
+
args: {
|
|
26
|
+
baseImagePath: import("zod").ZodString;
|
|
27
|
+
prompt: import("zod").ZodString;
|
|
28
|
+
outputPath: import("zod").ZodString;
|
|
29
|
+
strength: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
30
|
+
overwrite: import("zod").ZodBoolean;
|
|
31
|
+
};
|
|
32
|
+
execute(args: {
|
|
33
|
+
baseImagePath: string;
|
|
34
|
+
prompt: string;
|
|
35
|
+
outputPath: string;
|
|
36
|
+
overwrite: boolean;
|
|
37
|
+
strength?: number | undefined;
|
|
38
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
39
|
+
};
|
|
40
|
+
pixelforge_check_image_job: {
|
|
41
|
+
description: string;
|
|
42
|
+
args: {
|
|
43
|
+
jobId: import("zod").ZodString;
|
|
44
|
+
};
|
|
45
|
+
execute(args: {
|
|
46
|
+
jobId: string;
|
|
47
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
48
|
+
};
|
|
49
|
+
};
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { resolveWorkspacePath } from "./path-safety.js";
|
|
3
|
+
import { startImageToImageJob, startTextToImageJob } from "./runner.js";
|
|
4
|
+
const GENERATE_IMAGE_DESCRIPTION = "Generate an image from a text prompt and write it to a requested file path in the current workspace. Provide a complete visual prompt and an explicit output path. This tool starts image generation in the background and returns a jobId immediately; the image file may not exist yet when the tool returns.";
|
|
5
|
+
const GENERATE_IMAGE_FROM_IMAGE_DESCRIPTION = "Generate a new image from an existing base image and a text prompt, then write the result to a requested file path in the current workspace. Provide the base image path, a complete visual prompt describing the desired generated result, and an explicit output path. This tool starts image generation in the background and returns a jobId immediately; the output image file may not exist yet when the tool returns.";
|
|
6
|
+
const CHECK_IMAGE_JOB_DESCRIPTION = "Check the status of a PixelForge image generation job by jobId. The response reports queued, running, succeeded, or failed. When the status is succeeded, the response includes the final file path, MIME type, byte size, elapsed time, and the provider that produced the image. When the status is failed, the response includes the explicit error reason. This tool only reports job state.";
|
|
7
|
+
const STARTED_MESSAGE = "Image generation started in the background. Use pixelforge_check_image_job with this jobId to inspect completion.";
|
|
8
|
+
/**
|
|
9
|
+
* Builds the three opencode tools backed by a shared runner context.
|
|
10
|
+
*
|
|
11
|
+
* The model-facing surface never mentions the internal `profile` concept: the
|
|
12
|
+
* only provider value returned is the resolved default provider name, which is
|
|
13
|
+
* informational. Provider/fallback selection stays entirely inside config.
|
|
14
|
+
*/
|
|
15
|
+
export function createPixelForgeTools(context) {
|
|
16
|
+
const defaultProvider = resolveDefaultProvider(context);
|
|
17
|
+
return {
|
|
18
|
+
pixelforge_generate_image: tool({
|
|
19
|
+
description: GENERATE_IMAGE_DESCRIPTION,
|
|
20
|
+
args: {
|
|
21
|
+
prompt: tool.schema.string().min(1).describe("Detailed text prompt describing the image to generate."),
|
|
22
|
+
outputPath: tool.schema
|
|
23
|
+
.string()
|
|
24
|
+
.min(1)
|
|
25
|
+
.describe("File path where the generated image should be written. Relative paths are resolved from the current session directory."),
|
|
26
|
+
overwrite: tool.schema.boolean().describe("Whether to overwrite the output file if it already exists."),
|
|
27
|
+
},
|
|
28
|
+
async execute(args, opencodeContext) {
|
|
29
|
+
const outputPath = resolveWorkspacePath(args.outputPath, opencodeContext.directory, opencodeContext.worktree);
|
|
30
|
+
const job = context.jobs.create({ kind: "text-to-image", prompt: args.prompt, outputPath });
|
|
31
|
+
startTextToImageJob({ ...context, sessionID: opencodeContext.sessionID }, job, args.overwrite);
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
jobId: job.id,
|
|
34
|
+
status: "queued",
|
|
35
|
+
outputPath: args.outputPath,
|
|
36
|
+
provider: defaultProvider,
|
|
37
|
+
message: STARTED_MESSAGE,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
pixelforge_generate_image_from_image: tool({
|
|
42
|
+
description: GENERATE_IMAGE_FROM_IMAGE_DESCRIPTION,
|
|
43
|
+
args: {
|
|
44
|
+
baseImagePath: tool.schema
|
|
45
|
+
.string()
|
|
46
|
+
.min(1)
|
|
47
|
+
.describe("Path to the local base image to use as visual input. Relative paths are resolved from the current session directory."),
|
|
48
|
+
prompt: tool.schema
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.describe("Detailed text prompt describing how to generate the new image from the base image."),
|
|
52
|
+
outputPath: tool.schema
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.describe("File path where the generated image should be written. Relative paths are resolved from the current session directory."),
|
|
56
|
+
strength: tool.schema
|
|
57
|
+
.number()
|
|
58
|
+
.min(0)
|
|
59
|
+
.max(1)
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("How strongly the generated result may diverge from the base image, from 0 to 1."),
|
|
62
|
+
overwrite: tool.schema.boolean().describe("Whether to overwrite the output file if it already exists."),
|
|
63
|
+
},
|
|
64
|
+
async execute(args, opencodeContext) {
|
|
65
|
+
const baseImagePath = resolveWorkspacePath(args.baseImagePath, opencodeContext.directory, opencodeContext.worktree);
|
|
66
|
+
const outputPath = resolveWorkspacePath(args.outputPath, opencodeContext.directory, opencodeContext.worktree);
|
|
67
|
+
const job = context.jobs.create({
|
|
68
|
+
kind: "image-to-image",
|
|
69
|
+
prompt: args.prompt,
|
|
70
|
+
baseImagePath,
|
|
71
|
+
outputPath,
|
|
72
|
+
});
|
|
73
|
+
startImageToImageJob({ ...context, sessionID: opencodeContext.sessionID }, job, args.overwrite, args.strength);
|
|
74
|
+
return JSON.stringify({
|
|
75
|
+
jobId: job.id,
|
|
76
|
+
status: "queued",
|
|
77
|
+
baseImagePath: args.baseImagePath,
|
|
78
|
+
outputPath: args.outputPath,
|
|
79
|
+
provider: defaultProvider,
|
|
80
|
+
message: STARTED_MESSAGE,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
pixelforge_check_image_job: tool({
|
|
85
|
+
description: CHECK_IMAGE_JOB_DESCRIPTION,
|
|
86
|
+
args: {
|
|
87
|
+
jobId: tool.schema
|
|
88
|
+
.string()
|
|
89
|
+
.min(1)
|
|
90
|
+
.describe("PixelForge image generation job ID returned by a generation tool."),
|
|
91
|
+
},
|
|
92
|
+
async execute(args) {
|
|
93
|
+
const status = context.jobs.publicStatus(args.jobId);
|
|
94
|
+
if (!status) {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
jobId: args.jobId,
|
|
97
|
+
status: "failed",
|
|
98
|
+
error: { category: "configuration", message: "Unknown PixelForge job ID.", retryable: false },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return JSON.stringify(status);
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Resolves the default profile's provider name for informational tool output. */
|
|
107
|
+
function resolveDefaultProvider(context) {
|
|
108
|
+
const profile = context.config.profiles[context.config.defaultProfile];
|
|
109
|
+
if (!profile)
|
|
110
|
+
throw new Error(`Default profile '${context.config.defaultProfile}' is missing from resolved config.`);
|
|
111
|
+
return profile.provider;
|
|
112
|
+
}
|