runline 0.27.1 → 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.
@@ -459,6 +459,12 @@ function formatError(cause) {
459
459
  // code gets a cwd-relative `require`, so a bare specifier would resolve
460
460
  // against the caller's cwd instead of runline's own node_modules.
461
461
  const ferrosearchPath = createRequire(import.meta.url).resolve("@shift-labs/ferrosearch");
462
+ // typebox ships ESM only; require() of an .mjs works everywhere runline
463
+ // runs (bun, and node since 22.12's require(esm)). Resolved host-side
464
+ // for the same reason as ferrosearch: eval'd worker code gets a
465
+ // cwd-relative require, so a bare specifier would resolve against the
466
+ // caller's cwd instead of runline's own node_modules.
467
+ const typeboxValuePath = createRequire(import.meta.url).resolve("typebox/value");
462
468
  function buildHelpData(plugins) {
463
469
  const data = {};
464
470
  for (const p of plugins) {
@@ -467,6 +473,7 @@ function buildHelpData(plugins) {
467
473
  access: a.access,
468
474
  description: a.description,
469
475
  inputs: helpInputs(a.inputSchema),
476
+ ...(isTypedInputSchema(a.inputSchema) ? { schema: a.inputSchema } : {}),
470
477
  }));
471
478
  }
472
479
  return data;
@@ -594,6 +601,9 @@ const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(
594
601
  // also means an index built by agent code is invisible to that limit.
595
602
  const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
596
603
 
604
+ // The host's own validator, for actions.check on typed schemas.
605
+ const { Check: __schemaCheck, Errors: __schemaErrors } = require(${JSON.stringify(typeboxValuePath)});
606
+
597
607
  const __help = ${JSON.stringify(helpData)};
598
608
 
599
609
  const __makeProxy = (path = []) => new Proxy(() => undefined, {
@@ -653,6 +663,82 @@ const __search = (() => {
653
663
  return index;
654
664
  })();
655
665
 
666
+ // instancePath "/members/0/externalId" → ["members", "0", "externalId"],
667
+ // with JSON-pointer escapes (~1 for /, ~0 for ~) undone per part.
668
+ const __pointerParts = (instancePath) =>
669
+ String(instancePath || '').split('/').filter((p) => p.length)
670
+ .map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
671
+
672
+ // ["members", "0", "externalId"] → "members[0].externalId"
673
+ const __fieldPath = (instancePath, prop) => {
674
+ const parts = __pointerParts(instancePath);
675
+ if (prop !== undefined) parts.push(String(prop));
676
+ let out = '';
677
+ for (const part of parts) {
678
+ if (/^\\d+$/.test(part)) out += '[' + part + ']';
679
+ else out += out ? '.' + part : part;
680
+ }
681
+ return out;
682
+ };
683
+
684
+ const __valueAt = (root, instancePath) => {
685
+ let v = root;
686
+ for (const part of __pointerParts(instancePath)) {
687
+ if (v == null) return undefined;
688
+ v = v[part];
689
+ }
690
+ return v;
691
+ };
692
+
693
+ const __valueType = (v) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v;
694
+
695
+ // Deep validation for typed schemas (SHFT-1187): the same Check/Errors
696
+ // the host runs before execute, on the same schema, so the preflight
697
+ // verdict and the execution verdict cannot diverge. The familiar
698
+ // missing/unknown/typeErrors buckets are derived from the validator's
699
+ // structured errors — keyword and params, never message parsing — and
700
+ // the errors array carries each raw "<path> <message>" line exactly as
701
+ // a rejected execution would report it. Grouping type errors by instance
702
+ // path keeps a failed union one typeError instead of one per branch.
703
+ const __checkTyped = (entry, provided, signature) => {
704
+ if (__schemaCheck(entry.schema, provided)) {
705
+ return { ok: true, missing: [], unknown: [], typeErrors: [], errors: [], signature };
706
+ }
707
+ const missing = [];
708
+ const unknown = [];
709
+ const errors = [];
710
+ const byPath = new Map();
711
+ for (const err of __schemaErrors(entry.schema, provided)) {
712
+ errors.push((err.instancePath || '/') + ' ' + err.message);
713
+ const params = err.params || {};
714
+ if (err.keyword === 'required') {
715
+ for (const prop of params.requiredProperties || []) missing.push(__fieldPath(err.instancePath, prop));
716
+ } else if (err.keyword === 'additionalProperties') {
717
+ for (const prop of params.additionalProperties || []) unknown.push(__fieldPath(err.instancePath, prop));
718
+ } else {
719
+ const group = byPath.get(err.instancePath) || [];
720
+ group.push(err);
721
+ byPath.set(err.instancePath, group);
722
+ }
723
+ }
724
+ const typeErrors = [];
725
+ for (const [instancePath, group] of byPath) {
726
+ const field = __fieldPath(instancePath) || '(input)';
727
+ const value = __valueAt(provided, instancePath);
728
+ const consts = group.filter((e) => e.keyword === 'const' && 'allowedValue' in (e.params || {}));
729
+ const typeErr = group.find((e) => e.keyword === 'type' && (e.params || {}).type);
730
+ if (consts.length) {
731
+ // A union of literals: the branches ARE the allowed values.
732
+ typeErrors.push({ field, expected: consts.map((e) => String(e.params.allowedValue)).join(' | '), actual: __fmt(value) });
733
+ } else if (typeErr) {
734
+ typeErrors.push({ field, expected: String(typeErr.params.type), actual: __valueType(value) });
735
+ } else {
736
+ typeErrors.push({ field, expected: group[0].message, actual: __fmt(value) });
737
+ }
738
+ }
739
+ return { ok: false, missing, unknown, typeErrors, errors, signature };
740
+ };
741
+
656
742
  const __actionsApi = {
657
743
  list(plugin) {
658
744
  const paths = Object.keys(__index);
@@ -691,6 +777,13 @@ const __actionsApi = {
691
777
  const near = __actionsApi.find(path, 3).map((n) => n.path);
692
778
  return { ok: false, error: 'Unknown action: ' + path, suggestions: near };
693
779
  }
780
+ const signature = __formatSignature(hit.plugin, hit.entry);
781
+ if (hit.entry.schema) {
782
+ // Mirror the host's normalizeActionInput: a check with no args is
783
+ // a call with an empty argument set; anything else — null, a
784
+ // scalar — is validated as given, exactly as execute would.
785
+ return __checkTyped(hit.entry, args === undefined ? {} : args, signature);
786
+ }
694
787
  const inputs = hit.entry.inputs || {};
695
788
  const provided = args && typeof args === 'object' ? args : {};
696
789
  const missing = [];
@@ -719,7 +812,8 @@ const __actionsApi = {
719
812
  missing,
720
813
  unknown,
721
814
  typeErrors,
722
- signature: __formatSignature(hit.plugin, hit.entry),
815
+ errors: [],
816
+ signature,
723
817
  };
724
818
  },
725
819
  };
@@ -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.1",
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",