demobite 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/launcher/index.mjs +5 -2
- package/package.json +1 -1
- package/skill/scripts/explore.mjs +124 -0
- package/skill/scripts/plan.mjs +23 -2
package/launcher/index.mjs
CHANGED
|
@@ -333,9 +333,12 @@ if (arg === "listen") {
|
|
|
333
333
|
const takeDir = path.join(process.cwd(), ".recorder", "takes", job.id);
|
|
334
334
|
fs.mkdirSync(takeDir, { recursive: true });
|
|
335
335
|
fs.writeFileSync(path.join(takeDir, "job.json"), JSON.stringify({ id: job.id, brief: job.brief, url: job.url ?? null, model, review_storyboard: job.review_storyboard !== false }, null, 2) + "\n");
|
|
336
|
-
|
|
336
|
+
// Spawned async (1.14.1): a spawnSync here blocked the event loop, so the 5 s heartbeat never fired
|
|
337
|
+
// during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
|
|
338
|
+
const child = (await import("node:child_process")).spawn("node", [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" } });
|
|
339
|
+
const code = await new Promise((resolve) => child.on("close", resolve));
|
|
337
340
|
const st = await api({ action: "poll", job_id: job.id });
|
|
338
|
-
if (st && !["ready", "failed", "cancelled"].includes(st.status)) await event(job.id, "failed", { message:
|
|
341
|
+
if (st && !["ready", "failed", "cancelled"].includes(st.status)) await event(job.id, "failed", { message: code === 0 ? "The demo did not come through — try again" : `The take stopped (exit ${code})` });
|
|
339
342
|
clearInterval(heartbeat);
|
|
340
343
|
console.log(` ■ Job ${job.id} ${st?.status ?? "ended"}.\n`);
|
|
341
344
|
waitOn();
|
package/package.json
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// EXPLORE (the built-in agent, founder 2026-10-15: "the agent we're building does
|
|
3
|
+
// a very short exploration if the brief isn't enough… the customer needs
|
|
4
|
+
// nothing but identity"). Without the app's code, the planner knows only the
|
|
5
|
+
// words on the starting page. This walks the live app for it: from the start
|
|
6
|
+
// address it follows the nav links, tabs and menus whose words match what the
|
|
7
|
+
// planner is looking for (the brief's nouns), two hops deep, and brings back
|
|
8
|
+
// every screen it reached — address, title, headings, tabs, controls — with a
|
|
9
|
+
// screenshot of the best matches. Headless, on the camera's profile (signed
|
|
10
|
+
// in), no video, no window. Reversible clicks only: links, tabs, menu
|
|
11
|
+
// openers; never a button that submits, creates, deletes or pays.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/explore.mjs <url> --for "integrations,settings,github" [--depth 2] [--out <dir>]
|
|
14
|
+
// prints one "· phrase" per screen (the app shows them live), then RESULT {json}
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const url = argv.find((a) => /^https?:\/\//.test(a));
|
|
20
|
+
const forArg = argv[argv.indexOf("--for") + 1] || "";
|
|
21
|
+
const depth = Math.min(3, Math.max(1, Number(argv[argv.indexOf("--depth") + 1]) || 2));
|
|
22
|
+
const outDir = argv.includes("--out") ? argv[argv.indexOf("--out") + 1] : process.cwd();
|
|
23
|
+
if (!url || !forArg) { console.error('Usage: node scripts/explore.mjs <url> --for "word,word" [--depth 2] [--out <dir>]'); process.exit(2); }
|
|
24
|
+
const STOP = new Set(["the", "a", "an", "and", "or", "of", "to", "in", "on", "at", "for", "with", "page", "section", "tab", "go", "open", "show", "explain", "demo", "record", "then", "available", "there", "it", "its", "this", "that", "your", "our", "my", "how", "what", "all", "about", "from", "into"]);
|
|
25
|
+
const stem = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/(ings|ing|ies|es|s|ed)$/, "");
|
|
26
|
+
const wants = [...new Set(forArg.split(/[,\s]+/).map(stem).filter((w) => w.length > 2 && !STOP.has(w)))];
|
|
27
|
+
const say = (t) => console.log(`· ${t}`);
|
|
28
|
+
const DANGER = /\b(delete|remove|pay|purchase|buy|subscribe|upgrade|publish|send|submit|save|create|add|invite|export|sign out|log out|logout|cancel|disconnect|revoke|reset|confirm)\b/i;
|
|
29
|
+
|
|
30
|
+
const { chromium } = await import("playwright");
|
|
31
|
+
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
|
32
|
+
const launchOpts = { headless: true, viewport: { width: 1440, height: 900 }, args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"], ignoreDefaultArgs: ["--enable-automation"] };
|
|
33
|
+
let ctx;
|
|
34
|
+
try { ctx = await chromium.launchPersistentContext(profileDir, { ...launchOpts, channel: "chrome" }); }
|
|
35
|
+
catch { ctx = await chromium.launchPersistentContext(profileDir, launchOpts); }
|
|
36
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
37
|
+
const origin = new URL(url).origin;
|
|
38
|
+
|
|
39
|
+
// What is on this screen, and which of its controls could lead somewhere.
|
|
40
|
+
const harvest = () => page.evaluate(() => {
|
|
41
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
|
|
42
|
+
const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
|
|
43
|
+
const label = (el) => clean(el.innerText || el.getAttribute("aria-label") || el.getAttribute("title") || "");
|
|
44
|
+
const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = JSON.stringify(x); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
|
|
45
|
+
const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t && t.length < 90));
|
|
46
|
+
const links = uniq([...document.querySelectorAll("a[href]")].filter(vis).map((el) => ({ kind: "link", text: label(el).slice(0, 60), href: el.href, tip: clean(el.closest("[title],[aria-label]")?.getAttribute("title") || el.closest("[aria-label]")?.getAttribute("aria-label") || "").slice(0, 60) })).filter((l) => (l.text || l.tip) && l.href.startsWith(location.origin) && !/^(mailto|tel):/.test(l.href)));
|
|
47
|
+
const tabs = uniq([...document.querySelectorAll("[role='tab'], [role='tablist'] button, [data-state] button[data-value], nav button, header button, aside button, button[aria-haspopup], button[aria-expanded]")].filter(vis).map((el) => ({ kind: el.getAttribute("role") === "tab" ? "tab" : "menu", text: label(el).slice(0, 60), id: el.id || el.getAttribute("data-value") || "" })).filter((t) => t.text));
|
|
48
|
+
const buttons = uniq([...document.querySelectorAll("button, [role='button']")].filter(vis).map((el) => label(el).slice(0, 60)).filter(Boolean)).slice(0, 40);
|
|
49
|
+
const fields = uniq([...document.querySelectorAll("input, textarea, select")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter(Boolean)).slice(0, 20);
|
|
50
|
+
const dialog = clean(document.querySelector("[role='dialog'] h1, [role='dialog'] h2, [role='dialog'] h3")?.innerText || "");
|
|
51
|
+
return { title: document.title, url: location.href, headings, links, tabs, buttons, fields, dialog };
|
|
52
|
+
});
|
|
53
|
+
const score = (text) => { const s = stem(text); let n = 0; for (const w of wants) if (s.includes(w) || w.includes(s) && s.length > 3) n++; return n; };
|
|
54
|
+
|
|
55
|
+
const screens = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
let shots = 0;
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const budgetMs = 75000;
|
|
60
|
+
const settle = async () => { await page.waitForLoadState("load").catch(() => {}); await page.waitForTimeout(1800); };
|
|
61
|
+
|
|
62
|
+
const record = async (how) => {
|
|
63
|
+
const h = await harvest();
|
|
64
|
+
const key = h.url.replace(/[#?].*$/, "") + (h.dialog ? `#${h.dialog}` : "") + (h.url.includes("?") ? `?${new URL(h.url).search.slice(1)}` : "");
|
|
65
|
+
if (seen.has(key)) return null;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
const sc = score([h.title, ...h.headings, h.dialog].join(" ")) * 2 + h.tabs.reduce((n, t) => n + score(t.text), 0) + h.buttons.reduce((n, b) => n + score(b), 0);
|
|
68
|
+
const entry = { how, url: h.url, title: h.title, headings: h.headings.slice(0, 12), tabs: h.tabs.map((t) => t.text).slice(0, 20), buttons: h.buttons, fields: h.fields, dialog: h.dialog || undefined, nav: h.links.filter((l) => l.tip || l.text).slice(0, 40).map((l) => `${l.text || l.tip} → ${l.href.replace(origin, "")}`), score: sc, shot: null };
|
|
69
|
+
if (shots < 6) { const f = path.join(outDir, `explore-${++shots}.jpg`); await page.screenshot({ path: f, type: "jpeg", quality: 50 }).catch(() => {}); entry.shot = f; }
|
|
70
|
+
screens.push(entry);
|
|
71
|
+
say(`${how}: "${(h.dialog || h.headings[0] || h.title || h.url).slice(0, 70)}"${h.tabs.length ? ` · tabs: ${h.tabs.map((t) => t.text).slice(0, 6).join(", ")}` : ""}`);
|
|
72
|
+
return { h, entry };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const candidates = (h) => {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const l of h.links) { const s = score(`${l.text} ${l.tip} ${l.href.replace(origin, "")}`); if (s) out.push({ s, kind: "link", text: l.text || l.tip, href: l.href }); }
|
|
78
|
+
for (const t of h.tabs) { const s = score(t.text); if (s && !DANGER.test(t.text)) out.push({ s, kind: t.kind, text: t.text }); }
|
|
79
|
+
return out.sort((a, b) => b.s - a.s).slice(0, 4);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const visit = async (c, fromUrl) => {
|
|
83
|
+
if (c.kind === "link") {
|
|
84
|
+
await page.goto(c.href, { waitUntil: "load", timeout: 30000 }).catch(() => null);
|
|
85
|
+
await settle();
|
|
86
|
+
return record(`${c.text}`);
|
|
87
|
+
}
|
|
88
|
+
// A tab or a menu opener on the current page: click it by its text, stay on the page.
|
|
89
|
+
if (page.url() !== fromUrl) { await page.goto(fromUrl, { waitUntil: "load", timeout: 30000 }).catch(() => null); await settle(); }
|
|
90
|
+
const loc = page.locator(c.kind === "tab" ? `[role='tab']:has-text("${c.text.replace(/"/g, "")}")` : `button:has-text("${c.text.replace(/"/g, "")}")`).first();
|
|
91
|
+
const ok = await loc.click({ timeout: 5000 }).then(() => true).catch(() => false);
|
|
92
|
+
if (!ok) return null;
|
|
93
|
+
await page.waitForTimeout(1500);
|
|
94
|
+
const r = await record(`${c.text}`);
|
|
95
|
+
// A menu that opened: its links are candidates too, then close it.
|
|
96
|
+
if (c.kind === "menu" && r) { r.menu = true; }
|
|
97
|
+
return r;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
say(`Looking around ${new URL(url).host} for ${wants.join(", ")}…`);
|
|
102
|
+
await page.goto(url, { waitUntil: "load", timeout: 45000 });
|
|
103
|
+
await settle();
|
|
104
|
+
let frontier = [];
|
|
105
|
+
const root = await record("Start");
|
|
106
|
+
if (root) frontier.push({ ...root, d: 0 });
|
|
107
|
+
while (frontier.length && Date.now() - started < budgetMs && screens.length < 10) {
|
|
108
|
+
const cur = frontier.shift();
|
|
109
|
+
if (cur.d >= depth) continue;
|
|
110
|
+
for (const c of candidates(cur.h)) {
|
|
111
|
+
if (Date.now() - started > budgetMs || screens.length >= 10) break;
|
|
112
|
+
const r = await visit(c, cur.entry.url);
|
|
113
|
+
if (r) frontier.push({ ...r, d: cur.d + 1 });
|
|
114
|
+
if (r?.menu) await page.keyboard.press("Escape").catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
say(screens.length > 1 ? `Found ${screens.length - 1} screen${screens.length > 2 ? "s" : ""} beyond the start.` : "Nothing beyond the start page matched.");
|
|
118
|
+
screens.sort((a, b) => b.score - a.score);
|
|
119
|
+
console.log("RESULT " + JSON.stringify({ wants, screens }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error(String(e.message).split("\n")[0].slice(0, 160));
|
|
122
|
+
console.log("RESULT " + JSON.stringify({ wants, screens, error: String(e.message).slice(0, 120) }));
|
|
123
|
+
}
|
|
124
|
+
await ctx.close();
|
package/skill/scripts/plan.mjs
CHANGED
|
@@ -108,6 +108,26 @@ const screenshot = (url) => {
|
|
|
108
108
|
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(file).toString("base64") } },
|
|
109
109
|
];
|
|
110
110
|
};
|
|
111
|
+
let explored = 0;
|
|
112
|
+
const explore = (from, lookingFor, depth) => {
|
|
113
|
+
if (!/^https?:\/\//.test(String(from))) return [{ type: "text", text: "from (a url) required" }];
|
|
114
|
+
const words = Array.isArray(lookingFor) ? lookingFor.join(",") : String(lookingFor || "");
|
|
115
|
+
if (!words.trim()) return [{ type: "text", text: "looking_for required" }];
|
|
116
|
+
if (++explored > 3) return [{ type: "text", text: "No more exploring for this storyboard; write it from what you saw." }];
|
|
117
|
+
const exDir = path.join(dir, `explore-${explored}`);
|
|
118
|
+
fs.mkdirSync(exDir, { recursive: true });
|
|
119
|
+
const child = spawnSync("node", [script("explore.mjs"), from, "--for", words, "--depth", String(Math.min(3, Math.max(1, Number(depth) || 2))), "--out", exDir], { cwd: process.cwd(), encoding: "utf8", env: process.env, maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
+
const lines = (child.stdout || "").split("\n");
|
|
121
|
+
for (const l of lines) if (l.startsWith("· ")) say(l.slice(2));
|
|
122
|
+
const last = lines.filter((l) => l.startsWith("RESULT ")).pop();
|
|
123
|
+
let res = null; try { res = JSON.parse(last.slice(7)); } catch { /* none */ }
|
|
124
|
+
if (!res) return [{ type: "text", text: `Exploring failed: ${(child.stderr || "").trim().split("\n").pop() || "no result"}` }];
|
|
125
|
+
const content = [];
|
|
126
|
+
const text = res.screens.map((sc, i) => `## Screen ${i + 1}: ${sc.title || sc.url}\n${sc.url}\nreached by: ${sc.how}${sc.dialog ? `\ndialog: ${sc.dialog}` : ""}\nheadings: ${sc.headings.join(" · ") || "(none)"}\ntabs: ${sc.tabs.join(" · ") || "(none)"}\nbuttons: ${sc.buttons.join(" · ") || "(none)"}${sc.fields.length ? `\nfields: ${sc.fields.join(" · ")}` : ""}\nnav: ${sc.nav.slice(0, 25).join(" · ") || "(none)"}`).join("\n\n");
|
|
127
|
+
content.push({ type: "text", text: `${text || "(no screens)"}${res.error ? `\n\n(stopped early: ${res.error})` : ""}\n\nScreenshots follow for the best matches, in that order.` });
|
|
128
|
+
for (const sc of res.screens.slice(0, 3)) if (sc.shot && fs.existsSync(sc.shot)) content.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(sc.shot).toString("base64") } });
|
|
129
|
+
return content;
|
|
130
|
+
};
|
|
111
131
|
const probe = (sb) => {
|
|
112
132
|
if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
|
|
113
133
|
const file = path.join(dir, "probe.json");
|
|
@@ -158,7 +178,7 @@ const prune = (messages) => {
|
|
|
158
178
|
if (b.type !== "tool_result" || !Array.isArray(b.content)) continue;
|
|
159
179
|
for (let k = 0; k < b.content.length; k++) {
|
|
160
180
|
if (b.content[k].type !== "image") continue;
|
|
161
|
-
if (++seen >
|
|
181
|
+
if (++seen > 4) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
|
|
162
182
|
}
|
|
163
183
|
}
|
|
164
184
|
}
|
|
@@ -172,7 +192,7 @@ const firstMessage = () => {
|
|
|
172
192
|
`## The app's words on the starting screen\n${vocab}`,
|
|
173
193
|
`## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
|
|
174
194
|
`## The project's files${codeOn ? " (this machine sits in the app's repository; read_file is on)" : " (no code here)"}\n${listFiles()}`,
|
|
175
|
-
`Write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
|
|
195
|
+
codeOn ? `Write the storyboard, probe it, fix it, probe again, then submit_storyboard.` : `There is no code: explore first (explore from the starting address, looking_for = the brief's nouns), then write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
|
|
176
196
|
];
|
|
177
197
|
return parts.join("\n\n");
|
|
178
198
|
};
|
|
@@ -206,6 +226,7 @@ const plan = async () => {
|
|
|
206
226
|
if (u.name === "list_files") out = listFiles();
|
|
207
227
|
else if (u.name === "read_file") out = readFile(input.path);
|
|
208
228
|
else if (u.name === "harvest_words") { console.log(` harvesting ${(input.urls || []).join(", ")}`); out = harvest(input.urls); }
|
|
229
|
+
else if (u.name === "explore") out = explore(input.from, input.looking_for, input.depth);
|
|
209
230
|
else if (u.name === "screenshot") { console.log(` looking at ${input.url}`); out = screenshot(input.url); }
|
|
210
231
|
else if (u.name === "probe") { console.log(" dry run…"); out = probe(input.storyboard); }
|
|
211
232
|
else if (u.name === "submit_storyboard") {
|