runline 0.7.7 → 0.8.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.
@@ -0,0 +1,378 @@
1
+ const NAME = "steel";
2
+ const DEFAULT_STEEL_BASE = "https://api.steel.dev";
3
+ // ── Helpers ──────────────────────────────────────────────────────────
4
+ function compactText(value) {
5
+ return String(value ?? "").replace(/\s+/g, " ").trim();
6
+ }
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+ function clampWait(value, fallback = 9000, max = 45000) {
11
+ const n = Number(value);
12
+ if (!Number.isFinite(n) || n <= 0)
13
+ return fallback;
14
+ return Math.min(Math.floor(n), max);
15
+ }
16
+ function getConnectionConfig(ctx) {
17
+ const cfg = (ctx?.connection?.config ?? {});
18
+ return {
19
+ steelApiKey: compactText(cfg.steelApiKey),
20
+ steelBaseUrl: compactText(cfg.steelBaseUrl || DEFAULT_STEEL_BASE).replace(/\/+$/, ""),
21
+ steelProxyUrl: compactText(cfg.steelProxyUrl),
22
+ };
23
+ }
24
+ class SteelClient {
25
+ apiKey;
26
+ baseUrl;
27
+ proxyUrl;
28
+ constructor(cfg) {
29
+ if (!cfg.steelApiKey) {
30
+ throw new Error("Missing STEEL_API_KEY. Configure it before using the steel browser plugin.");
31
+ }
32
+ this.apiKey = cfg.steelApiKey;
33
+ this.baseUrl = cfg.steelBaseUrl || DEFAULT_STEEL_BASE;
34
+ this.proxyUrl = cfg.steelProxyUrl;
35
+ }
36
+ async request(method, pathname, body) {
37
+ const resp = await fetch(`${this.baseUrl}${pathname}`, {
38
+ method,
39
+ headers: { "steel-api-key": this.apiKey, "content-type": "application/json" },
40
+ body: body ? JSON.stringify(body) : undefined,
41
+ });
42
+ const text = await resp.text();
43
+ if (!resp.ok) {
44
+ throw new Error(`Steel ${method} ${pathname} -> ${resp.status}: ${text.slice(0, 300)}`);
45
+ }
46
+ return text ? JSON.parse(text) : {};
47
+ }
48
+ async createSession({ useProxy = false, timeout = 180000 } = {}) {
49
+ const body = { timeout };
50
+ if (useProxy) {
51
+ if (!this.proxyUrl)
52
+ throw new Error("useProxy requested but STEEL_PROXY_URL is not configured.");
53
+ body.proxyUrl = this.proxyUrl;
54
+ }
55
+ return this.request("POST", "/v1/sessions", body);
56
+ }
57
+ async release(sessionId) {
58
+ try {
59
+ return await this.request("POST", `/v1/sessions/${sessionId}/release`);
60
+ }
61
+ catch {
62
+ return { success: false };
63
+ }
64
+ }
65
+ // Steel's hosted screenshot API stores the image on images.steel.dev and
66
+ // returns a PUBLIC URL (no auth) that renders inline in chat — the bytes
67
+ // never have to traverse the agent runtime (which strips large base64).
68
+ async hostedScreenshot({ url, fullPage = false, delay, useProxy = false }) {
69
+ const body = { url };
70
+ if (fullPage)
71
+ body.fullPage = true;
72
+ if (Number.isFinite(delay) && delay > 0)
73
+ body.delay = delay;
74
+ if (useProxy) {
75
+ if (!this.proxyUrl)
76
+ throw new Error("useProxy requested but STEEL_PROXY_URL is not configured.");
77
+ body.proxyUrl = this.proxyUrl;
78
+ }
79
+ const res = await this.request("POST", "/v1/screenshot", body);
80
+ return res?.url || null;
81
+ }
82
+ }
83
+ class CdpConnection {
84
+ wsUrl;
85
+ nextId = 0;
86
+ pending = new Map();
87
+ ws = null;
88
+ constructor(wsUrl) {
89
+ this.wsUrl = wsUrl;
90
+ }
91
+ connect() {
92
+ return new Promise((resolve, reject) => {
93
+ if (typeof WebSocket === "undefined") {
94
+ reject(new Error("Global WebSocket is not available in this runtime; the steel browser plugin needs Node 22+ / Bun."));
95
+ return;
96
+ }
97
+ const ws = new WebSocket(this.wsUrl);
98
+ this.ws = ws;
99
+ const onError = () => reject(new Error("Steel CDP websocket connection failed"));
100
+ ws.addEventListener("open", () => {
101
+ ws.removeEventListener("error", onError);
102
+ resolve();
103
+ });
104
+ ws.addEventListener("error", onError);
105
+ ws.addEventListener("close", () => {
106
+ for (const [, p] of this.pending)
107
+ p.reject(new Error("Steel CDP connection closed"));
108
+ this.pending.clear();
109
+ });
110
+ ws.addEventListener("message", (ev) => {
111
+ let msg;
112
+ try {
113
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ if (msg.id != null && this.pending.has(msg.id)) {
119
+ const p = this.pending.get(msg.id);
120
+ this.pending.delete(msg.id);
121
+ if (msg.error)
122
+ p.reject(new Error(msg.error.message || "CDP error"));
123
+ else
124
+ p.resolve(msg.result);
125
+ }
126
+ });
127
+ });
128
+ }
129
+ send(method, params = {}, sessionId, timeoutMs = 45000) {
130
+ const requestId = ++this.nextId;
131
+ const payload = { id: requestId, method, params };
132
+ if (sessionId)
133
+ payload.sessionId = sessionId;
134
+ return new Promise((resolve, reject) => {
135
+ this.pending.set(requestId, { resolve, reject });
136
+ try {
137
+ this.ws.send(JSON.stringify(payload));
138
+ }
139
+ catch (err) {
140
+ this.pending.delete(requestId);
141
+ reject(err);
142
+ return;
143
+ }
144
+ setTimeout(() => {
145
+ if (this.pending.has(requestId)) {
146
+ this.pending.delete(requestId);
147
+ reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`));
148
+ }
149
+ }, timeoutMs);
150
+ });
151
+ }
152
+ close() {
153
+ try {
154
+ this.ws?.close();
155
+ }
156
+ catch {
157
+ /* noop */
158
+ }
159
+ }
160
+ }
161
+ class PageDriver {
162
+ conn;
163
+ sessionId;
164
+ constructor(conn, sessionId) {
165
+ this.conn = conn;
166
+ this.sessionId = sessionId;
167
+ }
168
+ static async attachFirstPage(conn) {
169
+ const { targetInfos } = await conn.send("Target.getTargets");
170
+ let target = (targetInfos || []).find((t) => t.type === "page");
171
+ if (!target) {
172
+ const created = await conn.send("Target.createTarget", { url: "about:blank" });
173
+ target = { targetId: created.targetId };
174
+ }
175
+ const { sessionId } = await conn.send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
176
+ const driver = new PageDriver(conn, sessionId);
177
+ await conn.send("Page.enable", {}, sessionId);
178
+ await conn.send("Runtime.enable", {}, sessionId);
179
+ return driver;
180
+ }
181
+ async navigate(url) {
182
+ await this.conn.send("Page.navigate", { url }, this.sessionId);
183
+ }
184
+ async eval(expression) {
185
+ const r = await this.conn.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, this.sessionId);
186
+ if (r.exceptionDetails) {
187
+ throw new Error(`page eval failed: ${r.exceptionDetails.text || r.exceptionDetails.exception?.description || "exception"}`);
188
+ }
189
+ return r.result?.value;
190
+ }
191
+ }
192
+ // Render a URL in a Steel browser and run a callback with the live PageDriver.
193
+ async function withRenderedPage(cfg, { url, useProxy = false, waitMs = 9000, waitSelector }, fn) {
194
+ const steel = new SteelClient(cfg);
195
+ const session = await steel.createSession({ useProxy });
196
+ const wsUrl = `${session.websocketUrl}${session.websocketUrl.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(steel.apiKey)}`;
197
+ const conn = new CdpConnection(wsUrl);
198
+ try {
199
+ await conn.connect();
200
+ const page = await PageDriver.attachFirstPage(conn);
201
+ await page.navigate(url);
202
+ await sleep(Math.min(waitMs, 6000));
203
+ if (waitSelector) {
204
+ const deadline = Date.now() + Math.max(0, waitMs - 6000);
205
+ while (Date.now() < deadline) {
206
+ const found = await page.eval(`!!document.querySelector(${JSON.stringify(waitSelector)})`);
207
+ if (found)
208
+ break;
209
+ await sleep(1500);
210
+ }
211
+ }
212
+ else if (waitMs > 6000) {
213
+ await sleep(waitMs - 6000);
214
+ }
215
+ return await fn(page, { sessionId: session.id, viewer: session.sessionViewerUrl });
216
+ }
217
+ finally {
218
+ conn.close();
219
+ await steel.release(session.id);
220
+ }
221
+ }
222
+ function robotWallExpr() {
223
+ return `/are you a human|verify you('| a)re not a robot|not a robot|enable javascript|access denied|unusual traffic/i.test((document.body && document.body.innerText || '').slice(0, 4000))`;
224
+ }
225
+ // ── Plugin registration ──────────────────────────────────────────────
226
+ export default function steel(rl) {
227
+ rl.setName(NAME);
228
+ rl.setVersion("0.1.0");
229
+ rl.setConnectionSchema({
230
+ steelApiKey: {
231
+ type: "string",
232
+ required: true,
233
+ env: "STEEL_API_KEY",
234
+ description: "Steel.dev API key for the cloud browser. Store only in secrets.",
235
+ },
236
+ steelBaseUrl: {
237
+ type: "string",
238
+ required: false,
239
+ env: "STEEL_API_BASE",
240
+ default: DEFAULT_STEEL_BASE,
241
+ description: "Steel.dev API base URL.",
242
+ },
243
+ steelProxyUrl: {
244
+ type: "string",
245
+ required: false,
246
+ env: "STEEL_PROXY_URL",
247
+ description: "Residential proxy URL, used only when an action passes useProxy:true. Store only in secrets.",
248
+ },
249
+ });
250
+ rl.registerAction("browser.scrape", {
251
+ description: "Render a URL in a real Steel cloud browser (executes JavaScript, passes 'verify you're not a robot' walls) and return its content. Use this instead of plain fetch for JS-heavy or anti-bot pages. No proxy by default; set useProxy:true only for datacenter-IP-blocked surfaces.",
252
+ inputSchema: {
253
+ url: { type: "string", required: true, description: "Absolute URL to render." },
254
+ format: { type: "string", required: false, default: "text", description: "text (visible innerText), html (rendered outerHTML), or links (anchor list)." },
255
+ waitMs: { type: "number", required: false, default: 9000, description: "How long to let the page render (ms, max 45000)." },
256
+ waitSelector: { type: "string", required: false, description: "Optional CSS selector to wait for before extracting." },
257
+ maxChars: { type: "number", required: false, default: 20000, description: "Cap on returned content length." },
258
+ useProxy: { type: "boolean", required: false, default: false, description: "Route through the residential proxy. Only needed for datacenter-blocked sites." },
259
+ },
260
+ async execute(input, ctx) {
261
+ const cfg = getConnectionConfig(ctx);
262
+ const url = compactText(input.url);
263
+ if (!/^https?:\/\//i.test(url))
264
+ throw new Error("url must be an absolute http(s) URL");
265
+ const format = compactText(input.format || "text").toLowerCase();
266
+ const maxChars = Number(input.maxChars) > 0 ? Math.min(Number(input.maxChars), 200000) : 20000;
267
+ return withRenderedPage(cfg, { url, useProxy: input.useProxy === true, waitMs: clampWait(input.waitMs), waitSelector: input.waitSelector }, async (page, meta) => {
268
+ const title = await page.eval("document.title");
269
+ const finalUrl = await page.eval("location.href");
270
+ const robotWall = await page.eval(robotWallExpr());
271
+ let content;
272
+ if (format === "html")
273
+ content = await page.eval("document.documentElement.outerHTML");
274
+ else if (format === "links")
275
+ content = JSON.stringify(await page.eval(`Array.from(document.querySelectorAll('a[href]')).slice(0,300).map(function(a){return {text:(a.innerText||'').replace(/\\s+/g,' ').trim().slice(0,80), href:a.href};})`));
276
+ else
277
+ content = await page.eval("document.body && document.body.innerText || ''");
278
+ return {
279
+ url,
280
+ finalUrl,
281
+ title,
282
+ robotWall,
283
+ format,
284
+ truncated: String(content).length > maxChars,
285
+ content: String(content).slice(0, maxChars),
286
+ viewerUrl: meta.viewer,
287
+ source: "steel.browser.scrape",
288
+ };
289
+ });
290
+ },
291
+ });
292
+ rl.registerAction("browser.extract", {
293
+ description: "Render a URL in a Steel browser and extract structured data via a map of CSS selectors. Returns the first match (or all matches with all:true) of each selector as text.",
294
+ inputSchema: {
295
+ url: { type: "string", required: true, description: "Absolute URL to render." },
296
+ selectors: { type: "object", required: true, description: "Map of { fieldName: cssSelector }." },
297
+ all: { type: "boolean", required: false, default: false, description: "Return all matches per selector instead of the first." },
298
+ waitMs: { type: "number", required: false, default: 9000, description: "Render wait (ms)." },
299
+ waitSelector: { type: "string", required: false, description: "Optional CSS selector to wait for." },
300
+ useProxy: { type: "boolean", required: false, default: false, description: "Route through the residential proxy." },
301
+ },
302
+ async execute(input, ctx) {
303
+ const cfg = getConnectionConfig(ctx);
304
+ const url = compactText(input.url);
305
+ if (!/^https?:\/\//i.test(url))
306
+ throw new Error("url must be an absolute http(s) URL");
307
+ const selectors = input.selectors && typeof input.selectors === "object" ? input.selectors : null;
308
+ if (!selectors || !Object.keys(selectors).length)
309
+ throw new Error("selectors must be a non-empty object of { name: cssSelector }");
310
+ const all = input.all === true;
311
+ return withRenderedPage(cfg, { url, useProxy: input.useProxy === true, waitMs: clampWait(input.waitMs), waitSelector: input.waitSelector }, async (page, meta) => {
312
+ const expr = `
313
+ (function(){
314
+ var sels = ${JSON.stringify(selectors)};
315
+ var all = ${all};
316
+ function txt(el){ return el ? (el.innerText||el.textContent||'').replace(/\\s+/g,' ').trim() : null; }
317
+ var out = {};
318
+ for (var k in sels){
319
+ if (all){ out[k] = Array.from(document.querySelectorAll(sels[k])).slice(0,50).map(txt); }
320
+ else { out[k] = txt(document.querySelector(sels[k])); }
321
+ }
322
+ return out;
323
+ })()`;
324
+ return {
325
+ url,
326
+ finalUrl: await page.eval("location.href"),
327
+ title: await page.eval("document.title"),
328
+ robotWall: await page.eval(robotWallExpr()),
329
+ data: await page.eval(expr),
330
+ viewerUrl: meta.viewer,
331
+ source: "steel.browser.extract",
332
+ };
333
+ });
334
+ },
335
+ });
336
+ rl.registerAction("browser.screenshot", {
337
+ description: "Capture a screenshot of a URL with the Steel cloud browser and return imageUrl — a PUBLIC, login-free PNG link (https://images.steel.dev/...). To 'send a screenshot', put imageUrl in your reply: WhatsApp/Slack render it inline and anyone can open it without a Steel account. base64 is opt-in and usually pointless (the agent runtime strips large base64 from action results), so prefer imageUrl.",
338
+ inputSchema: {
339
+ url: { type: "string", required: true, description: "Absolute URL to capture." },
340
+ fullPage: { type: "boolean", required: false, default: false, description: "Capture the full scrollable page." },
341
+ waitMs: { type: "number", required: false, default: 0, description: "Milliseconds to wait before capturing (for JS-heavy pages). 0 = capture as soon as loaded." },
342
+ useProxy: { type: "boolean", required: false, default: false, description: "Route through the BYO residential proxy (STEEL_PROXY_URL). Only for datacenter-IP-blocked surfaces." },
343
+ includeBase64: { type: "boolean", required: false, default: false, description: "Also fetch the hosted image and return raw PNG base64. Off by default — share imageUrl instead." },
344
+ },
345
+ async execute(input, ctx) {
346
+ const cfg = getConnectionConfig(ctx);
347
+ const url = compactText(input.url);
348
+ if (!/^https?:\/\//i.test(url))
349
+ throw new Error("url must be an absolute http(s) URL");
350
+ const steel = new SteelClient(cfg);
351
+ const imageUrl = await steel.hostedScreenshot({
352
+ url,
353
+ fullPage: input.fullPage === true,
354
+ delay: clampWait(input.waitMs, 0, 30000) || undefined,
355
+ useProxy: input.useProxy === true,
356
+ });
357
+ const out = {
358
+ url,
359
+ imageUrl,
360
+ mimeType: "image/png",
361
+ note: "Share imageUrl — it's a public, login-free screenshot link that renders inline in chat. Put it in your reply text; don't screenshot-to-base64.",
362
+ source: "steel.browser.screenshot",
363
+ };
364
+ if (input.includeBase64 === true && imageUrl) {
365
+ try {
366
+ const resp = await fetch(imageUrl);
367
+ const buf = Buffer.from(await resp.arrayBuffer());
368
+ out.byteLength = buf.length;
369
+ out.base64 = buf.toString("base64");
370
+ }
371
+ catch (e) {
372
+ out.base64Error = String(e?.message || e);
373
+ }
374
+ }
375
+ return out;
376
+ },
377
+ });
378
+ }
@@ -12,6 +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
16
  import { parseSize } from "../../_shared/parseSize.js";
16
17
  const ENDPOINT = "https://api.together.xyz/v1/images/generations";
17
18
  export default function together(rl) {
@@ -26,13 +27,18 @@ export default function together(rl) {
26
27
  },
27
28
  });
28
29
  rl.registerAction("image.create", {
29
- description: "Generate an image with Together AI (Flux, Ideogram, Qwen-Image, …). Returns base64-encoded PNGs.",
30
+ description: "Generate an image with Together AI (Flux, Ideogram, Qwen-Image, …). Writes the image(s) to disk and returns their file `path`s — not base64. Deliver each with send_file using its `path`.",
30
31
  inputSchema: {
31
32
  prompt: {
32
33
  type: "string",
33
34
  required: true,
34
35
  description: "Detailed description of the image",
35
36
  },
37
+ saveDir: {
38
+ type: "string",
39
+ required: false,
40
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
41
+ },
36
42
  model: {
37
43
  type: "string",
38
44
  required: false,
@@ -83,11 +89,9 @@ export default function together(rl) {
83
89
  throw new Error(`Together API error ${res.status}: ${await res.text()}`);
84
90
  }
85
91
  const data = (await res.json());
86
- const images = (data.data ?? []).map((d) => ({
87
- base64: d.b64_json,
88
- mimeType: "image/png",
89
- }));
90
- return { provider: "together", model, images };
92
+ const stamp = Date.now();
93
+ const images = (data.data ?? []).map((d, i) => writeImageFile({ base64: d.b64_json, mimeType: "image/png", provider: "together", index: i, saveDir: p.saveDir, stamp }));
94
+ return { provider: "together", model, images, note: SEND_FILE_NOTE };
91
95
  },
92
96
  });
93
97
  }
@@ -8,6 +8,7 @@
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
12
  const ENDPOINT = "https://api.x.ai/v1/images/generations";
12
13
  const MODEL = "grok-imagine-image";
13
14
  export default function xai(rl) {
@@ -22,13 +23,18 @@ export default function xai(rl) {
22
23
  },
23
24
  });
24
25
  rl.registerAction("image.create", {
25
- description: "Generate an image with xAI Grok Imagine (Aurora). Returns base64-encoded JPEGs and any revised prompt the model produced.",
26
+ description: "Generate an image with xAI Grok Imagine (Aurora). Writes the JPEG(s) to disk and returns their file `path`s (plus any revised prompt) not base64. Deliver each with send_file using its `path`.",
26
27
  inputSchema: {
27
28
  prompt: {
28
29
  type: "string",
29
30
  required: true,
30
31
  description: "Detailed description of the image",
31
32
  },
33
+ saveDir: {
34
+ type: "string",
35
+ required: false,
36
+ description: "Directory to write the image file(s) into. Defaults to the OS temp dir.",
37
+ },
32
38
  aspectRatio: {
33
39
  type: "string",
34
40
  required: false,
@@ -66,12 +72,12 @@ export default function xai(rl) {
66
72
  throw new Error(`xAI API error ${res.status}: ${await res.text()}`);
67
73
  }
68
74
  const data = (await res.json());
69
- const images = (data.data ?? []).map((d) => ({
70
- base64: d.b64_json,
71
- mimeType: "image/jpeg",
75
+ const stamp = Date.now();
76
+ const images = (data.data ?? []).map((d, i) => ({
77
+ ...writeImageFile({ base64: d.b64_json, mimeType: "image/jpeg", provider: "xai", index: i, saveDir: p.saveDir, stamp }),
72
78
  ...(d.revised_prompt ? { revisedPrompt: d.revised_prompt } : {}),
73
79
  }));
74
- return { provider: "xai", model: MODEL, images };
80
+ return { provider: "xai", model: MODEL, images, note: SEND_FILE_NOTE };
75
81
  },
76
82
  });
77
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",