@chalky/chalky 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +45 -2
- package/app/SYSTEM.md +31 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +398 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +166 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +85 -0
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +247 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +112 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +364 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +291 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/mcp.json +3 -0
- package/app/settings.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +281 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Browser half of the point plugin (experiment). A pencil button in the tool palette (top) turns on a clear
|
|
2
|
+
// layer over the page; the cursor is a pen, blue over something a click would point at.
|
|
3
|
+
// Click: a note box opens there. Hold the button and draw: on release the note box opens, and the note
|
|
4
|
+
// covers the drawn area (its bounding box, in main-space px: where ui_render's place puts a surface).
|
|
5
|
+
// The user sees only the box; the target goes to the agent with the note: surface and component (from
|
|
6
|
+
// ui.js's data-surface / data-cid), the element itself (also inside App frames, which are same-origin),
|
|
7
|
+
// its text, the source file to edit, the spot (main-space px; on empty space the spot is the whole target),
|
|
8
|
+
// and the area if drawn. Enter sends it (point.py passes it to the surface's owner, gives it an id and
|
|
9
|
+
// tells every open page to pin it, with its drawing). The pin fades when the agent marks the note
|
|
10
|
+
// done (event "point_done") or the user closes it with its ✕; open notes are redrawn after a reload.
|
|
11
|
+
// Esc or the button turns it off.
|
|
12
|
+
// The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
|
|
13
|
+
// combine ui.js fires "ui-combine" {a, b, x, y} when a surface is dropped onto another: the note
|
|
14
|
+
// says how to combine them and goes to b's owner as point_combine (b changes; a stays)
|
|
15
|
+
// files the files plugin fires "files-dropped" {x, y, files} when files land on the page (from the
|
|
16
|
+
// computer, or a row from its panel): a note with their paths, on what they were dropped on
|
|
17
|
+
// Parts: an element (or an App page's window) with partAt(x, y) is asked what is at the spot (an enemy in a
|
|
18
|
+
// canvas game); its answer goes with the note as "part". For a drawing, the thing under its middle is asked
|
|
19
|
+
// partIn(points) instead, if it has it: what is inside the drawn shape (the circled cells, paragraphs, an
|
|
20
|
+
// image of the area). points: [[x, y], ...] in window px (an App's window: px in its frame); it may be async.
|
|
21
|
+
export default async function (page) {
|
|
22
|
+
let names = {}; // "ui-table" -> "Table" (tags are lower case; catalog names are not)
|
|
23
|
+
try { for (const n of Object.keys(await (await fetch("/ui-api/catalog")).json())) names[`ui-${n.toLowerCase()}`] = n; } catch {}
|
|
24
|
+
|
|
25
|
+
const layer = document.createElement("div"); // catches clicks, so the page underneath doesn't react
|
|
26
|
+
layer.style.cssText = "position:fixed;inset:0;z-index:9998;display:none";
|
|
27
|
+
// The cursor: the button's pen (white edge, so it shows on dark too), its tip (4, 20) is the spot pointed at.
|
|
28
|
+
const pen = (color) => `url("data:image/svg+xml,${encodeURIComponent(
|
|
29
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
|
|
30
|
+
`<g stroke="#fff" stroke-width="4"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g>` +
|
|
31
|
+
`<g stroke="${color}" stroke-width="2"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g></svg>`)}") 4 20, crosshair`;
|
|
32
|
+
const PEN = pen("#6e6e73"), PEN_ON = pen("#0071e3"); // grey: empty space; blue: over a thing
|
|
33
|
+
layer.style.cursor = PEN;
|
|
34
|
+
const input = document.createElement("input"); // the note box, opened where the user clicked
|
|
35
|
+
input.id = "point-note";
|
|
36
|
+
input.placeholder = "Write a note, Enter to send";
|
|
37
|
+
input.style.cssText = "position:fixed;z-index:9999;width:280px;box-sizing:border-box;font:inherit;font-size:14px;" +
|
|
38
|
+
"padding:8px 12px;border-radius:12px;outline:none;color:#1d1d1f;background:var(--glass-strong,#fff);" + // the page's glass
|
|
39
|
+
"backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);border:1px solid var(--edge,#eee);" +
|
|
40
|
+
"box-shadow:0 8px 30px rgba(0,0,0,.1);display:none";
|
|
41
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
42
|
+
function strokeSvg(points) { // a freehand stroke, drawn over the whole window (fixed, like the pins)
|
|
43
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
44
|
+
svg.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none";
|
|
45
|
+
const line = document.createElementNS(SVG, "polyline");
|
|
46
|
+
line.style.cssText = "fill:none;stroke:#f5b700;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;opacity:.85";
|
|
47
|
+
line.setAttribute("points", points.map((p) => p.join(",")).join(" "));
|
|
48
|
+
svg.append(line);
|
|
49
|
+
return svg;
|
|
50
|
+
}
|
|
51
|
+
const ink = strokeSvg([]); // the stroke being drawn
|
|
52
|
+
ink.style.zIndex = 9999;
|
|
53
|
+
ink.style.display = "none";
|
|
54
|
+
let current = null; // {target, x, y, stroke} of the open note
|
|
55
|
+
let stroke = null; // [[x, y], ...] while the mouse button is held down
|
|
56
|
+
document.body.append(layer, ink, input);
|
|
57
|
+
|
|
58
|
+
const button = document.createElement("button");
|
|
59
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></svg>`;
|
|
60
|
+
button.title = "Point: click anything, or hold and draw around an area, then write a note (Esc to stop)";
|
|
61
|
+
page.slot("status").prepend(button); // the first tool in the palette, whichever script loads first
|
|
62
|
+
const setOn = (on) => {
|
|
63
|
+
layer.style.display = on ? "block" : "none";
|
|
64
|
+
button.classList.toggle("on", on);
|
|
65
|
+
if (!on) input.style.display = ink.style.display = "none";
|
|
66
|
+
if (!on) current = stroke = null;
|
|
67
|
+
if (on) document.dispatchEvent(new CustomEvent("tool-on", { detail: "point" })); // the other tools turn off
|
|
68
|
+
};
|
|
69
|
+
button.onclick = () => setOn(layer.style.display === "none");
|
|
70
|
+
document.addEventListener("tool-on", (e) => { if (e.detail !== "point" && layer.style.display !== "none") setOn(false); }); // one tool at a time
|
|
71
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
|
|
72
|
+
|
|
73
|
+
const describe = (e) => e.localName + (e.id ? `#${e.id}` : "") + (e.dataset?.name ? `[data-name="${e.dataset.name}"]` : "") +
|
|
74
|
+
[...e.classList].slice(0, 2).map((c) => `.${c}`).join("");
|
|
75
|
+
|
|
76
|
+
function inMain(x, y) { // window px -> main-space px (the main space scrolls; ui_render's place uses these)
|
|
77
|
+
const main = page.slot("top"), m = main.getBoundingClientRect();
|
|
78
|
+
return { x: Math.round(x - m.left + main.scrollLeft), y: Math.round(y - m.top + main.scrollTop) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function target(x, y) {
|
|
82
|
+
const el = document.elementsFromPoint(x, y).find((e) => e !== layer);
|
|
83
|
+
if (!el) return null;
|
|
84
|
+
let inner = null, frame = null, box = el.getBoundingClientRect();
|
|
85
|
+
if (el.localName === "iframe") { // look inside the frame (an App): same origin, so its document is readable
|
|
86
|
+
try {
|
|
87
|
+
const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
|
|
88
|
+
inner = el.contentDocument?.elementFromPoint(x - dx, y - dy);
|
|
89
|
+
if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(b.x + dx, b.y + dy, b.width, b.height); }
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
const hitEl = inner || el;
|
|
93
|
+
const position = inMain(x, y);
|
|
94
|
+
if (el === page.slot("top")) return { owner: null, surface: null, components: [], element: "empty space in the main space",
|
|
95
|
+
text: "", file: "", position, box: new DOMRect(x - 12, y - 12, 24, 24) };
|
|
96
|
+
const components = []; // outermost first, e.g. ["root (Card)", "chart1 (Table)"]
|
|
97
|
+
for (let e = el.closest("[data-cid]"); e; e = e.parentElement?.closest("[data-cid]"))
|
|
98
|
+
components.unshift(`${e.dataset.cid} (${names[e.localName] || e.localName})`);
|
|
99
|
+
const path = []; // a short selector: up to the nearest element with an id, at most 4 steps
|
|
100
|
+
for (let e = hitEl; e && path.length < 4 && e.localName !== "body"; e = e.parentElement) {
|
|
101
|
+
path.unshift(describe(e));
|
|
102
|
+
if (e.id) break;
|
|
103
|
+
}
|
|
104
|
+
const innermost = el.closest("[data-cid]");
|
|
105
|
+
let part = null; // what the thing itself says is at this spot (partAt), e.g. {"name": "enemy-2"}
|
|
106
|
+
try {
|
|
107
|
+
if (frame?.contentWindow?.partAt) {
|
|
108
|
+
const r = frame.getBoundingClientRect();
|
|
109
|
+
part = frame.contentWindow.partAt(x - r.left - frame.clientLeft, y - r.top - frame.clientTop);
|
|
110
|
+
} else {
|
|
111
|
+
const owner = [hitEl, ...[...document.elementsFromPoint(x, y)]].find((e) => typeof e?.partAt === "function");
|
|
112
|
+
if (owner) { const r = owner.getBoundingClientRect(); part = owner.partAt(x - r.left, y - r.top); }
|
|
113
|
+
}
|
|
114
|
+
} catch (e) { console.warn("partAt:", e); }
|
|
115
|
+
const file = frame ? "/app" + new URL(frame.contentWindow.location.href).pathname // /ui/apps/x/... -> /app/ui/apps/x/...
|
|
116
|
+
: innermost ? `/app/extensions/ui/components/${names[innermost.localName] || innermost.localName}.js`
|
|
117
|
+
: "/app/extensions/page/page.html (the page itself)"; // /app/: the code folder (the server puts in the real one)
|
|
118
|
+
return {
|
|
119
|
+
owner: el.closest("[data-owner]")?.dataset.owner || null,
|
|
120
|
+
surface: el.closest("[data-surface]")?.dataset.surface || null,
|
|
121
|
+
components,
|
|
122
|
+
element: path.join(" > "),
|
|
123
|
+
part,
|
|
124
|
+
text: (hitEl.innerText || hitEl.value || hitEl.alt || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
125
|
+
file,
|
|
126
|
+
at: { x: Math.round(((x - box.x) / box.width) * 100) + "%", y: Math.round(((y - box.y) / box.height) * 100) + "%" },
|
|
127
|
+
position,
|
|
128
|
+
box,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The note box at (x, y). `note`: what Enter sends besides the text (target, combine, files);
|
|
133
|
+
// `suggestion`: the faded text, sent as it is if the box is left empty (else the box needs words).
|
|
134
|
+
function openBox(x, y, note, suggestion = "") {
|
|
135
|
+
input.style.display = "block";
|
|
136
|
+
input.style.left = Math.min(x + 12, innerWidth - input.offsetWidth - 8) + "px";
|
|
137
|
+
input.style.top = Math.min(y + 12, innerHeight - input.offsetHeight - 8) + "px";
|
|
138
|
+
input.placeholder = suggestion || "Write a note, Enter to send";
|
|
139
|
+
current = { x, y, suggestion, ...note };
|
|
140
|
+
input.value = "";
|
|
141
|
+
input.focus();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function openNote(t, x, y, drawn = null) { // a note on target t (and the drawn stroke)
|
|
145
|
+
if (!t) return;
|
|
146
|
+
const { box, ...rest } = t;
|
|
147
|
+
openBox(x, y, { type: "point_note", target: rest, stroke: drawn, to: rest.owner });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Combine: a surface was dropped onto another (ui.js). The note goes to the target's owner.
|
|
151
|
+
document.addEventListener("ui-combine", ({ detail: { a, b, x, y } }) => {
|
|
152
|
+
const to = document.querySelector(`[data-surface="${CSS.escape(b)}"]`)?.dataset.owner;
|
|
153
|
+
openBox(x, y, { type: "point_combine", a, b, to }, `Add ${a} to ${b}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Files dropped on the page (the files plugin: from the computer, or a row from its panel; already
|
|
157
|
+
// stored in /workspace/drops): a note on what they landed on. Esc closes the box; the files stay stored.
|
|
158
|
+
document.addEventListener("files-dropped", ({ detail: { x, y, files } }) => {
|
|
159
|
+
const t = target(x, y) || { owner: null, surface: null, components: [], element: "", text: "", file: "", position: inMain(x, y) };
|
|
160
|
+
const { box, ...rest } = t;
|
|
161
|
+
const paths = files.map((f) => `${f.path} (${Math.ceil(f.size / 1024)} KB)`);
|
|
162
|
+
openBox(x, y, { type: "point_note", target: rest, files: paths, to: rest.owner }, `Use ${files.map((f) => f.name).join(", ")} here`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Press, move, release: a small movement is a click on one thing; a bigger one is a drawing.
|
|
166
|
+
layer.onmousedown = (e) => {
|
|
167
|
+
stroke = [[e.clientX, e.clientY]];
|
|
168
|
+
input.style.display = ink.style.display = "none";
|
|
169
|
+
};
|
|
170
|
+
layer.onmousemove = (e) => {
|
|
171
|
+
if (!stroke) {
|
|
172
|
+
const t = target(e.clientX, e.clientY);
|
|
173
|
+
layer.style.cursor = t && t.surface ? PEN_ON : PEN;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const [lx, ly] = stroke.at(-1);
|
|
177
|
+
if (Math.hypot(e.clientX - lx, e.clientY - ly) < 3) return; // keep the stroke light
|
|
178
|
+
stroke.push([e.clientX, e.clientY]);
|
|
179
|
+
ink.style.display = "block";
|
|
180
|
+
ink.firstChild.setAttribute("points", stroke.map((p) => p.join(",")).join(" "));
|
|
181
|
+
};
|
|
182
|
+
async function partIn(x, y, points) { // what the thing at (x, y) says is inside the drawing, or null
|
|
183
|
+
const el = document.elementsFromPoint(x, y).find((e) => e !== layer && e !== ink);
|
|
184
|
+
if (el?.localName === "iframe") {
|
|
185
|
+
try {
|
|
186
|
+
const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
|
|
187
|
+
if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => [px - dx, py - dy]));
|
|
188
|
+
} catch (err) { console.warn("partIn:", err); }
|
|
189
|
+
}
|
|
190
|
+
const owner = document.elementsFromPoint(x, y).find((e) => typeof e.partIn === "function");
|
|
191
|
+
try { return owner ? await owner.partIn(points) : null; } catch (err) { console.warn("partIn:", err); return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
layer.onmouseup = async (e) => {
|
|
195
|
+
const points = stroke;
|
|
196
|
+
stroke = null;
|
|
197
|
+
if (!points) return;
|
|
198
|
+
const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]);
|
|
199
|
+
const left = Math.min(...xs), top = Math.min(...ys), width = Math.max(...xs) - left, height = Math.max(...ys) - top;
|
|
200
|
+
if (width < 12 && height < 12) return openNote(target(e.clientX, e.clientY), e.clientX, e.clientY);
|
|
201
|
+
const t = target(left + width / 2, top + height / 2); // what is under the middle of the drawing
|
|
202
|
+
if (t) t.area = { ...inMain(left, top), width: Math.round(width), height: Math.round(height) };
|
|
203
|
+
const inside = t && (await partIn(left + width / 2, top + height / 2, points));
|
|
204
|
+
if (inside) t.part = inside;
|
|
205
|
+
openNote(t, e.clientX, e.clientY, points);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
input.onkeydown = (e) => {
|
|
209
|
+
const text = input.value.trim() || current?.suggestion;
|
|
210
|
+
if (e.key !== "Enter" || !text || !current) return;
|
|
211
|
+
const { suggestion, ...note } = current;
|
|
212
|
+
page.send({ ...note, text, to: note.to || document.getElementById("to")?.value, // else the agent picked in the chat
|
|
213
|
+
board: Number(document.body.dataset.board) || 1 }); // the board shown (boards/): what it points at is on it
|
|
214
|
+
setOn(false);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const pins = {}; // note id -> pin element: its label, and its drawing if it has one
|
|
218
|
+
function pin({ id, text, to, x, y, stroke, board }) { // stays where the note was written (doesn't follow scrolling yet)
|
|
219
|
+
if (pins[id]) return;
|
|
220
|
+
const el = (pins[id] = document.createElement("div"));
|
|
221
|
+
el.dataset.board = board ?? 1; // shown only on its board (boards/)
|
|
222
|
+
el.style.cssText = "position:fixed;inset:0;z-index:9997;pointer-events:none;transition:opacity 1.5s";
|
|
223
|
+
if (stroke) el.append(strokeSvg(stroke));
|
|
224
|
+
const label = document.createElement("div");
|
|
225
|
+
label.style.cssText = `position:absolute;left:${x}px;top:${y}px;max-width:240px;` +
|
|
226
|
+
"background:#fff8c4;border:1px solid #e6c200;border-radius:4px;padding:3px 20px 3px 6px;font:12px sans-serif;" +
|
|
227
|
+
"box-shadow:0 1px 3px #0003";
|
|
228
|
+
label.textContent = `✎ ${text} · sent to ${to}`;
|
|
229
|
+
const close = document.createElement("button");
|
|
230
|
+
close.textContent = "✕";
|
|
231
|
+
close.title = "Close this note (its agent is told)";
|
|
232
|
+
close.style.cssText = "position:absolute;top:1px;right:3px;line-height:1;pointer-events:auto;border:0;background:none;cursor:pointer;padding:0 2px;font:inherit;color:#8a7400";
|
|
233
|
+
close.onclick = () => page.send({ type: "point_dismiss", id });
|
|
234
|
+
label.append(close);
|
|
235
|
+
el.append(label);
|
|
236
|
+
document.body.append(el);
|
|
237
|
+
}
|
|
238
|
+
page.on("point_pin", pin);
|
|
239
|
+
page.on("point_done", ({ id }) => {
|
|
240
|
+
const el = pins[id];
|
|
241
|
+
if (!el) return;
|
|
242
|
+
delete pins[id];
|
|
243
|
+
el.style.opacity = "0";
|
|
244
|
+
setTimeout(() => el.remove(), 1500);
|
|
245
|
+
});
|
|
246
|
+
try { for (const [id, n] of Object.entries(await (await fetch("/point/notes")).json())) pin({ id, ...n }); } catch {}
|
|
247
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Notes on the UI
|
|
2
|
+
The user clicks anything on the page (or draws around it, or drops files there) and writes a note: you get
|
|
3
|
+
"[note on the UI] <their words>" with exactly what it is on.
|
|
4
|
+
- Change that exact thing. If its text is data (a row, a score), change the surface's data, not the component.
|
|
5
|
+
- When you have dealt with it, call note_done(id).
|
|
6
|
+
Before you handle your first note, read README.md.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Notes on the UI (port of the Python point plugin; the browser half, browser.js, is its point.js): the user points at
|
|
2
|
+
// things and writes a note there; the note and its target go to an agent, so it knows exactly what it is about.
|
|
3
|
+
// point_note click anything on the page (or draw around an area) and write: the target is the surface,
|
|
4
|
+
// component, element, part (partAt), its text and source file, the spot and the area.
|
|
5
|
+
// Files dropped on the page come as a note too ("files": paths in <workspace>/drops).
|
|
6
|
+
// point_combine the user dragged surface a onto surface b and wrote how to combine them. The agent changes b to
|
|
7
|
+
// take a in, reading a's data live ({"ref"} props); a stays as it is.
|
|
8
|
+
// Each note goes to the surface's owner (else the agent picked in the chat), gets an id (n1, n2, ...: never reused)
|
|
9
|
+
// and stays pinned (state/notes.json: {"last", "open": {id: note}}, redrawn after a reload) until the agent calls
|
|
10
|
+
// note_done(id). The user can also close a pin (its ✕): the note is dropped, and taken out of its agent's inbox if
|
|
11
|
+
// its turn hasn't started (the agent never saw it); else its agent is told (a note).
|
|
12
|
+
// guide.md: the short prompt section; README.md (copied from point.py): the whole of it, read before a first note.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
const host = (globalThis as any).chalky;
|
|
19
|
+
const FILE = path.join(host.state, "notes.json");
|
|
20
|
+
if (!host.shared.has("point"))
|
|
21
|
+
host.shared.set("point", fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, open: {} });
|
|
22
|
+
const notes = host.shared.get("point"); // {last, open: {id: {text, to, x, y, stroke, board}}}
|
|
23
|
+
if (!host.shared.has("point-items")) host.shared.set("point-items", new Map());
|
|
24
|
+
const items: Map<string, any> = host.shared.get("point-items"); // note id -> its swarm inbox item (not saved)
|
|
25
|
+
const save = () => fs.writeFileSync(FILE, JSON.stringify(notes));
|
|
26
|
+
|
|
27
|
+
function pin(text: string, to: string, msg: any) { // a new open note: saved, pinned on every page
|
|
28
|
+
const id = `n${++notes.last}`;
|
|
29
|
+
notes.open[id] = { text, to, x: msg.x ?? 0, y: msg.y ?? 0, stroke: msg.stroke ?? null, // with its drawing, if any
|
|
30
|
+
board: Number(msg.board) || 1 }; // the board it was written on (boards/): its pin shows there
|
|
31
|
+
save();
|
|
32
|
+
host.broadcast("point_pin", { id, ...notes.open[id] });
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function describe(sid: string) { // a source of a combine, for the agent: cut short if long
|
|
37
|
+
const r = host.shared.get("ui")?.surfaces[sid] ?? {};
|
|
38
|
+
const text = JSON.stringify({ owner: r.owner, about: r.about, components: r.components, data: r.data });
|
|
39
|
+
return text.length < 3000 ? text : text.slice(0, 3000) + ` … (more in ${host.app}/ui/surfaces.json and ${host.state}/ui-data.json)`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
|
|
44
|
+
.replace("README.md", path.join(import.meta.dirname, "README.md"));
|
|
45
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.point = guide; });
|
|
46
|
+
// a removed board (boards/) takes its open notes with it; a note its agent hasn't seen yet is just taken back
|
|
47
|
+
pi.on("session_start", () => host.api.boards?.onRemove("point", (n: number) => {
|
|
48
|
+
const gone: { owner: string; what: string }[] = [];
|
|
49
|
+
for (const id of Object.keys(notes.open).filter((id) => (notes.open[id].board ?? 1) === n)) {
|
|
50
|
+
const note = notes.open[id], item = items.get(id);
|
|
51
|
+
delete notes.open[id];
|
|
52
|
+
items.delete(id);
|
|
53
|
+
host.broadcast("point_done", { id });
|
|
54
|
+
if (!(item && host.api.swarm?.withdraw(item))) gone.push({ owner: note.to, what: `note ${id} ("${note.text}")` });
|
|
55
|
+
}
|
|
56
|
+
save();
|
|
57
|
+
return gone;
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
pi.registerTool({
|
|
61
|
+
name: "note_done", label: "Note done", description: "Mark a note the user pinned on the UI as dealt with; its pin fades.",
|
|
62
|
+
parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }) }),
|
|
63
|
+
async execute(_id, a) {
|
|
64
|
+
if (!notes.open[a.id]) return { content: [{ type: "text", text: `error: no open note '${a.id}'. Open notes: ${Object.keys(notes.open).join(", ")}` }], details: undefined };
|
|
65
|
+
delete notes.open[a.id];
|
|
66
|
+
items.delete(a.id);
|
|
67
|
+
save();
|
|
68
|
+
host.broadcast("point_done", { id: a.id });
|
|
69
|
+
return { content: [{ type: "text", text: `note ${a.id} is done; its pin fades from the page` }], details: undefined };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
host.routes.set("/point/notes", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(notes.open)));
|
|
74
|
+
|
|
75
|
+
host.pageHandlers.set("point", (msg: any) => {
|
|
76
|
+
const swarm = host.api.swarm;
|
|
77
|
+
if (msg.type === "point_dismiss" && notes.open[msg.id]) {
|
|
78
|
+
const note = notes.open[msg.id];
|
|
79
|
+
delete notes.open[msg.id];
|
|
80
|
+
save();
|
|
81
|
+
host.broadcast("point_done", { id: msg.id });
|
|
82
|
+
const item = items.get(msg.id);
|
|
83
|
+
items.delete(msg.id);
|
|
84
|
+
if (!(item && swarm?.withdraw(item)))
|
|
85
|
+
swarm?.send("user", note.to, `[note on the UI] the user closed note ${msg.id} ("${note.text}"): drop it if you haven't finished it.`, true);
|
|
86
|
+
}
|
|
87
|
+
if (!["point_note", "point_combine"].includes(msg.type) || !swarm) return;
|
|
88
|
+
const to = host.agents.has(msg.to) ? msg.to : "agent1";
|
|
89
|
+
const text = msg.text ?? "";
|
|
90
|
+
if (msg.type === "point_combine") {
|
|
91
|
+
const { a, b } = msg;
|
|
92
|
+
const madeFrom = [...(host.shared.get("ui")?.surfaces[b]?.made_from ?? []).filter((m: string) => m !== a), a];
|
|
93
|
+
const id = pin(`combine ${a} into ${b}: ${text}`, to, msg);
|
|
94
|
+
return items.set(id, swarm.send("user", to, [`[combine on the UI] ${text}`, ` id: ${id} (call note_done("${id}") when dealt with)`,
|
|
95
|
+
` board: ${notes.open[id].board}`,
|
|
96
|
+
` dragged: ${a} (keep it as it is)`, ` onto: ${b} (change this one)`,
|
|
97
|
+
` so: ui_render(surface="${b}", ..., made_from=${JSON.stringify(madeFrom)}, instruction=<their words>), reading ${a}'s data with {"ref": "${a}", "path": ...}`,
|
|
98
|
+
` ${a}: ${describe(a)}`, ` ${b}: ${describe(b)}`].join("\n")));
|
|
99
|
+
}
|
|
100
|
+
const t = msg.target ?? {};
|
|
101
|
+
const at = t.at && `${t.at.x} across, ${t.at.y} down the element`;
|
|
102
|
+
const pos = t.position && `x=${t.position.x}, y=${t.position.y} px in the main space (for ui_render's place)`;
|
|
103
|
+
const area = t.area && `x=${t.area.x}, y=${t.area.y}, width=${t.area.width}, height=${t.area.height} px in the main space `
|
|
104
|
+
+ (t.surface ? "(the user drew around this part of the surface: the note is about it)" : "(the user drew around empty space: build inside it)");
|
|
105
|
+
const id = pin(text, to, msg);
|
|
106
|
+
const lines = [`[note on the UI] ${text}`, ` id: ${id} (call note_done("${id}") when dealt with)`,
|
|
107
|
+
...([["board", notes.open[id].board], ["surface", t.surface], ["component", (t.components ?? []).join(" → ")], ["element", t.element],
|
|
108
|
+
["part", t.part && JSON.stringify(t.part)], ["at", at], ["text", t.text], ["file", t.file?.replace(/^\/app\//, host.app + "/")], ["position", pos],
|
|
109
|
+
["area", area], ["files", msg.files?.join(", ")]] as [string, any][]).filter(([, v]) => v).map(([k, v]) => ` ${k}: ${v}`)];
|
|
110
|
+
items.set(id, swarm.send("user", to, lines.join("\n")));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Where things are: the paths the prompt needs that depend on this machine, as the <paths> section. The prompt's
|
|
2
|
+
// words are app/SYSTEM.md (pi's documented way to replace its default prompt), which is plain text and can't hold
|
|
3
|
+
// these. pi adds the skills list and the working folder (<cwd>) itself.
|
|
4
|
+
import { getDocsPath, getExamplesPath, getReadmePath, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
pi.on("before_agent_start", (event) => {
|
|
8
|
+
const host = (globalThis as any).chalky;
|
|
9
|
+
event.systemPromptOptions.sections.paths = [
|
|
10
|
+
`{code} = ${host.app}: your code (versioned: each turn that changes it is saved, and the user can roll back)`,
|
|
11
|
+
`{work} = ${host.work}: your working folder (<cwd>), for project work and the user's files`,
|
|
12
|
+
`{data} = ${host.state}: what the agents and apps keep (conversations, app data, surfaces' data; not versioned)`,
|
|
13
|
+
`{page} = http://127.0.0.1:${host.port}: the user's page, served by the host`,
|
|
14
|
+
`The host: ${host.root}/server.ts`,
|
|
15
|
+
`pi's docs: ${getDocsPath()}/extensions.md for extensions, examples in ${getExamplesPath()}/extensions/; the rest in `
|
|
16
|
+
+ `${getReadmePath()} and ${getDocsPath()}/. Read pi's .md files completely and follow their links.`,
|
|
17
|
+
].join("\n");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Self-edits take effect: the Python agent's restart plugin, the pi way. The tool only asks; when this agent's turn
|
|
2
|
+
// has ended, the host calls pi's session.reload() (extensions, skills and prompt load again; the conversation stays).
|
|
3
|
+
// Not pi's reload-runtime.ts example: its pi.sendUserMessage("/reload-runtime") reaches the model as text in 1.4.0
|
|
4
|
+
// (expandPromptTemplates defaults to false), and the model kept calling the tool again.
|
|
5
|
+
// No process restart, so no start.sh (a rollback: the versions plugin, which reloads every agent).
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
pi.registerTool({
|
|
11
|
+
name: "reload", label: "Reload", description: "Load your edited extensions again, when your turn ends.",
|
|
12
|
+
parameters: Type.Object({ reason: Type.String() }),
|
|
13
|
+
async execute(_id, a) {
|
|
14
|
+
const host = (globalThis as any).chalky, name = pi.getSessionName();
|
|
15
|
+
host.reloadAfterTurn.set(name, false); // not quiet: the page notes the reload
|
|
16
|
+
host.shared.set(`reload-reason ${name}`, a.reason); // the versions plugin's commit message for this turn
|
|
17
|
+
return { content: [{ type: "text", text: "Reload scheduled for when your turn ends. End your turn now; don't call reload again." }],
|
|
18
|
+
details: undefined };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Browser half of the screen extension (Python's screen.js, unchanged but for this header): this tab, shared once (getDisplayMedia), for every plugin that needs a picture
|
|
2
|
+
// of what the user sees. Offered as page.provide("screen", {...}); a plugin gets it with await page.use("screen"):
|
|
3
|
+
// share() ask the browser to share this tab (only works right after a click: transient activation);
|
|
4
|
+
// resolves true if shared. The user can stop sharing any time (the browser's bar).
|
|
5
|
+
// shared() whether the tab is shared now
|
|
6
|
+
// frame() the screen right now, as a canvas (null when not shared)
|
|
7
|
+
// picture(full, box) crop a frame to box (window px {x, y, w, h}), upload it (POST /screen/shot): {path, url}
|
|
8
|
+
// An agent can ask to see the screen (its tool see_user_screen, event screen_ask): if the tab is shared, a picture
|
|
9
|
+
// of the whole window goes back at once; else a card asks "Share / No" first, since the agent's ask is no click
|
|
10
|
+
// (screen_reply {id, shot, window, main}).
|
|
11
|
+
export default async function (page) {
|
|
12
|
+
const area = page.slot("top");
|
|
13
|
+
const style = document.createElement("style");
|
|
14
|
+
style.textContent = `
|
|
15
|
+
.screen-ask { position: fixed; top: 76px; left: 50%; transform: translateX(-50%); z-index: 100; display: flex; align-items: center; gap: 10px;
|
|
16
|
+
padding: 8px 8px 8px 14px; border-radius: 14px; background: var(--glass-strong, #fff); border: 1px solid var(--edge, #eee);
|
|
17
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, .12); font-size: 13px; color: var(--text); }
|
|
18
|
+
.screen-ask button { border: 0; border-radius: 9px; padding: 5px 12px; font: inherit; cursor: pointer; background: var(--line, #eee); color: var(--text); }
|
|
19
|
+
.screen-ask button.yes { background: var(--accent, #0071e3); color: #fff; }`;
|
|
20
|
+
document.head.append(style);
|
|
21
|
+
|
|
22
|
+
let stream = null, video = null;
|
|
23
|
+
async function share() {
|
|
24
|
+
if (stream) return true;
|
|
25
|
+
try {
|
|
26
|
+
const full = { width: { ideal: innerWidth * devicePixelRatio }, height: { ideal: innerHeight * devicePixelRatio } }; // sharp: the page's own pixels
|
|
27
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: full, preferCurrentTab: true, selfBrowserSurface: "include" });
|
|
28
|
+
stream.getVideoTracks()[0].onended = () => { stream = null; video?.remove(); video = null; }; // the user stopped sharing
|
|
29
|
+
video = Object.assign(document.createElement("video"), { muted: true, playsInline: true, srcObject: stream });
|
|
30
|
+
video.style.cssText = "position:fixed;width:1px;height:1px;opacity:0;pointer-events:none";
|
|
31
|
+
document.body.append(video);
|
|
32
|
+
await video.play();
|
|
33
|
+
return true;
|
|
34
|
+
} catch { stream = null; return false; } // not shared
|
|
35
|
+
}
|
|
36
|
+
const shared = () => !!video?.videoWidth;
|
|
37
|
+
function frame() {
|
|
38
|
+
if (!shared()) return null;
|
|
39
|
+
const c = Object.assign(document.createElement("canvas"), { width: video.videoWidth, height: video.videoHeight });
|
|
40
|
+
c.getContext("2d").drawImage(video, 0, 0);
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
async function picture(full, box) {
|
|
44
|
+
const kx = full.width / innerWidth, ky = full.height / innerHeight; // frame px per window px (the frame is the tab)
|
|
45
|
+
const c = Object.assign(document.createElement("canvas"), { width: Math.round(box.w * kx), height: Math.round(box.h * ky) });
|
|
46
|
+
c.getContext("2d").drawImage(full, box.x * kx, box.y * ky, box.w * kx, box.h * ky, 0, 0, c.width, c.height);
|
|
47
|
+
const png = await new Promise((ok) => c.toBlob(ok, "image/png"));
|
|
48
|
+
const out = await (await fetch("/screen/shot", { method: "POST", body: png })).json();
|
|
49
|
+
return { path: out.path || null, url: URL.createObjectURL(png) };
|
|
50
|
+
}
|
|
51
|
+
page.provide("screen", { share, shared, frame, picture });
|
|
52
|
+
|
|
53
|
+
// ---- an agent asks to see the screen ----
|
|
54
|
+
const asks = {}; // id -> its card, while the agent waits
|
|
55
|
+
async function answer(id) {
|
|
56
|
+
for (const card of Object.values(asks)) card.remove(); // not in the picture
|
|
57
|
+
await new Promise((ok) => setTimeout(ok, 300)); // a fresh frame from the shared tab
|
|
58
|
+
const full = frame();
|
|
59
|
+
const s = full && await picture(full, { x: 0, y: 0, w: innerWidth, h: innerHeight }).catch(() => null);
|
|
60
|
+
const m = area.getBoundingClientRect(); // window (0, 0) in main-space px (as point.js)
|
|
61
|
+
page.send({ type: "screen_reply", id, shot: s?.path || null, window: { width: innerWidth, height: innerHeight },
|
|
62
|
+
main: { x: area.scrollLeft - m.left, y: area.scrollTop - m.top } });
|
|
63
|
+
}
|
|
64
|
+
page.on("screen_ask", ({ id, agent }) => {
|
|
65
|
+
if (shared()) return answer(id);
|
|
66
|
+
const card = Object.assign(document.createElement("div"), { className: "screen-ask" });
|
|
67
|
+
const yes = Object.assign(document.createElement("button"), { className: "yes", textContent: "Share" });
|
|
68
|
+
const no = Object.assign(document.createElement("button"), { textContent: "No" });
|
|
69
|
+
card.append(`${agent} wants to see your screen`, yes, no);
|
|
70
|
+
yes.onclick = async () => { await share(); answer(id); };
|
|
71
|
+
no.onclick = () => page.send({ type: "screen_reply", id, shot: null });
|
|
72
|
+
asks[id] = card;
|
|
73
|
+
document.body.append(card);
|
|
74
|
+
});
|
|
75
|
+
page.on("screen_ask_done", ({ id }) => { asks[id]?.remove(); delete asks[id]; }); // answered (here or in another tab), or timed out
|
|
76
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Screen: what the user sees, as pictures (port of the Python screen plugin; browser.js is its screen.js). The
|
|
2
|
+
// browser half shares the tab once (for every extension: doodle's steps, an agent's look) and uploads pictures
|
|
3
|
+
// cropped from it. This half keeps them and gives agents the tool see_user_screen: the user's window as it is now
|
|
4
|
+
// (their size, scroll, panels, running apps; ui-check's headless Chrome is a fresh window of its own).
|
|
5
|
+
// POST /screen/shot a png (at most MAX_BYTES): saved in state/screens, {path} back
|
|
6
|
+
// event "screen_ask" {id, agent} to every open page, when an agent calls see_user_screen
|
|
7
|
+
// page message screen_reply {id, shot, window: {width, height}, main: {x, y}}: the first answers
|
|
8
|
+
// (shot null: the user said no; main: window (0, 0) in main-space px)
|
|
9
|
+
// event "screen_ask_done" {id} to every page when answered or after ASK_WAIT s, so the asks go away
|
|
10
|
+
// host.api.screen isShot(path): whether a path is a picture saved here (others attach pictures by path)
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
|
|
16
|
+
const host = (globalThis as any).chalky;
|
|
17
|
+
const SHOTS = path.join(host.state, "screens"); // outside app/: not code
|
|
18
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
20
|
+
const ASK_WAIT = 90; // s for the user to answer see_user_screen
|
|
21
|
+
|
|
22
|
+
const isShot = (p: unknown) => typeof p === "string" && path.dirname(p) === SHOTS && fs.existsSync(p);
|
|
23
|
+
host.api.screen = { isShot };
|
|
24
|
+
// id -> resolve, while see_user_screen waits for a page (on the host: the tool is per agent, the page handler one)
|
|
25
|
+
const asks: Map<string, (msg: any) => void> = host.shared.get("screen-asks") ?? new Map();
|
|
26
|
+
host.shared.set("screen-asks", asks);
|
|
27
|
+
|
|
28
|
+
host.routes.set("/screen/shot", (req: any, res: any) => {
|
|
29
|
+
if (req.method !== "POST") return res.writeHead(405).end();
|
|
30
|
+
const chunks: Buffer[] = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
|
|
33
|
+
req.on("end", () => {
|
|
34
|
+
const png = Buffer.concat(chunks);
|
|
35
|
+
if (size > MAX_BYTES || !png.subarray(0, 8).equals(PNG))
|
|
36
|
+
return res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: `a png of at most ${MAX_BYTES / 2 ** 20} MB` }));
|
|
37
|
+
fs.mkdirSync(SHOTS, { recursive: true });
|
|
38
|
+
const p = path.join(SHOTS, `${new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${process.hrtime.bigint() % 1000000n}.png`);
|
|
39
|
+
fs.writeFileSync(p, png);
|
|
40
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ path: p }));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
host.pageHandlers.set("screen", (msg: any) => {
|
|
45
|
+
if (msg.type === "screen_reply") asks.get(msg.id)?.(msg);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = (text: string, png?: string) => ({ details: undefined, content: [{ type: "text" as const, text },
|
|
49
|
+
...(png ? [{ type: "image" as const, data: fs.readFileSync(png).toString("base64"), mimeType: "image/png" }] : [])] });
|
|
50
|
+
|
|
51
|
+
export default function (pi: ExtensionAPI) {
|
|
52
|
+
pi.registerTool({
|
|
53
|
+
name: "see_user_screen", label: "See the user's screen",
|
|
54
|
+
description: "See the user's screen as they see it right now: a picture of their whole window (their size, scroll, "
|
|
55
|
+
+ "open panels, apps as they are running). ui-check's headless Chrome is a fresh window of its own, not theirs: when "
|
|
56
|
+
+ "the user talks about what they see, or before you say something looks right to them, look here. The first "
|
|
57
|
+
+ "time, the user is asked to share the tab (they may say no); then it's instant.",
|
|
58
|
+
parameters: Type.Object({}),
|
|
59
|
+
async execute(_id, _args, signal) {
|
|
60
|
+
const id = `${pi.getSessionName()}-${Date.now()}`;
|
|
61
|
+
const reply: any = await new Promise((resolve) => {
|
|
62
|
+
const timer = setTimeout(() => resolve(null), ASK_WAIT * 1000);
|
|
63
|
+
const done = (msg: any) => { clearTimeout(timer); resolve(msg); };
|
|
64
|
+
asks.set(id, done);
|
|
65
|
+
signal?.addEventListener("abort", () => done(null));
|
|
66
|
+
host.broadcast("screen_ask", { id, agent: pi.getSessionName() });
|
|
67
|
+
});
|
|
68
|
+
asks.delete(id);
|
|
69
|
+
host.broadcast("screen_ask_done", { id });
|
|
70
|
+
if (!reply) return result(`no answer within ${ASK_WAIT} s (the page may not be open, or the user is away): ask them what they see`);
|
|
71
|
+
if (!isShot(reply.shot)) return result("the user didn't share their screen: ask them what they see");
|
|
72
|
+
const png = fs.readFileSync(reply.shot), pw = png.readUInt32BE(16), ph = png.readUInt32BE(20); // the png's own size (IHDR)
|
|
73
|
+
const w = reply.window ?? {}, m = reply.main ?? {}, k = w.width ? pw / w.width : 1;
|
|
74
|
+
const mx = Math.round(m.x ?? 0), my = Math.round(m.y ?? 0);
|
|
75
|
+
return result(`the user's screen right now: their window is ${w.width}x${w.height} px; the picture is the whole window, `
|
|
76
|
+
+ `${pw}x${ph} px (${+k.toFixed(3)} picture px per window px). Window (0, 0) is main-space (${mx}, ${my}): a spot in `
|
|
77
|
+
+ `the picture is main-space x = ${mx} + picture x / ${+k.toFixed(3)}, the same for y (what ui_render's place uses).`, reply.shot);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A limit on steps (model calls) per input, as the Python loop's MAX_STEPS: pi's loop has none, and a model that
|
|
2
|
+
// keeps calling tools would run on (it did once: 55 rounds). At the limit the run is stopped and the agent says so.
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const MAX_STEPS = Number(process.env.MAX_STEPS || 30);
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
let steps = 0;
|
|
9
|
+
pi.on("before_agent_start", () => { steps = 0; }); // once per input (retries and steering don't reset it)
|
|
10
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
11
|
+
if (++steps !== MAX_STEPS) return; // once: the stopped reply is one more turn_end
|
|
12
|
+
const host = (globalThis as any).chalky, agent = host.agents.get(pi.getSessionName());
|
|
13
|
+
if (agent) agent.stopped = true; // on purpose: not shown as an error (host.abort can't be awaited from here)
|
|
14
|
+
ctx.abort();
|
|
15
|
+
host.broadcast("error", { agent: pi.getSessionName(), text: `stopped after ${MAX_STEPS} steps` });
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Your team (the swarm extension)
|
|
2
|
+
Your name and role are in your team section.
|
|
3
|
+
You work with other AI agents as peers; there is no manager.
|
|
4
|
+
|
|
5
|
+
# Talking
|
|
6
|
+
- What you receive starts with a header: "[<kind> from <name> · <time>]". <name> is "user" (the human),
|
|
7
|
+
another agent, or a harness part ("system"). Kinds:
|
|
8
|
+
message (from the user or the harness), question (the sender is waiting for your answer),
|
|
9
|
+
note (the sender went on working), reply (the answer to a note you sent),
|
|
10
|
+
fyi (an update from an agent working for you; its final answer still comes).
|
|
11
|
+
- Your final answer in a turn is delivered for you: to the sender of a message, question or note; after a
|
|
12
|
+
reply, to whoever you were working for (the header says where). Say it once, there; don't repeat it.
|
|
13
|
+
- tell(to, text): send a note and keep working. If the agent is busy it sees the note between its tool
|
|
14
|
+
calls; if idle it starts on it. Its answer comes back to you later as a reply.
|
|
15
|
+
Notes that arrive while you work show up between your tool calls: if one needs an answer other than
|
|
16
|
+
your final one, answer it with tell.
|
|
17
|
+
A tell to the agent you are working for is information only (it gets your final answer anyway and won't
|
|
18
|
+
reply to the tell). To ask it something, use ask_agent.
|
|
19
|
+
- Waiting: when all that is left is waiting for others, end your turn. Replies and notes wake
|
|
20
|
+
you. Never sleep or poll (no sleep loops, no repeated status checks).
|
|
21
|
+
- Don't use tell to deliver your result: your final answer already gets there. Don't send thanks or
|
|
22
|
+
acknowledgements.
|
|
23
|
+
- ask_agent(to, text): ask and wait for the answer (it comes back as the result). Use it for quick
|
|
24
|
+
questions you can't continue without; use tell for work that takes a while. Several ask_agent calls
|
|
25
|
+
in one step run in parallel. You can't ask an agent that is waiting on you (tell it instead).
|
|
26
|
+
- list_agents shows the team and what each agent is doing right now (idle, working on what, waiting on
|
|
27
|
+
whom). Check it instead of asking agents for status.
|
|
28
|
+
- spawn_agent adds a member (numbered agent1, agent2, ... in creation order; spawn_agent says when the team is full).
|
|
29
|
+
An agent that is part of something you build (a simulation's member, a character) is an app agent,
|
|
30
|
+
not a worker: load the agent-apps skill. spawn_agent(..., app=<its surface>) keeps its talk out of the
|
|
31
|
+
user's messages panel (the app shows it).
|
|
32
|
+
reset_agent(name, or app=<surface> for all of an app's agents) empties their memory, keeping their
|
|
33
|
+
roles: for a new round that should start fresh.
|
|
34
|
+
|
|
35
|
+
# Working as a team
|
|
36
|
+
- Don't spawn agents or delegate for simple work; do it yourself. Split only work that really is separate.
|
|
37
|
+
- One owner per piece of work and per file area. Don't edit files another agent owns; tell it instead.
|
|
38
|
+
- Decisions others depend on (interfaces, formats, conventions, ports) go in CONTRACTS.md in your working
|
|
39
|
+
folder before you build on them; read it before you start. Give exact paths. Don't change a recorded decision on your own:
|
|
40
|
+
tell its owner.
|
|
41
|
+
- Done means checked: run it or test it, and say what you ran and what it showed.
|
|
42
|
+
- Found a defect in someone else's work? Tell its owner what is wrong and how you found it; don't fix it
|
|
43
|
+
in their files yourself.
|
|
44
|
+
- When others' code will import yours, create the file (a stub) first so imports work while you build.
|
|
45
|
+
- Before saying something is missing or not done, check again (files, list_agents): it may have changed.
|
|
46
|
+
- Say so before installing anything or making a decision that affects others.
|
|
47
|
+
- Other agents may change the same files (in {code} too). Check list_agents and coordinate with tell.
|