runline 0.27.2 → 0.28.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.
@@ -8,9 +8,9 @@
8
8
  * send_file/attachment tool. Plugins load in-process via jiti, so node:fs is
9
9
  * available (same pattern as googleDrive/googleSlides).
10
10
  */
11
- import { writeFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
- import { join } from "node:path";
13
+ import { basename, extname, join } from "node:path";
14
14
  const MIME_EXT = {
15
15
  "image/png": "png",
16
16
  "image/jpeg": "jpg",
@@ -38,3 +38,38 @@ export function writeImageFile(opts) {
38
38
  return { path, mimeType, byteLength: bytes.length };
39
39
  }
40
40
  export const SEND_FILE_NOTE = "Image(s) written to disk. Deliver each to the user with send_file using its `path`.";
41
+ const EXT_MIME = {
42
+ png: "image/png",
43
+ jpg: "image/jpeg",
44
+ jpeg: "image/jpeg",
45
+ webp: "image/webp",
46
+ gif: "image/gif",
47
+ };
48
+ /**
49
+ * Read a local image for an `image.edit` call: bytes + base64 + mime
50
+ * (from the extension) + a `data:` URI for JSON APIs. Throws with the
51
+ * plugin's name when the file can't be read so errors point at the
52
+ * right tool.
53
+ */
54
+ export function readImageInput(imagePath, pluginName) {
55
+ if (typeof imagePath !== "string" || imagePath.trim().length === 0) {
56
+ throw new Error(`${pluginName}: imagePath is required`);
57
+ }
58
+ let bytes;
59
+ try {
60
+ bytes = readFileSync(imagePath);
61
+ }
62
+ catch (err) {
63
+ throw new Error(`${pluginName}: cannot read image at ${imagePath} — ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ const ext = extname(imagePath).slice(1).toLowerCase();
66
+ const mimeType = EXT_MIME[ext] ?? "image/png";
67
+ const base64 = bytes.toString("base64");
68
+ return {
69
+ bytes,
70
+ base64,
71
+ mimeType,
72
+ dataUri: `data:${mimeType};base64,${base64}`,
73
+ fileName: basename(imagePath),
74
+ };
75
+ }
@@ -14,14 +14,32 @@
14
14
  * stripped before delivery. Hand each `path` to the host's file-sending
15
15
  * tool (e.g. send_file) to deliver the image.
16
16
  *
17
- * Nano Banana supports conversational editing — chain prompts in
18
- * follow-up calls and it'll keep iterating on the last image.
17
+ * Nano Banana is strong at editing — `image.edit` sends a local image
18
+ * inline next to the instruction and writes the edited result to disk.
19
19
  */
20
- import { writeFileSync } from "node:fs";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
20
  import * as t from "typebox";
21
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/imageFile.js";
24
22
  const BASE = "https://generativelanguage.googleapis.com/v1beta/models";
23
+ /** Write every inline image part of a Gemini response to disk. */
24
+ function saveInlineImages(data, saveDir) {
25
+ const stamp = Date.now();
26
+ const images = [];
27
+ for (const candidate of data.candidates ?? []) {
28
+ for (const part of candidate.content?.parts ?? []) {
29
+ if (part.inlineData?.data) {
30
+ images.push(writeImageFile({
31
+ base64: part.inlineData.data,
32
+ mimeType: part.inlineData.mimeType ?? "image/png",
33
+ provider: "googleImage",
34
+ index: images.length,
35
+ saveDir,
36
+ stamp,
37
+ }));
38
+ }
39
+ }
40
+ }
41
+ return images;
42
+ }
25
43
  export default function googleImage(rl) {
26
44
  rl.setName("googleImage");
27
45
  rl.setVersion("0.1.0");
@@ -70,27 +88,64 @@ export default function googleImage(rl) {
70
88
  throw new Error(`Google API error ${res.status}: ${await res.text()}`);
71
89
  }
72
90
  const data = (await res.json());
73
- const dir = (typeof p.saveDir === "string" && p.saveDir.trim()) || tmpdir();
74
- const stamp = Date.now();
75
- const images = [];
76
- for (const candidate of data.candidates ?? []) {
77
- for (const part of candidate.content?.parts ?? []) {
78
- if (part.inlineData?.data) {
79
- const mimeType = part.inlineData.mimeType ?? "image/png";
80
- const ext = mimeType.includes("jpeg") ? "jpg" : mimeType.split("/")[1] || "png";
81
- const bytes = Buffer.from(part.inlineData.data, "base64");
82
- const path = join(dir, `googleImage-${stamp}-${images.length}.${ext}`);
83
- writeFileSync(path, bytes);
84
- images.push({ path, mimeType, byteLength: bytes.length });
85
- }
86
- }
91
+ const images = saveInlineImages(data, p.saveDir);
92
+ return { provider: "googleImage", model, images, note: SEND_FILE_NOTE };
93
+ },
94
+ });
95
+ rl.registerAction("image.edit", {
96
+ access: "write",
97
+ description: "Edit a local image with Gemini (Nano Banana): give the file path and describe the change. Sends the image inline with the instruction, writes the edited image(s) to disk, and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
98
+ inputSchema: t.Object({
99
+ prompt: t.String({
100
+ minLength: 1,
101
+ description: "Instruction describing the edit to apply",
102
+ }),
103
+ imagePath: t.String({
104
+ minLength: 1,
105
+ description: "Path to the source image file",
106
+ }),
107
+ model: t.Optional(t.String({
108
+ minLength: 1,
109
+ description: "Gemini image model ID. Defaults to gemini-2.5-flash-image.",
110
+ })),
111
+ saveDir: t.Optional(t.String({
112
+ description: "Directory to write the image file(s) into. Empty or omitted uses the OS temp directory.",
113
+ })),
114
+ }, { additionalProperties: false }),
115
+ async execute(input, ctx) {
116
+ const p = (input ?? {});
117
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
118
+ throw new Error("googleImage: prompt is required");
87
119
  }
88
- return {
89
- provider: "googleImage",
90
- model,
91
- images,
92
- note: "Image(s) written to disk. Deliver each to the user with send_file using its `path`.",
120
+ const img = readImageInput(p.imagePath, "googleImage");
121
+ const apiKey = ctx.connection.config.apiKey;
122
+ const model = p.model ?? "gemini-2.5-flash-image";
123
+ const url = `${BASE}/${model}:generateContent?key=${encodeURIComponent(apiKey)}`;
124
+ const body = {
125
+ contents: [
126
+ {
127
+ parts: [
128
+ { text: p.prompt },
129
+ { inlineData: { mimeType: img.mimeType, data: img.base64 } },
130
+ ],
131
+ },
132
+ ],
133
+ generationConfig: { responseModalities: ["IMAGE", "TEXT"] },
93
134
  };
135
+ const res = await fetch(url, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify(body),
139
+ });
140
+ if (!res.ok) {
141
+ throw new Error(`Google API error ${res.status}: ${await res.text()}`);
142
+ }
143
+ const data = (await res.json());
144
+ const images = saveInlineImages(data, p.saveDir);
145
+ if (images.length === 0) {
146
+ throw new Error("googleImage: the model returned no edited image — it may have refused; try a more specific instruction");
147
+ }
148
+ return { provider: "googleImage", model, images, note: SEND_FILE_NOTE };
94
149
  },
95
150
  });
96
151
  }
@@ -14,10 +14,9 @@
14
14
  * const { images } = await openai.image.create({ prompt: "a red bicycle on snow" })
15
15
  * // images[0].path -> "/tmp/openai-image-….png"
16
16
  */
17
- import { writeFileSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
17
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile, } from "../../_shared/imageFile.js";
20
18
  const ENDPOINT = "https://api.openai.com/v1/images/generations";
19
+ const EDIT_ENDPOINT = "https://api.openai.com/v1/images/edits";
21
20
  export default function openai(rl) {
22
21
  rl.setName("openai");
23
22
  rl.setVersion("0.1.0");
@@ -114,25 +113,117 @@ export default function openai(rl) {
114
113
  throw new Error(`OpenAI API error ${res.status}: ${await res.text()}`);
115
114
  }
116
115
  const data = (await res.json());
117
- const dir = (typeof p.saveDir === "string" && p.saveDir.trim()) || tmpdir();
118
116
  const stamp = Date.now();
119
- const images = (data.data ?? []).map((d, i) => {
120
- const bytes = Buffer.from(d.b64_json ?? "", "base64");
121
- const path = join(dir, `openai-image-${stamp}-${i}.png`);
122
- writeFileSync(path, bytes);
123
- return {
124
- path,
117
+ const images = (data.data ?? []).map((d, i) => ({
118
+ ...writeImageFile({
119
+ base64: d.b64_json,
125
120
  mimeType: "image/png",
126
- byteLength: bytes.length,
127
- ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
128
- };
121
+ provider: "openai-image",
122
+ index: i,
123
+ saveDir: p.saveDir,
124
+ stamp,
125
+ }),
126
+ ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
127
+ }));
128
+ return { provider: "openai", model, images, note: SEND_FILE_NOTE };
129
+ },
130
+ });
131
+ rl.registerAction("image.edit", {
132
+ access: "write",
133
+ description: "Edit a local image with OpenAI (gpt-image-*) via /v1/images/edits: give the file path(s) and describe the change. Writes the edited PNG(s) to disk and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
134
+ inputSchema: {
135
+ prompt: {
136
+ type: "string",
137
+ required: true,
138
+ description: "Instruction describing the edit to apply",
139
+ },
140
+ imagePath: {
141
+ type: "string",
142
+ required: false,
143
+ description: "Path to the source image file. Either this or imagePaths is required.",
144
+ },
145
+ imagePaths: {
146
+ type: "array",
147
+ required: false,
148
+ description: "Paths to multiple source images (gpt-image models accept up to 16; the first is the primary).",
149
+ },
150
+ saveDir: {
151
+ type: "string",
152
+ required: false,
153
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
154
+ },
155
+ model: {
156
+ type: "string",
157
+ required: false,
158
+ description: "gpt-image-2 | gpt-image-1 | gpt-image-1-mini. Omit to use the connection default.",
159
+ },
160
+ size: {
161
+ type: "string",
162
+ required: false,
163
+ description: "WxH output size. Omit to let the API match the input.",
164
+ },
165
+ quality: {
166
+ type: "string",
167
+ required: false,
168
+ description: "low | medium | high",
169
+ },
170
+ n: {
171
+ type: "number",
172
+ required: false,
173
+ description: "Number of edited variants (default: 1, max: 4)",
174
+ },
175
+ },
176
+ async execute(input, ctx) {
177
+ const p = (input ?? {});
178
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
179
+ throw new Error("openai: prompt is required");
180
+ }
181
+ const paths = Array.isArray(p.imagePaths) && p.imagePaths.length > 0
182
+ ? p.imagePaths
183
+ : p.imagePath
184
+ ? [p.imagePath]
185
+ : [];
186
+ if (paths.length === 0) {
187
+ throw new Error("openai: imagePath (or imagePaths) is required");
188
+ }
189
+ const apiKey = ctx.connection.config.apiKey;
190
+ const model = p.model ??
191
+ ctx.connection.config.defaultModel ??
192
+ "gpt-image-1";
193
+ const form = new FormData();
194
+ form.append("model", model);
195
+ form.append("prompt", p.prompt);
196
+ form.append("n", String(Math.min(p.n ?? 1, 4)));
197
+ if (p.size)
198
+ form.append("size", p.size);
199
+ if (p.quality)
200
+ form.append("quality", p.quality);
201
+ for (const path of paths) {
202
+ const img = readImageInput(path, "openai");
203
+ form.append("image[]", new Blob([img.bytes], { type: img.mimeType }), img.fileName);
204
+ }
205
+ const res = await fetch(EDIT_ENDPOINT, {
206
+ method: "POST",
207
+ headers: { Authorization: `Bearer ${apiKey}` },
208
+ body: form,
129
209
  });
130
- return {
131
- provider: "openai",
132
- model,
133
- images,
134
- note: "Image(s) written to disk. Deliver each to the user with send_file using its `path`.",
135
- };
210
+ if (!res.ok) {
211
+ throw new Error(`OpenAI API error ${res.status}: ${await res.text()}`);
212
+ }
213
+ const data = (await res.json());
214
+ const stamp = Date.now();
215
+ const images = (data.data ?? []).map((d, i) => ({
216
+ ...writeImageFile({
217
+ base64: d.b64_json,
218
+ mimeType: "image/png",
219
+ provider: "openai-image",
220
+ index: i,
221
+ saveDir: p.saveDir,
222
+ stamp,
223
+ }),
224
+ ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
225
+ }));
226
+ return { provider: "openai", model, images, note: SEND_FILE_NOTE };
136
227
  },
137
228
  });
138
229
  }
@@ -13,8 +13,9 @@
13
13
  * legacy `style` knob — pass `styleId` against your own custom
14
14
  * style if you've set one up.
15
15
  */
16
- import { SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
16
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
17
17
  const ENDPOINT = "https://external.api.recraft.ai/v1/images/generations";
18
+ const EDIT_ENDPOINT = "https://external.api.recraft.ai/v1/images/imageToImage";
18
19
  export default function recraft(rl) {
19
20
  rl.setName("recraft");
20
21
  rl.setVersion("0.1.0");
@@ -102,4 +103,83 @@ export default function recraft(rl) {
102
103
  return { provider: "recraft", model, images, note: SEND_FILE_NOTE };
103
104
  },
104
105
  });
106
+ rl.registerAction("image.edit", {
107
+ access: "write",
108
+ description: "Edit a local image with Recraft (imageToImage): give the file path, describe the change, and tune `strength` for how far to depart from the original. Writes the edited image(s) to disk and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
109
+ inputSchema: {
110
+ prompt: {
111
+ type: "string",
112
+ required: true,
113
+ description: "Instruction describing the transformation",
114
+ },
115
+ imagePath: {
116
+ type: "string",
117
+ required: true,
118
+ description: "Path to the source image file (PNG, JPG, WEBP, or SVG; max 10 MB)",
119
+ },
120
+ strength: {
121
+ type: "number",
122
+ required: false,
123
+ description: "0–1: how strongly to alter the input (default: 0.2 — close to the original)",
124
+ },
125
+ saveDir: {
126
+ type: "string",
127
+ required: false,
128
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
129
+ },
130
+ model: {
131
+ type: "string",
132
+ required: false,
133
+ description: "Any Recraft V3 / V4 / V4.1 model, raster or vector (e.g. recraftv3, recraftv4_pro, recraftv4_1_vector). API default: recraftv4_1.",
134
+ },
135
+ style: {
136
+ type: "string",
137
+ required: false,
138
+ description: "Photorealism | Illustration | Vector art | Hand-drawn | Icon | Recraft V3 Raw",
139
+ },
140
+ styleId: {
141
+ type: "string",
142
+ required: false,
143
+ description: "ID of a custom style created in your Recraft account",
144
+ },
145
+ n: {
146
+ type: "number",
147
+ required: false,
148
+ description: "Number of edited variants (default: 1, max: 6)",
149
+ },
150
+ },
151
+ async execute(input, ctx) {
152
+ const p = (input ?? {});
153
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
154
+ throw new Error("recraft: prompt is required");
155
+ }
156
+ const img = readImageInput(p.imagePath, "recraft");
157
+ const apiKey = ctx.connection.config.apiKey;
158
+ const form = new FormData();
159
+ form.append("image", new Blob([img.bytes], { type: img.mimeType }), img.fileName);
160
+ form.append("prompt", p.prompt);
161
+ form.append("strength", String(p.strength ?? 0.2));
162
+ form.append("response_format", "b64_json");
163
+ form.append("n", String(Math.min(p.n ?? 1, 6)));
164
+ if (p.model)
165
+ form.append("model", p.model);
166
+ if (p.style)
167
+ form.append("style", p.style);
168
+ if (p.styleId)
169
+ form.append("style_id", p.styleId);
170
+ const res = await fetch(EDIT_ENDPOINT, {
171
+ method: "POST",
172
+ headers: { Authorization: `Bearer ${apiKey}` },
173
+ body: form,
174
+ });
175
+ if (!res.ok) {
176
+ throw new Error(`Recraft API error ${res.status}: ${await res.text()}`);
177
+ }
178
+ const data = (await res.json());
179
+ const stamp = Date.now();
180
+ const images = (data.data ?? []).map((d, i) => writeImageFile({ base64: d.b64_json, mimeType: "image/png", provider: "recraft", index: i, saveDir: p.saveDir, stamp }));
181
+ // The imageToImage endpoint's own default model is recraftv4_1.
182
+ return { provider: "recraft", model: p.model ?? "recraftv4_1", images, note: SEND_FILE_NOTE };
183
+ },
184
+ });
105
185
  }
@@ -19,7 +19,7 @@
19
19
  * separately.
20
20
  */
21
21
  import { Buffer } from "node:buffer";
22
- import { SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
22
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
23
23
  import { parseSize } from "../../_shared/parseSize.js";
24
24
  const POLL_INTERVAL_MS = 2_000;
25
25
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
@@ -35,6 +35,93 @@ function stringifyError(err) {
35
35
  return String(err);
36
36
  }
37
37
  }
38
+ /**
39
+ * Create a prediction for `model`, wait for it to reach a terminal
40
+ * state (Prefer: wait + polling), download the output URL(s), and
41
+ * write them to disk. Shared by image.create and image.edit — the
42
+ * only thing that differs between them is the prediction `input`.
43
+ */
44
+ async function runPrediction(opts) {
45
+ const { apiToken, model, input, timeoutMs, saveDir } = opts;
46
+ const deadline = Date.now() + timeoutMs;
47
+ const createRes = await fetch(`https://api.replicate.com/v1/models/${model}/predictions`, {
48
+ method: "POST",
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ Authorization: `Bearer ${apiToken}`,
52
+ // `Prefer: wait` lets the server hold the connection open for
53
+ // fast jobs so we don't have to poll at all on the happy path.
54
+ Prefer: "wait",
55
+ },
56
+ body: JSON.stringify({ input }),
57
+ });
58
+ if (!createRes.ok) {
59
+ throw new Error(`Replicate API error ${createRes.status}: ${await createRes.text()}`);
60
+ }
61
+ let prediction = (await createRes.json());
62
+ while (prediction.status !== "succeeded" &&
63
+ prediction.status !== "failed" &&
64
+ prediction.status !== "canceled") {
65
+ if (Date.now() >= deadline) {
66
+ throw new Error(`Replicate generation timed out after ${timeoutMs}ms (still ${prediction.status})`);
67
+ }
68
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
69
+ const pollRes = await fetch(prediction.urls.get, {
70
+ headers: { Authorization: `Bearer ${apiToken}` },
71
+ });
72
+ if (!pollRes.ok) {
73
+ throw new Error(`Replicate poll error ${pollRes.status}: ${await pollRes.text()}`);
74
+ }
75
+ prediction = (await pollRes.json());
76
+ }
77
+ if (prediction.status !== "succeeded") {
78
+ throw new Error(`Replicate generation ${prediction.status}: ${stringifyError(prediction.error)}`);
79
+ }
80
+ // Output is either a single URL or an array of them. Download
81
+ // each and base64-encode so the caller gets bytes back, not
82
+ // pre-signed URLs that expire. Track per-URL failures and
83
+ // surface them: silent partial success would let an agent
84
+ // think it got 3 images when one 404'd.
85
+ const outputs = Array.isArray(prediction.output)
86
+ ? prediction.output
87
+ : prediction.output
88
+ ? [prediction.output]
89
+ : [];
90
+ const images = [];
91
+ const failures = [];
92
+ const stamp = Date.now();
93
+ for (const url of outputs) {
94
+ if (typeof url !== "string") {
95
+ failures.push({ url: String(url), reason: "non-string output" });
96
+ continue;
97
+ }
98
+ const imgRes = await fetch(url);
99
+ if (!imgRes.ok) {
100
+ failures.push({
101
+ url,
102
+ reason: `download failed (${imgRes.status})`,
103
+ });
104
+ continue;
105
+ }
106
+ const buf = Buffer.from(await imgRes.arrayBuffer());
107
+ const contentType = (imgRes.headers.get("content-type") ?? "image/webp")
108
+ .split(";")[0]
109
+ .trim();
110
+ images.push(writeImageFile({
111
+ base64: buf.toString("base64"),
112
+ mimeType: contentType,
113
+ provider: "replicate",
114
+ index: images.length,
115
+ saveDir,
116
+ stamp,
117
+ }));
118
+ }
119
+ if (images.length === 0 && outputs.length > 0) {
120
+ const detail = failures.map((f) => `${f.url}: ${f.reason}`).join("; ");
121
+ throw new Error(`Replicate succeeded but all ${outputs.length} output URLs failed to download — ${detail}`);
122
+ }
123
+ return { images, failures };
124
+ }
38
125
  export default function replicate(rl) {
39
126
  rl.setName("replicate");
40
127
  rl.setVersion("0.1.0");
@@ -89,91 +176,78 @@ export default function replicate(rl) {
89
176
  const apiToken = ctx.connection.config.apiToken;
90
177
  const model = p.model ?? "black-forest-labs/flux-dev";
91
178
  const { width, height } = parseSize(p.size, "replicate");
92
- const timeoutMs = p.timeoutMs ?? DEFAULT_TIMEOUT_MS;
93
- const deadline = Date.now() + timeoutMs;
94
- const createRes = await fetch(`https://api.replicate.com/v1/models/${model}/predictions`, {
95
- method: "POST",
96
- headers: {
97
- "Content-Type": "application/json",
98
- Authorization: `Bearer ${apiToken}`,
99
- // `Prefer: wait` lets the server hold the connection open for
100
- // fast jobs so we don't have to poll at all on the happy path.
101
- Prefer: "wait",
179
+ const { images, failures } = await runPrediction({
180
+ apiToken,
181
+ model,
182
+ input: {
183
+ prompt: p.prompt,
184
+ width,
185
+ height,
186
+ num_outputs: Math.min(p.n ?? 1, 4),
102
187
  },
103
- body: JSON.stringify({
104
- input: {
105
- prompt: p.prompt,
106
- width,
107
- height,
108
- num_outputs: Math.min(p.n ?? 1, 4),
109
- },
110
- }),
188
+ timeoutMs: p.timeoutMs ?? DEFAULT_TIMEOUT_MS,
189
+ saveDir: p.saveDir,
111
190
  });
112
- if (!createRes.ok) {
113
- throw new Error(`Replicate API error ${createRes.status}: ${await createRes.text()}`);
114
- }
115
- let prediction = (await createRes.json());
116
- while (prediction.status !== "succeeded" &&
117
- prediction.status !== "failed" &&
118
- prediction.status !== "canceled") {
119
- if (Date.now() >= deadline) {
120
- throw new Error(`Replicate generation timed out after ${timeoutMs}ms (still ${prediction.status})`);
121
- }
122
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
123
- const pollRes = await fetch(prediction.urls.get, {
124
- headers: { Authorization: `Bearer ${apiToken}` },
125
- });
126
- if (!pollRes.ok) {
127
- throw new Error(`Replicate poll error ${pollRes.status}: ${await pollRes.text()}`);
128
- }
129
- prediction = (await pollRes.json());
130
- }
131
- if (prediction.status !== "succeeded") {
132
- throw new Error(`Replicate generation ${prediction.status}: ${stringifyError(prediction.error)}`);
133
- }
134
- // Output is either a single URL or an array of them. Download
135
- // each and base64-encode so the caller gets bytes back, not
136
- // pre-signed URLs that expire. Track per-URL failures and
137
- // surface them: silent partial success would let an agent
138
- // think it got 3 images when one 404'd.
139
- const outputs = Array.isArray(prediction.output)
140
- ? prediction.output
141
- : prediction.output
142
- ? [prediction.output]
143
- : [];
144
- const images = [];
145
- const failures = [];
146
- const stamp = Date.now();
147
- for (const url of outputs) {
148
- if (typeof url !== "string") {
149
- failures.push({ url: String(url), reason: "non-string output" });
150
- continue;
151
- }
152
- const imgRes = await fetch(url);
153
- if (!imgRes.ok) {
154
- failures.push({
155
- url,
156
- reason: `download failed (${imgRes.status})`,
157
- });
158
- continue;
159
- }
160
- const buf = Buffer.from(await imgRes.arrayBuffer());
161
- const contentType = (imgRes.headers.get("content-type") ?? "image/webp")
162
- .split(";")[0]
163
- .trim();
164
- images.push(writeImageFile({
165
- base64: buf.toString("base64"),
166
- mimeType: contentType,
167
- provider: "replicate",
168
- index: images.length,
169
- saveDir: p.saveDir,
170
- stamp,
171
- }));
172
- }
173
- if (images.length === 0 && outputs.length > 0) {
174
- const detail = failures.map((f) => `${f.url}: ${f.reason}`).join("; ");
175
- throw new Error(`Replicate succeeded but all ${outputs.length} output URLs failed to download — ${detail}`);
191
+ const result = { provider: "replicate", model, images, note: SEND_FILE_NOTE };
192
+ if (failures.length > 0)
193
+ result.failures = failures;
194
+ return result;
195
+ },
196
+ });
197
+ rl.registerAction("image.edit", {
198
+ access: "write",
199
+ description: "Edit a local image via Replicate: give the file path and describe the change. Default model is black-forest-labs/flux-kontext-pro (input key `input_image`); other models may name their image input differently — override with `imageInputKey` (e.g. google/nano-banana uses `image_input`, which is sent as an array). Writes the edited image(s) to disk and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
200
+ inputSchema: {
201
+ prompt: {
202
+ type: "string",
203
+ required: true,
204
+ description: "Instruction describing the edit to apply",
205
+ },
206
+ imagePath: {
207
+ type: "string",
208
+ required: true,
209
+ description: "Path to the source image file (jpeg/png/gif/webp). Sent as a data URI — Replicate recommends hosted URLs for files over ~256 KB, so very large images may be rejected.",
210
+ },
211
+ saveDir: {
212
+ type: "string",
213
+ required: false,
214
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
215
+ },
216
+ model: {
217
+ type: "string",
218
+ required: false,
219
+ description: "Edit-capable Replicate model id, e.g. black-forest-labs/flux-kontext-pro (default), black-forest-labs/flux-kontext-max, google/nano-banana",
220
+ },
221
+ imageInputKey: {
222
+ type: "string",
223
+ required: false,
224
+ description: "Name of the model's image input field (default: input_image). Keys ending in `_input` are sent as an array.",
225
+ },
226
+ timeoutMs: {
227
+ type: "number",
228
+ required: false,
229
+ description: "Max ms to wait for the prediction to finish (default: 300000 = 5 minutes)",
230
+ },
231
+ },
232
+ async execute(input, ctx) {
233
+ const p = (input ?? {});
234
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
235
+ throw new Error("replicate: prompt is required");
176
236
  }
237
+ const img = readImageInput(p.imagePath, "replicate");
238
+ const apiToken = ctx.connection.config.apiToken;
239
+ const model = p.model ?? "black-forest-labs/flux-kontext-pro";
240
+ const imageKey = p.imageInputKey ?? "input_image";
241
+ const { images, failures } = await runPrediction({
242
+ apiToken,
243
+ model,
244
+ input: {
245
+ prompt: p.prompt,
246
+ [imageKey]: imageKey.endsWith("_input") ? [img.dataUri] : img.dataUri,
247
+ },
248
+ timeoutMs: p.timeoutMs ?? DEFAULT_TIMEOUT_MS,
249
+ saveDir: p.saveDir,
250
+ });
177
251
  const result = { provider: "replicate", model, images, note: SEND_FILE_NOTE };
178
252
  if (failures.length > 0)
179
253
  result.failures = failures;
@@ -12,7 +12,7 @@
12
12
  * fidelity switch to FLUX.1-dev / Ideogram / Qwen-Image and bump
13
13
  * `steps` accordingly (20–30 is typical for non-schnell models).
14
14
  */
15
- import { SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
15
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
16
16
  import { parseSize } from "../../_shared/parseSize.js";
17
17
  const ENDPOINT = "https://api.together.xyz/v1/images/generations";
18
18
  export default function together(rl) {
@@ -95,4 +95,72 @@ export default function together(rl) {
95
95
  return { provider: "together", model, images, note: SEND_FILE_NOTE };
96
96
  },
97
97
  });
98
+ rl.registerAction("image.edit", {
99
+ access: "write",
100
+ description: "Edit a local image with Together AI (FLUX.1 Kontext): give the file path and describe the change. Sends the image as a data URI via `image_url` on the generations endpoint, writes the edited image(s) to disk, and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
101
+ inputSchema: {
102
+ prompt: {
103
+ type: "string",
104
+ required: true,
105
+ description: "Instruction describing the edit to apply",
106
+ },
107
+ imagePath: {
108
+ type: "string",
109
+ required: true,
110
+ description: "Path to the source image file",
111
+ },
112
+ saveDir: {
113
+ type: "string",
114
+ required: false,
115
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
116
+ },
117
+ model: {
118
+ type: "string",
119
+ required: false,
120
+ description: "Edit-capable model, e.g. black-forest-labs/FLUX.1-kontext-pro (default), black-forest-labs/FLUX.1-kontext-max",
121
+ },
122
+ steps: {
123
+ type: "number",
124
+ required: false,
125
+ description: "Inference steps (default: 28)",
126
+ },
127
+ n: {
128
+ type: "number",
129
+ required: false,
130
+ description: "Number of edited variants (default: 1, max: 4)",
131
+ },
132
+ },
133
+ async execute(input, ctx) {
134
+ const p = (input ?? {});
135
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
136
+ throw new Error("together: prompt is required");
137
+ }
138
+ const img = readImageInput(p.imagePath, "together");
139
+ const apiKey = ctx.connection.config.apiKey;
140
+ const model = p.model ?? "black-forest-labs/FLUX.1-kontext-pro";
141
+ const body = {
142
+ model,
143
+ prompt: p.prompt,
144
+ image_url: img.dataUri,
145
+ steps: p.steps ?? 28,
146
+ n: Math.min(p.n ?? 1, 4),
147
+ response_format: "base64",
148
+ };
149
+ const res = await fetch(ENDPOINT, {
150
+ method: "POST",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ Authorization: `Bearer ${apiKey}`,
154
+ },
155
+ body: JSON.stringify(body),
156
+ });
157
+ if (!res.ok) {
158
+ throw new Error(`Together API error ${res.status}: ${await res.text()}`);
159
+ }
160
+ const data = (await res.json());
161
+ const stamp = Date.now();
162
+ const images = (data.data ?? []).map((d, i) => writeImageFile({ base64: d.b64_json, mimeType: "image/png", provider: "together", index: i, saveDir: p.saveDir, stamp }));
163
+ return { provider: "together", model, images, note: SEND_FILE_NOTE };
164
+ },
165
+ });
98
166
  }
@@ -8,9 +8,11 @@
8
8
  * text rendering well. Sized via aspect_ratio rather than W×H —
9
9
  * the API does the math.
10
10
  */
11
- import { SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
11
+ import { readImageInput, SEND_FILE_NOTE, writeImageFile } from "../../_shared/imageFile.js";
12
12
  const ENDPOINT = "https://api.x.ai/v1/images/generations";
13
+ const EDIT_ENDPOINT = "https://api.x.ai/v1/images/edits";
13
14
  const MODEL = "grok-imagine-image";
15
+ const EDIT_MODEL = "grok-imagine-image-2.0";
14
16
  export default function xai(rl) {
15
17
  rl.setName("xai");
16
18
  rl.setVersion("0.1.0");
@@ -75,10 +77,74 @@ export default function xai(rl) {
75
77
  const data = (await res.json());
76
78
  const stamp = Date.now();
77
79
  const images = (data.data ?? []).map((d, i) => ({
78
- ...writeImageFile({ base64: d.b64_json, mimeType: "image/jpeg", provider: "xai", index: i, saveDir: p.saveDir, stamp }),
80
+ ...writeImageFile({ base64: d.b64_json, mimeType: d.mime_type ?? "image/jpeg", provider: "xai", index: i, saveDir: p.saveDir, stamp }),
79
81
  ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
80
82
  }));
81
83
  return { provider: "xai", model: MODEL, images, note: SEND_FILE_NOTE };
82
84
  },
83
85
  });
86
+ rl.registerAction("image.edit", {
87
+ access: "write",
88
+ description: "Edit a local image with xAI Grok Imagine: give the file path and describe the change. Sends the image as a data URI to /v1/images/edits (JSON, not multipart), writes the edited image(s) to disk, and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
89
+ inputSchema: {
90
+ prompt: {
91
+ type: "string",
92
+ required: true,
93
+ description: "Instruction describing the edit to apply",
94
+ },
95
+ imagePath: {
96
+ type: "string",
97
+ required: true,
98
+ description: "Path to the source image file",
99
+ },
100
+ saveDir: {
101
+ type: "string",
102
+ required: false,
103
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
104
+ },
105
+ model: {
106
+ type: "string",
107
+ required: false,
108
+ description: "Edit-capable model (default: grok-imagine-image-2.0)",
109
+ },
110
+ n: {
111
+ type: "number",
112
+ required: false,
113
+ description: "Number of edited variants (default: 1, max: 10)",
114
+ },
115
+ },
116
+ async execute(input, ctx) {
117
+ const p = (input ?? {});
118
+ if (typeof p.prompt !== "string" || p.prompt.length === 0) {
119
+ throw new Error("xai: prompt is required");
120
+ }
121
+ const img = readImageInput(p.imagePath, "xai");
122
+ const apiKey = ctx.connection.config.apiKey;
123
+ const model = p.model ?? EDIT_MODEL;
124
+ const res = await fetch(EDIT_ENDPOINT, {
125
+ method: "POST",
126
+ headers: {
127
+ "Content-Type": "application/json",
128
+ Authorization: `Bearer ${apiKey}`,
129
+ },
130
+ body: JSON.stringify({
131
+ model,
132
+ prompt: p.prompt,
133
+ image: { url: img.dataUri },
134
+ n: Math.min(p.n ?? 1, 10),
135
+ response_format: "b64_json",
136
+ }),
137
+ });
138
+ if (!res.ok) {
139
+ throw new Error(`xAI API error ${res.status}: ${await res.text()}`);
140
+ }
141
+ const data = (await res.json());
142
+ const stamp = Date.now();
143
+ const images = (data.data ?? []).map((d, i) => ({
144
+ ...writeImageFile({ base64: d.b64_json, mimeType: d.mime_type ?? "image/jpeg", provider: "xai", index: i, saveDir: p.saveDir, stamp }),
145
+ ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
146
+ }));
147
+ return { provider: "xai", model, images, note: SEND_FILE_NOTE };
148
+ },
149
+ });
84
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
4
4
  "description": "Code mode for agents \u2014 turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",