@chalky/chalky 0.1.4 → 0.1.6
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/README.md +16 -5
- package/agent/SYSTEM.md +36 -0
- package/agent/extensions/app/README.md +72 -0
- package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
- package/agent/extensions/app/guide.md +5 -0
- package/agent/extensions/app/index.ts +66 -0
- package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
- package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
- package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
- package/agent/extensions/card/README.md +88 -0
- package/agent/extensions/card/blocks/Button.js +34 -0
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
- package/agent/extensions/card/blocks/Markdown.js +37 -0
- package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
- package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -129
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
- package/agent/extensions/command/guide.md +2 -0
- package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
- package/agent/extensions/doodle/README.md +57 -0
- package/agent/extensions/doodle/browser.js +1013 -0
- package/agent/extensions/doodle/guide.md +22 -0
- package/agent/extensions/doodle/index.ts +274 -0
- package/agent/extensions/file/browser.js +182 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +90 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -33
- package/{app → agent}/extensions/mcp/browser.js +1 -1
- package/agent/extensions/mcp/guide.md +3 -0
- package/{app → agent}/extensions/mcp/index.ts +2 -4
- package/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
- package/agent/extensions/note/README.md +37 -0
- package/agent/extensions/note/browser.js +382 -0
- package/agent/extensions/note/guide.md +9 -0
- package/agent/extensions/note/index.ts +215 -0
- package/agent/extensions/page/browser.js +453 -0
- package/{app → agent}/extensions/page/index.ts +72 -3
- package/{app → agent}/extensions/page/page.html +102 -49
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +10 -23
- package/{app → agent}/extensions/screen/index.ts +11 -11
- package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
- package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
- package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
- package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
- package/{app/extensions/versions → agent/extensions/version}/browser.js +19 -10
- package/agent/extensions/version/index.ts +205 -0
- package/{app → agent}/extensions/viewer/README.md +4 -4
- package/{app → agent}/extensions/viewer/browser.js +8 -8
- package/agent/extensions/viewer/guide.md +3 -0
- package/{app → agent}/extensions/viewer/index.ts +22 -17
- package/{app → agent}/extensions/voice/browser.js +4 -3
- package/{app → agent}/extensions/voice/index.ts +8 -6
- package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
- package/agent/settings.json +8 -0
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
- package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +15 -15
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/agent/skills/show-me/SKILL.md +66 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +2 -2
- package/package.json +9 -8
- package/server.ts +313 -58
- package/app/SYSTEM.md +0 -31
- package/app/extensions/appdata/README.md +0 -10
- package/app/extensions/appdata/guide.md +0 -4
- package/app/extensions/appdata/index.ts +0 -47
- package/app/extensions/commands/guide.md +0 -3
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -398
- package/app/extensions/doodle/guide.md +0 -6
- package/app/extensions/doodle/index.ts +0 -54
- package/app/extensions/files/browser.js +0 -96
- package/app/extensions/files/guide.md +0 -7
- package/app/extensions/frames/guide.md +0 -7
- package/app/extensions/frames/index.ts +0 -85
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -247
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -112
- package/app/extensions/prompt/index.ts +0 -19
- package/app/extensions/ui/README.md +0 -69
- package/app/extensions/ui/components/App.js +0 -28
- package/app/extensions/ui/components/Button.js +0 -28
- package/app/extensions/ui/components/Markdown.js +0 -37
- package/app/extensions/ui/guide.md +0 -7
- package/app/extensions/ui/index.ts +0 -291
- package/app/extensions/versions/index.ts +0 -168
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/settings.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- package/node_modules/xlsx/CHANGELOG.md +0 -361
- package/node_modules/xlsx/LICENSE +0 -201
- package/node_modules/xlsx/README.md +0 -41
- package/node_modules/xlsx/bin/xlsx.njs +0 -310
- package/node_modules/xlsx/bower.json +0 -22
- package/node_modules/xlsx/dist/LICENSE +0 -201
- package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
- package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
- package/node_modules/xlsx/dist/cpexcel.js +0 -1506
- package/node_modules/xlsx/dist/shim.min.js +0 -2
- package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
- package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
- package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
- package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
- package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
- package/node_modules/xlsx/dist/zahl.d.ts +0 -4
- package/node_modules/xlsx/package.json +0 -183
- package/node_modules/xlsx/types/index.d.ts +0 -1065
- package/node_modules/xlsx/types/tsconfig.json +0 -15
- package/node_modules/xlsx/xlsx.js +0 -28103
- package/node_modules/xlsx/xlsx.mjs +0 -28228
- package/node_modules/xlsx/xlsxworker.js +0 -14
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// The page side of page_run (page/index.ts): runs an agent's code in this page, or inside a card's app, where the user
|
|
2
|
+
// can watch it, and answers.
|
|
3
|
+
// page message page_tab {tab} this tab is the one the user looks at: when this script loads, and on focus
|
|
4
|
+
// event "page_run" {req, tab, card, code, agent} only the tab named runs it -> page message page_ran {req, value?, error?, errors}
|
|
5
|
+
// page message page_stop {agent} the bar's Stop: stops that agent only
|
|
6
|
+
// page message {to, text} the bar's "Let <agent> continue": a message to the agent
|
|
7
|
+
// The code runs with the app frame's own Function (document and window are the app's) and gets helpers that act the
|
|
8
|
+
// way a person would, slowly enough to watch: click, type, press, drag (the agent's pointer moves there first, then
|
|
9
|
+
// the events a real hand makes fire), read (what's on screen, as short lines) and wait. Plain DOM code works too.
|
|
10
|
+
// What to act on: an element, a CSS selector, its visible text, or a spot {x, y} (window px: whatever is on top there,
|
|
11
|
+
// as with a real mouse; for empty space, and see-through layers like Point's).
|
|
12
|
+
// What the user sees: the pointer (the agent's colour and name), an outline on each thing touched (also when code
|
|
13
|
+
// skips the helpers: any event code makes, isTrusted false, while a run is on), and a notice (top right) while the agent is using
|
|
14
|
+
// the page (from its first page_run to the end of its turn). The user's own click or key in the main space or an app
|
|
15
|
+
// pauses it: the current helper stops, the agent hears "the user took over", later runs are refused until the user
|
|
16
|
+
// presses "Let <agent> continue". While agent code runs, the page's Stop button and rollbacks refuse it.
|
|
17
|
+
// What the page sends because of the agent's code (its click, key or drop set off a page message) carries
|
|
18
|
+
// {by: <agent>}, so the extension that gets it can say who did it (frames: "agent1 pressed Implement", not "the user").
|
|
19
|
+
// A run in a card first brings that card to the front (data-agent-front, until a run in another card) and scrolls it
|
|
20
|
+
// into view, so the user sees it. If the app reloads or leaves during a run, the answer comes at once (pagehide).
|
|
21
|
+
// The value comes back as short text: an element as tag#id "its text", anything else as JSON, cut at MAX characters.
|
|
22
|
+
const MAX = 2000;
|
|
23
|
+
const PACE = { moveMin: 280, moveMax: 750, afterClick: 350, keyMin: 40, keyMax: 90, linger: 1100 };
|
|
24
|
+
const ACTIVE = "button, a[href], input, select, textarea, summary, label, [role=button], [role=tab], [role=checkbox], [role=radio], "
|
|
25
|
+
+ "[role=switch], [role=menuitem], [role=option], [role=link], [role=slider], [contenteditable=''], [contenteditable=true]";
|
|
26
|
+
const colour = (a) => `hsl(${([...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 997, 7) * 137) % 360} 65% 45%)`; // as page.html
|
|
27
|
+
const short = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
28
|
+
const norm = (s) => String(s ?? "").trim().replace(/\s+/g, " ");
|
|
29
|
+
class TookOver extends Error {}
|
|
30
|
+
|
|
31
|
+
export default function (page) {
|
|
32
|
+
const tab = Math.random().toString(36).slice(2, 10);
|
|
33
|
+
const claim = () => { try { page.send({ type: "page_tab", tab }); return true; } catch { return false; } }; // false: not connected yet
|
|
34
|
+
const first = () => claim() || setTimeout(first, 300); // the first status may come before this script loads: don't wait for it
|
|
35
|
+
first();
|
|
36
|
+
page.on("status", () => { if (document.hasFocus()) claim(); }); // status comes again on every reconnect
|
|
37
|
+
addEventListener("focus", claim);
|
|
38
|
+
document.addEventListener("visibilitychange", () => { if (!document.hidden) claim(); });
|
|
39
|
+
|
|
40
|
+
const main = page.slot("top");
|
|
41
|
+
let session = null; // {agent, paused}: from an agent's first page_run to the end of its turn
|
|
42
|
+
let busy = 0, mine = 0; // runs going on; >0 while a helper fires its own events (they get their own outline)
|
|
43
|
+
let refused = []; // what the guard refused during the current run
|
|
44
|
+
let codeClicked = false; // the last click on this page came from code
|
|
45
|
+
let byCode = false; // the last click, key, input or drop came from code
|
|
46
|
+
|
|
47
|
+
// every page message goes through page.send (one pageApi for all plugins): while agent code acts, say whose it is
|
|
48
|
+
const send = page.send;
|
|
49
|
+
page.send = (obj) => send(busy && byCode && session ? { ...obj, by: session.agent } : obj);
|
|
50
|
+
|
|
51
|
+
// ---------- what the user sees ----------
|
|
52
|
+
const style = document.createElement("style");
|
|
53
|
+
style.textContent = `
|
|
54
|
+
[data-card][data-agent-front] { position: relative; z-index: 3; } /* a pinned card keeps its inline absolute; a held or flipped one goes higher */
|
|
55
|
+
.page-pointer { position: fixed; left: 0; top: 0; z-index: 2147483000; pointer-events: none; display: flex; gap: 2px;
|
|
56
|
+
opacity: 0; transition: opacity .3s; will-change: transform; }
|
|
57
|
+
.page-pointer.on { opacity: 1; }
|
|
58
|
+
.page-pointer svg { width: 20px; height: 20px; filter: drop-shadow(0 2px 3px rgba(0,0,0,.25)); }
|
|
59
|
+
.page-pointer span { margin-top: 14px; color: #fff; font: 600 11px system-ui, sans-serif; padding: 2px 7px; border-radius: 7px; }
|
|
60
|
+
.page-touch { position: fixed; z-index: 2147482999; pointer-events: none; border: 2px solid; border-radius: 9px; transition: opacity .5s; }
|
|
61
|
+
.page-touch b { position: absolute; top: -18px; left: -2px; color: #fff; font: 600 10px system-ui, sans-serif; padding: 1px 6px;
|
|
62
|
+
border-radius: 6px; white-space: nowrap; }
|
|
63
|
+
.page-ripple { position: fixed; z-index: 2147483000; pointer-events: none; width: 36px; height: 36px; margin: -18px 0 0 -18px;
|
|
64
|
+
border-radius: 50%; border: 2px solid; animation: page-ripple .55s ease-out forwards; }
|
|
65
|
+
@keyframes page-ripple { from { transform: scale(.3); opacity: 1; } to { transform: scale(1.5); opacity: 0; } }
|
|
66
|
+
.page-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; animation: page-pulse 1.2s infinite; }
|
|
67
|
+
.page-dot.paused { background: var(--muted, #999) !important; animation: none; }
|
|
68
|
+
@keyframes page-pulse { 50% { opacity: .35; } }
|
|
69
|
+
@media (prefers-reduced-motion: reduce) { .page-pointer { transition: opacity .1s; } .page-dot { animation: none; } }`;
|
|
70
|
+
document.head.append(style);
|
|
71
|
+
const pointer = Object.assign(document.createElement("div"), { className: "page-pointer" });
|
|
72
|
+
pointer.innerHTML = `<svg viewBox="0 0 20 20"><path d="M3 2 L3 16 L7 12.5 L9.8 18 L12 17 L9.3 11.6 L14.5 11.2 Z" stroke="#fff"
|
|
73
|
+
stroke-width="1.3" stroke-linejoin="round"/></svg><span></span>`;
|
|
74
|
+
let bar = null; // the notice (pageApi.notice) while an agent uses the page
|
|
75
|
+
document.body.append(pointer);
|
|
76
|
+
let px = innerWidth - 80, py = innerHeight - 80, hideTimer, idleTimer;
|
|
77
|
+
|
|
78
|
+
function showBar() { // new, or the same notice with new words: ✕ hides it for the turn (Close, when paused)
|
|
79
|
+
const a = session.agent;
|
|
80
|
+
if (session.quiet && !session.paused) return;
|
|
81
|
+
const dot = Object.assign(document.createElement("span"), { className: "page-dot" + (session.paused ? " paused" : "") });
|
|
82
|
+
dot.style.background = colour(a);
|
|
83
|
+
const msg = session.paused ? `You took over: ${a} is paused` : `${a} is using the page`;
|
|
84
|
+
const button = (text, go, fn) => Object.assign(document.createElement("button"), { textContent: text, className: go ? "go" : "", onclick: fn });
|
|
85
|
+
const content = [dot, msg, session.paused
|
|
86
|
+
? button(`Let ${a} continue`, true, () => {
|
|
87
|
+
session.paused = false; session.quiet = false; showBar();
|
|
88
|
+
page.send({ to: a, text: "[from the page] The user is done using the page: carry on where you left off. "
|
|
89
|
+
+ "Read the page again first, it may have changed." });
|
|
90
|
+
})
|
|
91
|
+
: button("Stop", false, () => { page.send({ type: "page_stop", agent: a }); endSession(); })];
|
|
92
|
+
if (bar?.open) bar.set(...content);
|
|
93
|
+
else bar = page.notice(content, () => { bar = null; if (!session) return; if (session.paused) endSession(); else session.quiet = true; });
|
|
94
|
+
}
|
|
95
|
+
function endSession() { session = null; bar?.close(); bar = null; pointer.classList.remove("on"); }
|
|
96
|
+
|
|
97
|
+
// where an element (or a point in its frame) is in this window, through any app frames (scaled ones too)
|
|
98
|
+
function toTop(doc, x, y) {
|
|
99
|
+
let k = 1;
|
|
100
|
+
for (let w = doc.defaultView; w && w !== window; w = w.parent) {
|
|
101
|
+
const fr = w.frameElement, r = fr.getBoundingClientRect(), s = r.width / (fr.offsetWidth || r.width || 1);
|
|
102
|
+
x = r.left + (fr.clientLeft + x) * s; y = r.top + (fr.clientTop + y) * s; k *= s;
|
|
103
|
+
}
|
|
104
|
+
return { x, y, k };
|
|
105
|
+
}
|
|
106
|
+
function box(el) {
|
|
107
|
+
const r = el.getBoundingClientRect(), p = toTop(el.ownerDocument, r.left, r.top);
|
|
108
|
+
return { x: p.x, y: p.y, w: r.width * p.k, h: r.height * p.k };
|
|
109
|
+
}
|
|
110
|
+
const spot = (el) => { const r = el.getBoundingClientRect(); return { x: r.left + Math.min(r.width / 2, 40), y: r.top + r.height / 2 }; };
|
|
111
|
+
const outlined = new WeakMap();
|
|
112
|
+
function outline(el, label, agent) {
|
|
113
|
+
if (!el?.getBoundingClientRect || Date.now() - (outlined.get(el) ?? 0) < 400) return; // typing: one outline, not one per letter
|
|
114
|
+
outlined.set(el, Date.now());
|
|
115
|
+
const b = box(el), c = colour(agent), d = Object.assign(document.createElement("div"), { className: "page-touch" });
|
|
116
|
+
Object.assign(d.style, { left: b.x - 3 + "px", top: b.y - 3 + "px", width: b.w + 6 + "px", height: b.h + 6 + "px", borderColor: c,
|
|
117
|
+
background: c.replace(")", " / .09)") });
|
|
118
|
+
const tag = document.createElement("b"); tag.textContent = `${agent} · ${short(label, 40)}`; tag.style.background = c; d.append(tag);
|
|
119
|
+
document.body.append(d);
|
|
120
|
+
setTimeout(() => { d.style.opacity = "0"; }, PACE.linger);
|
|
121
|
+
setTimeout(() => d.remove(), PACE.linger + 600);
|
|
122
|
+
}
|
|
123
|
+
function ripple(x, y, agent) {
|
|
124
|
+
const d = Object.assign(document.createElement("div"), { className: "page-ripple" });
|
|
125
|
+
Object.assign(d.style, { left: x + "px", top: y + "px", borderColor: colour(agent) });
|
|
126
|
+
document.body.append(d); setTimeout(() => d.remove(), 600);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- pausing, and the guard ----------
|
|
130
|
+
const gate = () => { if (session?.paused) throw new TookOver(); };
|
|
131
|
+
async function sleep(ms) {
|
|
132
|
+
const end = Date.now() + ms;
|
|
133
|
+
while (Date.now() < end) { gate(); await new Promise((r) => setTimeout(r, Math.min(60, end - Date.now()))); }
|
|
134
|
+
gate();
|
|
135
|
+
}
|
|
136
|
+
function takeOver(e) {
|
|
137
|
+
if (!e.isTrusted || !session || session.paused) return;
|
|
138
|
+
if (e.target.ownerDocument === document && !main.contains(e.target)) return; // the chat, the palette, the bar: not the page's content
|
|
139
|
+
session.paused = true; pointer.classList.remove("on"); showBar();
|
|
140
|
+
}
|
|
141
|
+
const watched = new WeakSet();
|
|
142
|
+
function watch(doc) { // the user's real input, and events code makes (outlined), in this document
|
|
143
|
+
if (watched.has(doc)) return;
|
|
144
|
+
watched.add(doc);
|
|
145
|
+
doc.addEventListener("pointerdown", takeOver, true);
|
|
146
|
+
doc.addEventListener("keydown", takeOver, true);
|
|
147
|
+
for (const type of ["click", "keydown", "input", "change", "submit", "drop"]) doc.addEventListener(type, (e) => { byCode = !e.isTrusted; }, true);
|
|
148
|
+
for (const type of ["click", "input", "change", "submit"]) doc.addEventListener(type, (e) => {
|
|
149
|
+
if (!e.isTrusted && busy && !mine && session) outline(e.target, type === "click" ? "clicked" : type === "submit" ? "submitted" : "changed", session.agent);
|
|
150
|
+
}, true);
|
|
151
|
+
}
|
|
152
|
+
watch(document);
|
|
153
|
+
document.addEventListener("click", (e) => {
|
|
154
|
+
codeClicked = !e.isTrusted;
|
|
155
|
+
if (!e.isTrusted && busy && e.target.closest?.("#stop")) { // Stop acts on the agents: only the user presses it
|
|
156
|
+
e.preventDefault(); e.stopImmediatePropagation(); refused.push("the Stop button: it acts on the agents, so only the user presses it");
|
|
157
|
+
}
|
|
158
|
+
}, true);
|
|
159
|
+
const realFetch = window.fetch;
|
|
160
|
+
window.fetch = function (input, init) {
|
|
161
|
+
if (busy && codeClicked && /\/version\/[^/?]+\/rollback/.test(String(input?.url ?? input))) {
|
|
162
|
+
refused.push("a rollback: it changes the agents' code, so only the user starts it");
|
|
163
|
+
return Promise.reject(new Error("refused: only the user rolls back"));
|
|
164
|
+
}
|
|
165
|
+
return realFetch.call(this, input, init);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ---------- the helpers an agent's code gets ----------
|
|
169
|
+
function helpers(doc, agent) {
|
|
170
|
+
const w = doc.defaultView;
|
|
171
|
+
function fire(el, type, init = {}) {
|
|
172
|
+
const C = type.startsWith("pointer") ? w.PointerEvent : type.startsWith("key") ? w.KeyboardEvent
|
|
173
|
+
: type === "input" ? w.InputEvent : /^(drag|drop)/.test(type) ? w.DragEvent
|
|
174
|
+
: /^(mouse|click|dblclick)/.test(type) ? w.MouseEvent : w.Event;
|
|
175
|
+
const bubbles = !/^(pointerenter|pointerleave|mouseenter|mouseleave|focus|blur)$/.test(type);
|
|
176
|
+
mine++;
|
|
177
|
+
try { return el.dispatchEvent(new C(type, { bubbles, cancelable: true, composed: true, view: w, ...init })); } finally { mine--; }
|
|
178
|
+
}
|
|
179
|
+
const label = (el) => norm(el.getAttribute?.("aria-label") || el.placeholder || (el.labels?.[0]?.innerText) || el.innerText || el.value || el.title || el.alt || "");
|
|
180
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && w.getComputedStyle(el).visibility !== "hidden"; };
|
|
181
|
+
// A spot {x, y} (window px of this document, as screen_see shows them) is where a real mouse would be: the thing on
|
|
182
|
+
// top there gets the events (a see-through layer like Point's, empty main space), at exactly that spot.
|
|
183
|
+
const isSpot = (t) => typeof t?.x === "number" && typeof t?.y === "number" && !t.nodeType;
|
|
184
|
+
function find(t, typing) {
|
|
185
|
+
if (t?.nodeType === 1) return t;
|
|
186
|
+
if (isSpot(t)) {
|
|
187
|
+
const el = doc.elementFromPoint(t.x, t.y);
|
|
188
|
+
if (!el) throw new Error(`nothing at x ${t.x}, y ${t.y}: the window is ${w.innerWidth}x${w.innerHeight}`);
|
|
189
|
+
return el;
|
|
190
|
+
}
|
|
191
|
+
if (typeof t !== "string" || !t) throw new Error("name what to act on: an element, a CSS selector, or its visible text");
|
|
192
|
+
try { const el = doc.querySelector(t); if (el) return el; } catch {} // not a selector: it's text
|
|
193
|
+
const want = norm(t).toLowerCase();
|
|
194
|
+
const fields = [...doc.querySelectorAll("input, textarea, select, [contenteditable=''], [contenteditable=true]")].filter(visible);
|
|
195
|
+
const pool = typing ? fields : [...doc.querySelectorAll(ACTIVE), ...doc.querySelectorAll("body *")].filter(visible);
|
|
196
|
+
const names = (el) => [label(el), norm(el.innerText), el.name, el.id].filter(Boolean).map((s) => s.toLowerCase());
|
|
197
|
+
for (const test of [(s) => s === want, (s) => s.startsWith(want), (s) => s.includes(want)]) {
|
|
198
|
+
const hit = pool.find((el) => (typing || !el.children.length || el.matches(ACTIVE)) && names(el).some(test));
|
|
199
|
+
if (hit) return typing ? hit : (hit.closest(ACTIVE) ?? hit);
|
|
200
|
+
}
|
|
201
|
+
if (typing) { const lab = [...doc.querySelectorAll("label")].find((l) => norm(l.innerText).toLowerCase().includes(want)); if (lab?.control) return lab.control; }
|
|
202
|
+
throw new Error(`nothing ${typing ? "to type into " : ""}on the page matches "${t}": read() lists what's there`);
|
|
203
|
+
}
|
|
204
|
+
async function reveal(el) {
|
|
205
|
+
const r = el.getBoundingClientRect();
|
|
206
|
+
if (r.top >= 0 && r.bottom <= w.innerHeight && r.left >= 0 && r.right <= w.innerWidth) return;
|
|
207
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
|
208
|
+
await sleep(450);
|
|
209
|
+
}
|
|
210
|
+
async function moveTo(el, target) { // target a spot: go exactly there (it is in the window already: no scrolling)
|
|
211
|
+
gate();
|
|
212
|
+
if (!isSpot(target)) await reveal(el);
|
|
213
|
+
const s = isSpot(target) ? { x: target.x, y: target.y } : spot(el), p = toTop(doc, s.x, s.y);
|
|
214
|
+
pointer.querySelector("path").setAttribute("fill", colour(agent));
|
|
215
|
+
Object.assign(pointer.querySelector("span"), { textContent: agent }).style.background = colour(agent);
|
|
216
|
+
pointer.classList.add("on"); clearTimeout(hideTimer);
|
|
217
|
+
const ms = Math.max(PACE.moveMin, Math.min(PACE.moveMax, Math.hypot(p.x - px, p.y - py) * 1.3));
|
|
218
|
+
pointer.style.transition = `transform ${ms}ms cubic-bezier(.3,.7,.3,1), opacity .3s`;
|
|
219
|
+
pointer.style.transform = `translate(${p.x}px, ${p.y}px)`; px = p.x; py = p.y;
|
|
220
|
+
await sleep(ms + 80);
|
|
221
|
+
return { s, p };
|
|
222
|
+
}
|
|
223
|
+
const at = (s, extra = {}) => ({ clientX: s.x, clientY: s.y, pointerId: 1, pointerType: "mouse", isPrimary: true, ...extra });
|
|
224
|
+
function press1(el, s) { // the events of one real click, in order
|
|
225
|
+
fire(el, "pointerover", at(s)); fire(el, "pointerenter", at(s)); fire(el, "mouseover", at(s));
|
|
226
|
+
fire(el, "pointermove", at(s)); fire(el, "mousemove", at(s));
|
|
227
|
+
fire(el, "pointerdown", at(s, { buttons: 1 })); fire(el, "mousedown", at(s, { buttons: 1, detail: 1 }));
|
|
228
|
+
if (el.matches(ACTIVE) || el.tabIndex >= 0) el.focus({ preventScroll: true });
|
|
229
|
+
fire(el, "pointerup", at(s)); fire(el, "mouseup", at(s, { detail: 1 })); fire(el, "click", at(s, { detail: 1 }));
|
|
230
|
+
}
|
|
231
|
+
const valueSetter = (el) => Object.getOwnPropertyDescriptor(w[el.localName === "textarea" ? "HTMLTextAreaElement" : "HTMLInputElement"].prototype, "value").set;
|
|
232
|
+
const keyInit = (key) => {
|
|
233
|
+
const parts = key.split("+"), k = parts.pop() || "+", has = (m) => parts.some((p) => p.toLowerCase() === m);
|
|
234
|
+
const code = k.length === 1 ? (/[a-z]/i.test(k) ? `Key${k.toUpperCase()}` : /\d/.test(k) ? `Digit${k}` : k === " " ? "Space" : "") : k;
|
|
235
|
+
return { key: k === "Space" ? " " : k, code, ctrlKey: has("ctrl") || has("control"), shiftKey: has("shift"), altKey: has("alt"), metaKey: has("meta") || has("cmd") };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const api = {
|
|
239
|
+
async click(target) {
|
|
240
|
+
const el = find(target), { s, p } = await moveTo(el, target);
|
|
241
|
+
ripple(p.x, p.y, agent); press1(el, s); outline(el, label(el) || el.localName, agent);
|
|
242
|
+
await sleep(PACE.afterClick);
|
|
243
|
+
},
|
|
244
|
+
async type(target, text, { add = false } = {}) {
|
|
245
|
+
let el = find(target, true);
|
|
246
|
+
const { s, p } = await moveTo(el, target);
|
|
247
|
+
ripple(p.x, p.y, agent); press1(el, s);
|
|
248
|
+
text = String(text);
|
|
249
|
+
if (isSpot(target)) { // as a keyboard would: into what the click focused, else as keys to the page (Text's words)
|
|
250
|
+
await sleep(120);
|
|
251
|
+
const f = doc.activeElement;
|
|
252
|
+
if (f?.matches?.("input, textarea, select") || f?.isContentEditable) el = f;
|
|
253
|
+
else {
|
|
254
|
+
for (const ch of text) {
|
|
255
|
+
gate();
|
|
256
|
+
const k = keyInit(ch === "\n" ? "Enter" : ch === " " ? "Space" : ch);
|
|
257
|
+
fire(f ?? doc.body, "keydown", k); fire(f ?? doc.body, "keyup", k);
|
|
258
|
+
await sleep(PACE.keyMin + Math.random() * (PACE.keyMax - PACE.keyMin));
|
|
259
|
+
}
|
|
260
|
+
outline(el, `typed "${short(text, 24)}"`, agent);
|
|
261
|
+
return sleep(PACE.afterClick);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (el.localName === "select") { // a dropdown: pick the option with that text (or value)
|
|
265
|
+
const o = [...el.options].find((o) => norm(o.text).toLowerCase() === norm(text).toLowerCase() || o.value === text);
|
|
266
|
+
if (!o) throw new Error(`"${label(el)}" has no option "${text}": ${[...el.options].map((o) => o.text).join(", ")}`);
|
|
267
|
+
el.value = o.value; fire(el, "input"); fire(el, "change"); outline(el, `picked "${o.text}"`, agent);
|
|
268
|
+
return sleep(PACE.afterClick);
|
|
269
|
+
}
|
|
270
|
+
const editable = el.isContentEditable, set = editable ? null : valueSetter(el);
|
|
271
|
+
if (/^(range|color|date|time|datetime-local|month|week)$/.test(el.type)) { // no letters to type: set it, as a slider drag or picker would
|
|
272
|
+
set.call(el, text); fire(el, "input"); fire(el, "change"); outline(el, `set to ${short(text, 24)}`, agent);
|
|
273
|
+
return sleep(PACE.afterClick);
|
|
274
|
+
}
|
|
275
|
+
if (!add) {
|
|
276
|
+
if (editable) { mine++; try { doc.execCommand("selectAll"); doc.execCommand("delete"); } finally { mine--; } }
|
|
277
|
+
else if (el.value) { set.call(el, ""); fire(el, "input", { inputType: "deleteContentBackward" }); }
|
|
278
|
+
}
|
|
279
|
+
for (const ch of text) {
|
|
280
|
+
gate();
|
|
281
|
+
const k = keyInit(ch === "\n" ? "Enter" : ch === " " ? "Space" : ch);
|
|
282
|
+
fire(el, "keydown", k);
|
|
283
|
+
if (editable) { mine++; try { doc.execCommand("insertText", false, ch); } finally { mine--; } }
|
|
284
|
+
else { set.call(el, el.value + ch); fire(el, "input", { inputType: "insertText", data: ch }); }
|
|
285
|
+
fire(el, "keyup", k);
|
|
286
|
+
outline(el, `typing "${short(text, 24)}"`, agent);
|
|
287
|
+
await sleep(PACE.keyMin + Math.random() * (PACE.keyMax - PACE.keyMin));
|
|
288
|
+
}
|
|
289
|
+
fire(el, "change");
|
|
290
|
+
await sleep(PACE.afterClick);
|
|
291
|
+
},
|
|
292
|
+
async press(key, target) {
|
|
293
|
+
const el = target ? find(target) : doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : doc.body;
|
|
294
|
+
if (target) await moveTo(el, target);
|
|
295
|
+
gate();
|
|
296
|
+
const k = keyInit(key), go = fire(el, "keydown", k);
|
|
297
|
+
if (go) { // what a browser does by itself for a real key, which events from code don't trigger
|
|
298
|
+
const field = el.localName === "input" && !/^(button|submit|checkbox|radio|reset)$/.test(el.type);
|
|
299
|
+
if (k.key === "Enter" && field && el.form) { mine++; try { el.form.requestSubmit(); } finally { mine--; } }
|
|
300
|
+
else if ((k.key === "Enter" || k.key === " ") && el.matches("button, a[href], [role=button], summary, input[type=checkbox], input[type=radio], input[type=submit]")) press1(el, spot(el));
|
|
301
|
+
else if (k.key === "Tab") {
|
|
302
|
+
const all = [...doc.querySelectorAll(ACTIVE)].filter((e) => visible(e) && !e.disabled && e.tabIndex >= 0);
|
|
303
|
+
const i = all.indexOf(el), next = all[(i + (k.shiftKey ? -1 : 1) + all.length) % all.length];
|
|
304
|
+
next?.focus();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
fire(el, "keyup", k);
|
|
308
|
+
outline(el, `pressed ${key}`, agent);
|
|
309
|
+
await sleep(PACE.afterClick);
|
|
310
|
+
},
|
|
311
|
+
async drag(target, dx, dy = 0) { // by dx, dy px, or onto another thing or a spot: drag(a, b)
|
|
312
|
+
const el = find(target), { s } = await moveTo(el, target);
|
|
313
|
+
const to = typeof dx === "number" ? { x: s.x + dx, y: s.y + dy } : isSpot(dx) ? { x: dx.x, y: dx.y } : spot(find(dx));
|
|
314
|
+
// A draggable thing (draggable="true", e.g. a file row) moves by the browser's drag-and-drop, which code
|
|
315
|
+
// can't start: fire its events ourselves, with one DataTransfer from dragstart to drop, as the browser would.
|
|
316
|
+
const source = el.closest('[draggable="true"]'), data = source && new w.DataTransfer();
|
|
317
|
+
fire(el, "pointerdown", at(s, { buttons: 1 })); fire(el, "mousedown", at(s, { buttons: 1 }));
|
|
318
|
+
const dragging = source && fire(source, "dragstart", at(s, { buttons: 1, dataTransfer: data })); // cancelled: no drag
|
|
319
|
+
let over = el, accepted = false;
|
|
320
|
+
for (let i = 1; i <= 12; i++) {
|
|
321
|
+
const q = { x: s.x + (to.x - s.x) * i / 12, y: s.y + (to.y - s.y) * i / 12 }, p = toTop(doc, q.x, q.y);
|
|
322
|
+
pointer.style.transition = "transform 40ms linear, opacity .3s"; pointer.style.transform = `translate(${p.x}px, ${p.y}px)`; px = p.x; py = p.y;
|
|
323
|
+
const now = doc.elementFromPoint(q.x, q.y) ?? el;
|
|
324
|
+
if (dragging) {
|
|
325
|
+
if (now !== over) { fire(over, "dragleave", at(q, { dataTransfer: data })); fire(now, "dragenter", at(q, { buttons: 1, dataTransfer: data })); }
|
|
326
|
+
accepted = !fire(now, "dragover", at(q, { buttons: 1, dataTransfer: data })); // a drop target cancels dragover
|
|
327
|
+
} else { fire(now, "pointermove", at(q, { buttons: 1 })); fire(now, "mousemove", at(q, { buttons: 1 })); }
|
|
328
|
+
over = now;
|
|
329
|
+
await sleep(45);
|
|
330
|
+
}
|
|
331
|
+
if (dragging) {
|
|
332
|
+
if (accepted) fire(over, "drop", at(to, { dataTransfer: data }));
|
|
333
|
+
fire(source, "dragend", at(to, { dataTransfer: data }));
|
|
334
|
+
} else { fire(over, "pointerup", at(to)); fire(over, "mouseup", at(to)); }
|
|
335
|
+
outline(el, accepted ? "dragged and dropped" : "dragged", agent);
|
|
336
|
+
await sleep(PACE.afterClick);
|
|
337
|
+
},
|
|
338
|
+
read(target, { all = false } = {}) { // what's on screen: headings, controls (with value and state), short texts
|
|
339
|
+
const root = target ? find(target) : doc.body, lines = [];
|
|
340
|
+
// the conversation (#log) and the working rows are what the agent already knows, and on Chalky's page they
|
|
341
|
+
// drown out the rest: left out, unless asked for by name (read("#log"))
|
|
342
|
+
const known = doc === document && !root.closest("#log, #working") ? "#log, #working" : null;
|
|
343
|
+
let out = 0;
|
|
344
|
+
for (const el of [...(target ? [root] : []), ...root.querySelectorAll("*")]) {
|
|
345
|
+
if (el.closest("script, style, template, noscript") || (known && el.closest(known))) continue;
|
|
346
|
+
const control = el.matches(ACTIVE) && el.localName !== "label", heading = /^h[1-6]$/.test(el.localName);
|
|
347
|
+
const text = !el.children.length ? norm(el.innerText) : "";
|
|
348
|
+
if (!control && !heading && !(text && text.length <= 80)) continue;
|
|
349
|
+
if (!control && el.parentElement?.closest(ACTIVE)) continue; // text inside a button: the button says it
|
|
350
|
+
if (!visible(el)) continue;
|
|
351
|
+
const r = el.getBoundingClientRect();
|
|
352
|
+
if (!all && (r.bottom < 0 || r.top > w.innerHeight)) { out++; continue; }
|
|
353
|
+
if (lines.length >= 80) { out++; continue; }
|
|
354
|
+
const tag = el.localName, role = el.getAttribute("role");
|
|
355
|
+
const kind = heading ? "heading" : tag === "input" ? `input ${el.type}` : control ? (role ?? tag) : "text";
|
|
356
|
+
const state = [el.checked && "checked", el.disabled && "disabled", el.getAttribute("aria-selected") === "true" && "selected",
|
|
357
|
+
el.getAttribute("aria-pressed") === "true" && "pressed", el.getAttribute("aria-checked") === "true" && "checked"].filter(Boolean);
|
|
358
|
+
const value = /^(input|textarea|select)$/.test(tag) && !/^(checkbox|radio|button|submit)$/.test(el.type) ? ` = ${JSON.stringify(short(el.value, 60))}` : "";
|
|
359
|
+
lines.push(`${kind} "${short(label(el) || norm(el.innerText), 70)}"${value}${state.length ? ` [${state.join(", ")}]` : ""}`);
|
|
360
|
+
}
|
|
361
|
+
return lines.join("\n") + (out ? `\n(+${out} more out of view: scroll, or read(${target ? "target, " : "null, "}{all: true}))` : "");
|
|
362
|
+
},
|
|
363
|
+
async wait(what, timeout = 5000) { // ms, a CSS selector to appear, or a function to become true
|
|
364
|
+
if (typeof what === "number") return sleep(what);
|
|
365
|
+
const test = typeof what === "string" ? () => doc.querySelector(what) : what, end = Date.now() + timeout;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const v = await test();
|
|
368
|
+
if (v) return v;
|
|
369
|
+
if (Date.now() > end) throw new Error(`wait: still not there after ${timeout} ms`);
|
|
370
|
+
await sleep(100);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
return api;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------- running a page_run ----------
|
|
378
|
+
page.on("page_run", async ({ req, tab: target, card, code, agent = "agent1" }) => {
|
|
379
|
+
if (target !== tab) return;
|
|
380
|
+
const answer = (a) => page.send({ type: "page_ran", req, errors: [], ...a });
|
|
381
|
+
if (session?.paused) return answer({ error: `the user has taken over the page: wait until they press "Let ${session.agent} continue" (you get a message)` });
|
|
382
|
+
let w = window;
|
|
383
|
+
if (card) {
|
|
384
|
+
const el = document.querySelector(`[data-card="${CSS.escape(card)}"]`);
|
|
385
|
+
if (!el) return answer({ error: `no card "${card}" on the page` });
|
|
386
|
+
const fr = el.querySelector("iframe");
|
|
387
|
+
if (!fr) return answer({ error: `card "${card}" shows blocks, not an app: run without card and start from document.querySelector('[data-card="${card}"]')` });
|
|
388
|
+
try { fr.contentDocument.body; w = fr.contentWindow; } catch { return answer({ error: `card "${card}" shows another site: its page can't be reached` }); }
|
|
389
|
+
if (el.closest(".board:not(.on)")) code = `/* not on the board the user is looking at */\n${code}`;
|
|
390
|
+
else { // in front of the other cards, and in view
|
|
391
|
+
for (const o of document.querySelectorAll("[data-agent-front]")) if (o !== el) o.removeAttribute("data-agent-front");
|
|
392
|
+
el.setAttribute("data-agent-front", "");
|
|
393
|
+
const r = el.getBoundingClientRect();
|
|
394
|
+
if (r.top < 0 || r.bottom > innerHeight || r.left < 0 || r.right > innerWidth) {
|
|
395
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
|
396
|
+
await new Promise((ok) => setTimeout(ok, 450));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!session || session.agent !== agent) session = { agent, paused: false };
|
|
401
|
+
showBar();
|
|
402
|
+
for (const fr of main.querySelectorAll("iframe")) try { if (fr.contentDocument) watch(fr.contentDocument); } catch {}
|
|
403
|
+
const errors = [], onError = (e) => errors.push(String(e.error?.stack ?? e.message ?? e).split("\n").slice(0, 3).join("\n"));
|
|
404
|
+
const onReject = (e) => errors.push(`unhandled rejection: ${e.reason?.message ?? e.reason}`);
|
|
405
|
+
const consoleError = w.console.error;
|
|
406
|
+
w.addEventListener("error", onError);
|
|
407
|
+
w.addEventListener("unhandledrejection", onReject);
|
|
408
|
+
w.console.error = (...args) => { errors.push(args.map(String).join(" ")); consoleError.apply(w.console, args); };
|
|
409
|
+
busy++; refused = []; clearTimeout(hideTimer);
|
|
410
|
+
const h = helpers(w.document, agent);
|
|
411
|
+
let settled = false;
|
|
412
|
+
const finish = (a) => { // once: the code ended, or the page it ran in went away (then the code never ends)
|
|
413
|
+
if (settled) return;
|
|
414
|
+
settled = true;
|
|
415
|
+
answer({ ...a, errors: [...errors, ...refused.map((r) => `refused ${r}`)] });
|
|
416
|
+
busy--;
|
|
417
|
+
w.removeEventListener("error", onError);
|
|
418
|
+
w.removeEventListener("unhandledrejection", onReject);
|
|
419
|
+
w.removeEventListener("pagehide", onLeave);
|
|
420
|
+
w.console.error = consoleError;
|
|
421
|
+
hideTimer = setTimeout(() => pointer.classList.remove("on"), 2500);
|
|
422
|
+
clearTimeout(idleTimer); // no turn behind it (curl), or a turn that never ends: the bar goes after a quiet minute
|
|
423
|
+
idleTimer = setTimeout(() => { if (session && !session.paused && !busy) endSession(); }, 60000);
|
|
424
|
+
};
|
|
425
|
+
const onLeave = () => finish({ value: "the app reloaded (or left its page): the code after that didn't run; continue in a new page_run" });
|
|
426
|
+
w.addEventListener("pagehide", onLeave);
|
|
427
|
+
try {
|
|
428
|
+
const value = await new w.Function("click", "type", "press", "drag", "read", "wait", `return (async () => {\n${code}\n})()`)(
|
|
429
|
+
h.click, h.type, h.press, h.drag, h.read, h.wait);
|
|
430
|
+
finish({ value: describe(value) });
|
|
431
|
+
} catch (e) {
|
|
432
|
+
finish({ error: e instanceof TookOver ? `the user took over the page (they clicked or typed): stop here and wait; when they press "Let ${agent} continue" you get a message`
|
|
433
|
+
: e?.name ? `${e.name}: ${e.message}` : String(e) }); // the stack only points into this file
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
page.on("run_end", ({ agent }) => { if (session?.agent === agent && !session.paused) endSession(); }); // paused: the bar stays, to continue
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// An element (from any frame: no instanceof) as tag#id.class "its text"
|
|
440
|
+
function element(el) {
|
|
441
|
+
const id = el.id ? `#${el.id}` : "", cls = typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\s+/).join(".") : "";
|
|
442
|
+
const text = norm(el.innerText ?? el.textContent ?? "");
|
|
443
|
+
const value = "value" in el && el.value !== "" && el.localName !== "button" ? ` = ${JSON.stringify(el.value)}` : "";
|
|
444
|
+
return `${el.localName}${id}${cls}${text ? ` "${short(text, 60)}"` : ""}${value}`;
|
|
445
|
+
}
|
|
446
|
+
function describe(v) {
|
|
447
|
+
if (v === undefined) return "done (nothing returned)";
|
|
448
|
+
if (v?.nodeType === 1) return element(v);
|
|
449
|
+
if (v && typeof v === "object" && typeof v.length === "number" && v.length && [...v].every((x) => x?.nodeType === 1)) v = [...v].map(element);
|
|
450
|
+
let s;
|
|
451
|
+
try { s = typeof v === "string" ? v : JSON.stringify(v, (_k, x) => (x?.nodeType === 1 ? element(x) : x), 1); } catch { s = String(v); }
|
|
452
|
+
return s.length > MAX ? s.slice(0, MAX) + `… (${s.length} characters, cut)` : s;
|
|
453
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The page the user sees
|
|
1
|
+
// The page the user sees, as an extension like the others: agents can change it, and the
|
|
2
2
|
// versions extension keeps and rolls back its history.
|
|
3
3
|
// GET / page.html, read fresh on every load (edits show on reload)
|
|
4
4
|
// GET /page/scripts the browser parts the page imports: one per extension folder with a browser.js
|
|
@@ -12,15 +12,84 @@
|
|
|
12
12
|
// (compaction keeps the original entries, so the page still shows everything)
|
|
13
13
|
// "joined" (an agent's first system message) and reply_to (who a turn's answer goes to: the host's "chalky.turn"
|
|
14
14
|
// entry just before the user message that starts it) are read from the session, so nothing is kept here.
|
|
15
|
+
// Running code in the user's open page (browser.js here runs it):
|
|
16
|
+
// tool page_run {code, card?} JavaScript in the page the user has open, or inside a card's app: its value back
|
|
17
|
+
// POST /page/run {code, card?, agent?} the same from bash (curl): {value?, error?, errors}
|
|
18
|
+
// event "page_run" {req, tab, card, code, agent} to every page; only the tab named runs it
|
|
19
|
+
// page message page_tab {tab} this tab is the one the user looks at (on load and on focus)
|
|
20
|
+
// page message page_ran {req, value?, error?, errors: [str]}
|
|
21
|
+
// page message page_stop {agent} the bar shown while an agent uses the page: Stop (that agent only)
|
|
22
|
+
// The code gets helpers that act as a person would, slowly enough for the user to watch (browser.js's header).
|
|
15
23
|
import fs from "node:fs";
|
|
16
24
|
import path from "node:path";
|
|
17
25
|
import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Type } from "typebox";
|
|
18
27
|
|
|
19
28
|
const host = (globalThis as any).chalky;
|
|
20
29
|
const EXTENSIONS = path.dirname(import.meta.dirname);
|
|
21
30
|
const browserFile = (name: string) => path.join(EXTENSIONS, name, "browser.js");
|
|
31
|
+
const RUN_WAIT = 60; // s for the page to answer a page_run
|
|
32
|
+
// req -> resolve, while page_run waits (on the host: each agent loads this file, the page handler is one); the tab to run in
|
|
33
|
+
const runs: Map<string, (msg: any) => void> = host.shared.get("page-runs") ?? new Map();
|
|
34
|
+
host.shared.set("page-runs", runs);
|
|
22
35
|
|
|
23
|
-
|
|
36
|
+
host.pageHandlers.set("page", (msg: any) => {
|
|
37
|
+
if (msg.type === "page_tab") host.shared.set("page-tab", msg.tab);
|
|
38
|
+
else if (msg.type === "page_ran") runs.get(msg.req)?.(msg);
|
|
39
|
+
else if (msg.type === "page_stop" && host.agents.has(msg.agent)) host.abort(msg.agent); // the bar's Stop: that agent only
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function runInPage(code: string, card: string | undefined, agent: string, signal?: AbortSignal): Promise<any> {
|
|
43
|
+
const req = `r${Date.now()}${Math.random().toString(36).slice(2, 6)}`, tab = host.shared.get("page-tab");
|
|
44
|
+
if (!tab) return Promise.resolve({ error: "no page is open: ask the user to open Chalky" });
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const done = ({ value, error, errors }: any) => { clearTimeout(timer); runs.delete(req); resolve({ value, error, errors }); };
|
|
47
|
+
const timer = setTimeout(() => done({ error: `no answer within ${RUN_WAIT} s: the page may be closed, or the code still running` }), RUN_WAIT * 1000);
|
|
48
|
+
runs.set(req, done);
|
|
49
|
+
signal?.addEventListener("abort", () => done({ error: "stopped" }));
|
|
50
|
+
host.broadcast("page_run", { req, tab, card, code, agent });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const said = (r: any) => (r.error ? `error: ${r.error}` : r.value)
|
|
55
|
+
+ (r.errors?.length ? `\nerrors in the page while it ran:\n${r.errors.join("\n")}` : "");
|
|
56
|
+
|
|
57
|
+
export default function (pi: ExtensionAPI) {
|
|
58
|
+
pi.on("resources_discover", () => ({ skillPaths: [path.join(import.meta.dirname, "skills")] })); // interact-with-page
|
|
59
|
+
pi.registerTool({
|
|
60
|
+
name: "page_run", label: "Page run", exposure: "deferred",
|
|
61
|
+
description: "Run JavaScript in the page the user has open, as it is right now, and get its value back. With card=<id>, "
|
|
62
|
+
+ "it runs inside that card's app (document is the app's page); without it, in Chalky's page itself. The code is the "
|
|
63
|
+
+ "body of an async function: use await, and return what you want back (an element comes back as a short description, "
|
|
64
|
+
+ "long values are cut). Errors the page logs while it runs come back too. It is the user's live page: what you click "
|
|
65
|
+
+ "really happens, and they watch it. Helpers act as a person would, with your pointer moving there first: "
|
|
66
|
+
+ "await click(t), await type(t, text), await press(key, t?), await drag(t, dx, dy), read(t?) (what's on screen, "
|
|
67
|
+
+ "as short lines), await wait(ms | selector | fn); t is an element, a CSS selector, its visible text, or a spot "
|
|
68
|
+
+ "{x, y} in window px (whatever is on top there, as with a real mouse: empty space, a drawing layer). "
|
|
69
|
+
+ "For acting, use the helpers: a bare el.click() happens at once, and the user only sees an outline afterwards. "
|
|
70
|
+
+ "If the user clicks or types on the page, they have taken over: stop and wait for their message.",
|
|
71
|
+
parameters: Type.Object({
|
|
72
|
+
code: Type.String({ description: "the body of an async function, e.g. return document.querySelector('h1').innerText" }),
|
|
73
|
+
card: Type.Optional(Type.String({ description: "a card showing an app: run inside that app" })),
|
|
74
|
+
}),
|
|
75
|
+
async execute(_id, args: any, signal) {
|
|
76
|
+
const r = await runInPage(args.code, args.card, pi.getSessionName(), signal);
|
|
77
|
+
return { details: undefined, content: [{ type: "text" as const, text: said(r) }] };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
host.routes.set("/page/run", (req: any, res: any) => {
|
|
82
|
+
if (req.method !== "POST") return res.writeHead(405).end();
|
|
83
|
+
let body = "";
|
|
84
|
+
req.on("data", (c: Buffer) => { body += c; });
|
|
85
|
+
req.on("end", async () => {
|
|
86
|
+
let a: any;
|
|
87
|
+
try { a = JSON.parse(body); } catch { a = null; }
|
|
88
|
+
const r = typeof a?.code !== "string" ? { error: 'send JSON {"code": "...", "card": "<id>"}' }
|
|
89
|
+
: await runInPage(a.code, a.card, a.agent ?? "agent1");
|
|
90
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(r));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
24
93
|
host.routes.set("/", (_req: any, res: any) =>
|
|
25
94
|
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }).end(fs.readFileSync(path.join(import.meta.dirname, "page.html"))));
|
|
26
95
|
host.routes.set("/page/scripts", (_req: any, res: any) => {
|
|
@@ -42,7 +111,7 @@ const text = (content: any) => typeof content === "string" ? content
|
|
|
42
111
|
function toPage(m: any, first = false, replyTo?: string) {
|
|
43
112
|
const ts = (m.timestamp ?? Date.now()) / 1000;
|
|
44
113
|
if (m.role === "system") return first ? { role: "system", content: "", ts } : null;
|
|
45
|
-
if (m.role === "user" || (m.role === "custom" && m.customType === "inbox")) { // inbox:
|
|
114
|
+
if (m.role === "user" || (m.role === "custom" && m.customType === "inbox")) { // inbox: team items filed without a turn
|
|
46
115
|
return { role: "user", content: text(m.content), ...(replyTo && { reply_to: replyTo }), ts };
|
|
47
116
|
}
|
|
48
117
|
if (m.role === "assistant") {
|