@chalky/chalky 0.1.5 → 0.1.7
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 +8 -2
- package/agent/SYSTEM.md +2 -1
- package/agent/extensions/app/README.md +15 -11
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/agent/extensions/board/browser.js +147 -10
- package/agent/extensions/card/browser.js +49 -6
- package/agent/extensions/card/index.ts +22 -19
- package/agent/extensions/doodle/README.md +1 -1
- package/agent/extensions/doodle/browser.js +20 -18
- package/agent/extensions/file/browser.js +46 -7
- package/agent/extensions/file/index.ts +3 -2
- package/agent/extensions/frame/README.md +2 -2
- package/agent/extensions/frame/browser.js +5 -3
- package/agent/extensions/frame/index.ts +16 -7
- package/agent/extensions/history/index.ts +3 -1
- package/agent/extensions/note/README.md +3 -1
- package/agent/extensions/note/browser.js +52 -23
- package/agent/extensions/note/guide.md +2 -1
- package/agent/extensions/note/index.ts +36 -13
- package/agent/extensions/page/browser.js +453 -0
- package/agent/extensions/page/index.ts +70 -1
- package/agent/extensions/page/page.html +48 -1
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/screen/browser.js +8 -21
- package/agent/extensions/screen/index.ts +7 -5
- package/agent/extensions/version/browser.js +12 -3
- package/agent/extensions/version/index.ts +73 -21
- package/agent/skills/design/SKILL.md +1 -1
- package/agent/skills/show-me/SKILL.md +66 -0
- package/package.json +2 -1
- package/server.ts +169 -7
|
@@ -175,13 +175,14 @@ export default async function (page) {
|
|
|
175
175
|
const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
|
|
176
176
|
|
|
177
177
|
// ---- where things are ----
|
|
178
|
+
const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js): window px per main px (and per app-frame px)
|
|
178
179
|
const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
|
|
179
|
-
const m = area.getBoundingClientRect();
|
|
180
|
-
return { x: x - m.left + area.scrollLeft, y: y - m.top + area.scrollTop };
|
|
180
|
+
const m = area.getBoundingClientRect(), z = zoom();
|
|
181
|
+
return { x: (x - m.left) / z + area.scrollLeft, y: (y - m.top) / z + area.scrollTop };
|
|
181
182
|
};
|
|
182
183
|
const inWindow = (p) => {
|
|
183
|
-
const m = area.getBoundingClientRect();
|
|
184
|
-
return { x:
|
|
184
|
+
const m = area.getBoundingClientRect(), z = zoom();
|
|
185
|
+
return { x: m.left + (p.x - area.scrollLeft) * z, y: m.top + (p.y - area.scrollTop) * z };
|
|
185
186
|
};
|
|
186
187
|
const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
|
|
187
188
|
|
|
@@ -266,7 +267,7 @@ export default async function (page) {
|
|
|
266
267
|
const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
|
|
267
268
|
const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
|
|
268
269
|
board: Number(document.body.dataset.board) || 1, // the board shown (board/)
|
|
269
|
-
area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
|
|
270
|
+
area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w / zoom()), height: Math.round(box.h / zoom()) } };
|
|
270
271
|
step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
|
|
271
272
|
steps.push(step);
|
|
272
273
|
for (const m of marks) m.el.remove();
|
|
@@ -303,10 +304,10 @@ export default async function (page) {
|
|
|
303
304
|
doc.head.append(s);
|
|
304
305
|
classes(doc.documentElement);
|
|
305
306
|
}
|
|
306
|
-
const offset = () => {
|
|
307
|
-
if (!frameEl) return { x: 0, y: 0 };
|
|
308
|
-
const r = frameEl.getBoundingClientRect();
|
|
309
|
-
return { x: r.left + frameEl.clientLeft, y: r.top + frameEl.clientTop };
|
|
307
|
+
const offset = () => { // an app frame's (0, 0) in window px, and window px per frame px (k: zoomed with the main space)
|
|
308
|
+
if (!frameEl) return { x: 0, y: 0, k: 1 };
|
|
309
|
+
const r = frameEl.getBoundingClientRect(), k = zoom();
|
|
310
|
+
return { x: r.left + frameEl.clientLeft * k, y: r.top + frameEl.clientTop * k, k };
|
|
310
311
|
};
|
|
311
312
|
const root = frameEl ? doc : area;
|
|
312
313
|
root.addEventListener("pointerdown", (e) => {
|
|
@@ -316,16 +317,16 @@ export default async function (page) {
|
|
|
316
317
|
if (erasing) { // the press is for the eraser, not for what's under it
|
|
317
318
|
e.preventDefault();
|
|
318
319
|
press = { erase: true, win };
|
|
319
|
-
eraseAt(inMain(e.clientX
|
|
320
|
+
eraseAt(inMain(o.x + e.clientX * o.k, o.y + e.clientY * o.k));
|
|
320
321
|
return;
|
|
321
322
|
}
|
|
322
323
|
grabbed = marks.length ? frame() : null; // before the click acts
|
|
323
324
|
if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
|
|
324
325
|
if (texting) e.preventDefault(); // the press is for the words (or a line), not for what's under it
|
|
325
|
-
press = { x: e.clientX
|
|
326
|
+
press = { x: o.x + e.clientX * o.k, y: o.y + e.clientY * o.k, id: e.pointerId, target: e.target, win, o, text: texting };
|
|
326
327
|
}, true);
|
|
327
328
|
win.addEventListener("pointermove", (e) => {
|
|
328
|
-
const o = offset(), x = e.clientX
|
|
329
|
+
const o = offset(), x = o.x + e.clientX * o.k, y = o.y + e.clientY * o.k;
|
|
329
330
|
over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
|
|
330
331
|
if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
|
|
331
332
|
if (!press || press.win !== win || !drawing) return;
|
|
@@ -495,7 +496,7 @@ export default async function (page) {
|
|
|
495
496
|
hideAsk();
|
|
496
497
|
const last = marks.at(-1).points.at(-1), to = document.getElementById("to")?.value || "agent1";
|
|
497
498
|
ask = Object.assign(document.createElement("form"), { className: "doodle-ask" });
|
|
498
|
-
const m = area.getBoundingClientRect(), room = inMain(m.left + area.clientWidth - 316, m.bottom - 140); // inside the view (not under the scrollbar)
|
|
499
|
+
const m = area.getBoundingClientRect(), z = zoom(), room = inMain(m.left + (area.clientWidth - 316) * z, m.bottom - 140 * z); // inside the view (not under the scrollbar)
|
|
499
500
|
Object.assign(ask.style, { left: `${Math.min(last.x + 16, room.x)}px`, top: `${Math.min(last.y + 12, room.y)}px` });
|
|
500
501
|
const field = Object.assign(document.createElement("input"), { placeholder: `Say something… (Enter sends to ${to})`, autocomplete: "off" });
|
|
501
502
|
field.setAttribute("aria-label", "Words to send with your marks");
|
|
@@ -527,10 +528,11 @@ export default async function (page) {
|
|
|
527
528
|
const distBox = (b, q) => Math.hypot(Math.max(b.x - q.x, 0, q.x - b.x - b.w), Math.max(b.y - q.y, 0, q.y - b.y - b.h));
|
|
528
529
|
const inPoly = (pts, q) => { let c = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const a = pts[i], b = pts[j];
|
|
529
530
|
if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) c = !c; } return c; };
|
|
530
|
-
const frameOffset = (fr) => { const r = fr.getBoundingClientRect(); return { x: r.left + fr.clientLeft, y: r.top + fr.clientTop }; };
|
|
531
|
-
function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in
|
|
532
|
-
const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0 }, p = inMain(r.left
|
|
533
|
-
|
|
531
|
+
const frameOffset = (fr) => { const r = fr.getBoundingClientRect(), k = zoom(); return { x: r.left + fr.clientLeft * k, y: r.top + fr.clientTop * k, k }; };
|
|
532
|
+
function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in (its px: main px)
|
|
533
|
+
const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0, k: 1 }, p = inMain(o.x + r.left * o.k, o.y + r.top * o.k);
|
|
534
|
+
const s = fr ? 1 : 1 / zoom(); // window px -> main px
|
|
535
|
+
return { x: p.x, y: p.y, w: r.width * s, h: r.height * s };
|
|
534
536
|
}
|
|
535
537
|
const scopes = (el) => [{ root: el, fr: null }, ...[...(el.localName === "iframe" ? [el] : el.querySelectorAll("iframe"))].flatMap((fr) => {
|
|
536
538
|
try { return fr.contentDocument?.body ? [{ root: fr.contentDocument.body, fr }] : []; } catch { return []; } // other origins: not ours
|
|
@@ -671,7 +673,7 @@ export default async function (page) {
|
|
|
671
673
|
if (under && !area.contains(under) && under !== document.documentElement && under !== document.body)
|
|
672
674
|
return { box: { x, y, w, h }, board: Number(board()), fixed: true };
|
|
673
675
|
const p = inMain(x, y);
|
|
674
|
-
return { box: { x: p.x, y: p.y, w, h }, board: Number(board()) };
|
|
676
|
+
return { box: { x: p.x, y: p.y, w: w / zoom(), h: h / zoom() }, board: Number(board()) };
|
|
675
677
|
}
|
|
676
678
|
function findMain(t, every) {
|
|
677
679
|
if (t.mark) {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Files dragged from the computer onto the page: uploaded (POST /file/upload), then a "file-dropped"
|
|
4
4
|
// DOM event {x, y, files: [{name, path, size}]} at the drop spot (the note extension opens its note box
|
|
5
5
|
// there). Dropped onto the panel itself: only stored.
|
|
6
|
+
// Pasted (Ctrl+V / ⌘V) with files on the clipboard: the same as a drop where the pointer last was (copied
|
|
7
|
+
// images, all named image.png, become pasted-<date>-<time>.png). Text pastes as usual.
|
|
6
8
|
// A row dragged out of the panel onto the page: the same event, with that file.
|
|
7
9
|
// A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
|
|
8
10
|
// {file, row} (the viewer extension opens a preview window attached to the row; rows carry data-path).
|
|
@@ -42,7 +44,11 @@ export default async function (page) {
|
|
|
42
44
|
#side .file-bar button { font: inherit; color: var(--text); background: var(--glass-strong); border: 1px solid var(--line);
|
|
43
45
|
border-radius: 8px; padding: 3px 10px; cursor: pointer; }
|
|
44
46
|
#side .file-bar .delete { color: #fff; background: var(--danger); border-color: transparent; }
|
|
45
|
-
#side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
|
|
47
|
+
#side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
|
|
48
|
+
.file-uploading { position: fixed; z-index: 200000; max-width: 240px; padding: 5px 10px; border-radius: 10px; font-size: 12px;
|
|
49
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
|
|
50
|
+
background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
51
|
+
border: 1px solid var(--edge); box-shadow: var(--shadow); }`;
|
|
46
52
|
document.head.append(style);
|
|
47
53
|
|
|
48
54
|
const toggle = document.createElement("button");
|
|
@@ -129,15 +135,48 @@ export default async function (page) {
|
|
|
129
135
|
side.removeAttribute("data-over");
|
|
130
136
|
const { clientX: x, clientY: y } = e;
|
|
131
137
|
if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
store([...e.dataTransfer.files], x, y, onPanel(e));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Upload files (at most MAX), then preview the first and fire "file-dropped" at (x, y), unless they landed on the panel
|
|
142
|
+
const MAX = 10;
|
|
143
|
+
async function store(all, x, y, panel, names = all.map((f) => f.name)) {
|
|
144
|
+
if (all.length > MAX) page.notice([`You added ${all.length} files: only the first ${MAX} were kept.`]);
|
|
145
|
+
const stored = [], files = all.slice(0, MAX), size = files.reduce((n, f) => n + f.size, 0);
|
|
146
|
+
const tag = Object.assign(document.createElement("div"), { className: "file-uploading",
|
|
147
|
+
textContent: `Uploading ${files.length > 1 ? `${files.length} files` : names[0]} · ${Math.ceil(size / 1024)} KB` });
|
|
148
|
+
Object.assign(tag.style, { left: `${Math.min(x + 14, innerWidth - 260)}px`, top: `${Math.min(y + 16, innerHeight - 40)}px` });
|
|
149
|
+
const slow = setTimeout(() => document.body.append(tag), 500); // only when it takes a while
|
|
150
|
+
for (const [i, f] of files.entries()) {
|
|
134
151
|
try {
|
|
135
|
-
const out = await (await fetch(`/file/upload?name=${encodeURIComponent(
|
|
152
|
+
const out = await (await fetch(`/file/upload?name=${encodeURIComponent(names[i])}`, { method: "POST", body: f })).json();
|
|
136
153
|
if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
|
|
137
|
-
else page.
|
|
138
|
-
} catch (err) { page.
|
|
154
|
+
else page.notice([`Could not upload ${names[i]}: ${out.error}`]);
|
|
155
|
+
} catch (err) { page.notice([`Could not upload ${names[i]}: ${err}`]); }
|
|
139
156
|
}
|
|
157
|
+
clearTimeout(slow); tag.remove();
|
|
140
158
|
if (stored.length) opened(stored[0]); // the list has its row by now (the "file" event comes before the reply)
|
|
141
|
-
if (stored.length && !
|
|
159
|
+
if (stored.length && !panel) dropped(x, y, stored);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Paste (Ctrl+V / ⌘V): files on the clipboard are added as if dropped where the pointer last was.
|
|
163
|
+
// Text pastes as usual. A paste inside a card's app goes to that app (its own page): Chalky never hears it.
|
|
164
|
+
let pointer = { x: innerWidth / 2, y: innerHeight / 2 };
|
|
165
|
+
document.addEventListener("pointermove", (e) => { pointer = { x: e.clientX, y: e.clientY }; }, { passive: true });
|
|
166
|
+
document.addEventListener("paste", (e) => {
|
|
167
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
168
|
+
if (!files.length) { // a Linux file manager copies the file's address, not the file
|
|
169
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
170
|
+
const typing = e.target.closest?.("input, textarea, [contenteditable]");
|
|
171
|
+
if (!typing && /^file:\/\//.test(text.trim())) page.notice(["You copied a file's address, not the file. Drag the file onto the page instead."]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
const d = new Date(), p = (n) => String(n).padStart(2, "0");
|
|
176
|
+
const stamp = `pasted-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
177
|
+
let n = 0; // every copied image arrives as image.png
|
|
178
|
+
const names = files.map((f) => (!f.name || f.name === "image.png") ? `${stamp}${n++ ? `-${n}` : ""}.${f.type.split("/")[1] || "png"}` : f.name);
|
|
179
|
+
const { x, y } = pointer;
|
|
180
|
+
store(files, x, y, side.contains(document.elementFromPoint(x, y)), names);
|
|
142
181
|
});
|
|
143
182
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// the page (from the computer: uploaded here, listed, and a "file-dropped" DOM event so note's browser.js opens its note
|
|
4
4
|
// box there; onto the panel itself: only stored; a row from the panel: the same event with that file).
|
|
5
5
|
// The agent always knows the folder (its prompt section); files it saves there show in the panel too (the list is
|
|
6
|
-
// sent again
|
|
6
|
+
// sent again after each of its tool calls, pi's tool_execution_end, if it changed: a file it saves shows at once,
|
|
7
|
+
// while it is still working).
|
|
7
8
|
// POST /file/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
|
|
8
9
|
// GET /file [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
|
|
9
10
|
// POST /file/delete {names: [...]} (names as listed): deleted for good, returns {deleted, errors}
|
|
@@ -37,7 +38,7 @@ function changed(always = false) { // tell the pages, if the list is not what t
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export default function (pi: ExtensionAPI) {
|
|
40
|
-
pi.on("
|
|
41
|
+
pi.on("tool_execution_end", () => changed()); // the agent may have saved files there
|
|
41
42
|
if (!host.shared.has("file-last")) host.shared.set("file-last", JSON.stringify(listing()));
|
|
42
43
|
|
|
43
44
|
host.routes.set("/file", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
|
|
@@ -24,8 +24,8 @@ one design, not separate requests: they share one look, and often each other's d
|
|
|
24
24
|
on the page, with no agent in between. Separate block cards with {"ref"} props are for a frame that only
|
|
25
25
|
shows another's data (refs only read). Don't change the page's own code (page.html, the browser.js files) to
|
|
26
26
|
link frames.
|
|
27
|
-
- Fill a frame with card_show(
|
|
28
|
-
card appears. Keep the place: the user chose where and how big.
|
|
27
|
+
- Fill a frame with card_show(place=<its place>, ...), with any card id (a name for what it is): its
|
|
28
|
+
dashed box goes when a card appears exactly there. Keep the place: the user chose where and how big.
|
|
29
29
|
- Split the work when it pays: several frames that are each real work and don't depend on each other can
|
|
30
30
|
go to helpers in parallel (team_spawn, or idle agents you have): tell each one its frame, and what done
|
|
31
31
|
means: "card <id> shown at <place>, doing <their words>". Say the shared look in each message
|
|
@@ -71,9 +71,10 @@ export default async function (page) {
|
|
|
71
71
|
const board = () => Number(document.body.dataset.board) || 1; // the board shown (board/): frames are drawn and sent there
|
|
72
72
|
document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
|
|
73
73
|
|
|
74
|
+
const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js)
|
|
74
75
|
const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
|
|
75
|
-
const m = area.getBoundingClientRect();
|
|
76
|
-
return { x: Math.round(x - m.left + area.scrollLeft), y: Math.round(y - m.top + area.scrollTop) };
|
|
76
|
+
const m = area.getBoundingClientRect(), z = zoom();
|
|
77
|
+
return { x: Math.round((x - m.left) / z + area.scrollLeft), y: Math.round((y - m.top) / z + area.scrollTop) };
|
|
77
78
|
};
|
|
78
79
|
const put = (box, f) => Object.assign(box.style, { left: `${f.x}px`, top: `${f.y}px`, width: `${f.width}px`, height: `${f.height}px` });
|
|
79
80
|
|
|
@@ -154,8 +155,9 @@ export default async function (page) {
|
|
|
154
155
|
const corner = e.currentTarget, x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
|
|
155
156
|
corner.setPointerCapture(e.pointerId);
|
|
156
157
|
let place = null;
|
|
158
|
+
const z = zoom(); // window px moved -> main px
|
|
157
159
|
corner.onpointermove = (ev) => {
|
|
158
|
-
place = { x, y, width: Math.max(40, Math.round(w0 + ev.clientX - e.clientX)), height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
|
|
160
|
+
place = { x, y, width: Math.max(40, Math.round(w0 + (ev.clientX - e.clientX) / z)), height: Math.max(30, Math.round(h0 + (ev.clientY - e.clientY) / z)) };
|
|
159
161
|
put(box, place);
|
|
160
162
|
};
|
|
161
163
|
corner.onpointerup = () => {
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
|
|
4
4
|
// A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where card_show's place puts a card;
|
|
5
5
|
// board: the one it was drawn on, board/; Implement sends the frames of the board shown), with an
|
|
6
|
-
// id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(
|
|
7
|
-
// when
|
|
6
|
+
// id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(place=<its place>) (any card id);
|
|
7
|
+
// when a card appears at its place (or with its id) (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
|
|
8
8
|
// can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
|
|
9
9
|
// GET /frame {id: frame}
|
|
10
10
|
// page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
|
|
11
|
-
// frame_remove {id}, frame_send {to, board}
|
|
11
|
+
// frame_remove {id}, frame_send {to, board, by?} (by: the agent whose code pressed Implement)
|
|
12
12
|
// event "frame" {frames: {id: frame}} to every open page, when they changed
|
|
13
13
|
// Saved in data/frame.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
|
|
14
14
|
import fs from "node:fs";
|
|
@@ -32,10 +32,17 @@ const place = (f: any) => JSON.stringify({ x: f.x, y: f.y, width: f.width, heigh
|
|
|
32
32
|
|
|
33
33
|
export default function (pi: ExtensionAPI) {
|
|
34
34
|
|
|
35
|
-
// a frame is done when a card with its id
|
|
35
|
+
// a frame is done when a card appears at its place on its board, or with its id (after all extensions loaded: the
|
|
36
|
+
// card one may come later). The place is what the user chose; agents often give the card a name of its own.
|
|
36
37
|
pi.on("session_start", () => {
|
|
37
38
|
host.api.card?.watch("frame", (sid: string) => {
|
|
38
|
-
|
|
39
|
+
const card = host.shared.get("card")?.cards[sid];
|
|
40
|
+
if (!card) return;
|
|
41
|
+
const at = (f: any) => card.place && (card.board ?? 1) === (f.board ?? 1)
|
|
42
|
+
&& ["x", "y", "width", "height"].every((k) => Math.round(card.place[k]) === f[k]);
|
|
43
|
+
const done = Object.keys(frames).filter((id) => id === sid || at(frames[id]));
|
|
44
|
+
for (const id of done) delete frames[id];
|
|
45
|
+
if (done.length) changed();
|
|
39
46
|
});
|
|
40
47
|
host.api.board?.onRemove("frame", (n: number) => { // a removed board (board/) takes its frames with it
|
|
41
48
|
const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
|
|
@@ -70,9 +77,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
77
|
if (!ready.length) return;
|
|
71
78
|
for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
|
|
72
79
|
changed();
|
|
73
|
-
|
|
80
|
+
// by: an agent's code pressed Implement (page/browser.js), not the user
|
|
81
|
+
const who = !msg.by ? "the user laid out" : msg.by === to ? "you pressed Implement for" : `${msg.by} pressed Implement for`;
|
|
82
|
+
const lines = [`[frames on the page] ${who} ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
|
|
74
83
|
...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
|
|
75
|
-
` Fill each with card_show(
|
|
84
|
+
` Fill each with card_show(place=<its place>, board=${board}, ...) (any card id): its dashed box goes when a card appears there.`];
|
|
76
85
|
const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
|
|
77
86
|
if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
|
|
78
87
|
team.send("user", to, lines.join("\n"));
|
|
@@ -88,7 +88,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
88
88
|
pi.on("session_start", () => {
|
|
89
89
|
if (!host.shared.has("history")) host.shared.set("history", new History(host.shared.get("card")));
|
|
90
90
|
host.api.history = host.shared.get("history");
|
|
91
|
-
host.api.card?.watch("history", (_id: string, by?: string, what?: string) => {
|
|
91
|
+
host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { // the card's words don't say who
|
|
92
|
+
if (what) host.api.history.add(by, `${by === "user" ? "you" : by} ${what}`);
|
|
93
|
+
});
|
|
92
94
|
});
|
|
93
95
|
const shared = (): History | undefined => host.shared.get("history");
|
|
94
96
|
|
|
@@ -32,4 +32,6 @@ thing has partAt), its text, and the file it comes from. Act on that exact thing
|
|
|
32
32
|
pointer ("this one") use the pen (doodle_mark) instead.
|
|
33
33
|
- When you did what a note asked, note_done(id): its pin turns into a faded ✓, which the user can reopen or delete. If your
|
|
34
34
|
answer leaves anything to them (you suggest something, ask, or offer a choice), leave the note open: their reply
|
|
35
|
-
comes in the same thread, and you close it once that is settled. The user can close one too (✓
|
|
35
|
+
comes in the same thread, and you close it once that is settled. The user can close one too (✓) or delete it (✕);
|
|
36
|
+
you get an fyi. A note you started that is no longer needed (it explained a step, the step is past) can go for
|
|
37
|
+
good: note_done(id, delete: true). Notes the user or another agent started you only close.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The page side of the note extension. A
|
|
2
|
-
// layer over the page; the cursor is a
|
|
1
|
+
// The page side of the note extension. A speech-bubble button in the tool palette (top) turns on a clear
|
|
2
|
+
// layer over the page; the cursor is a speech bubble, blue over something a click would point at.
|
|
3
3
|
// Click: a note box opens there. Hold the button and draw: on release the note box opens, and the note
|
|
4
4
|
// covers the drawn area (its bounding box, in main-space px: where card_show's place puts a card).
|
|
5
5
|
// The user sees only the box; the target goes to the agent with the note: card and block (from
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// tells every open page to pin it, with its drawing). Esc or the button turns it off.
|
|
10
10
|
// Pins: a numbered pin per note (the agents' in their colour), on the thing it is on, scrolling with the main space
|
|
11
11
|
// and moving with its card. Click one for its thread: who wrote what, a reply box (Enter sends it to the note's
|
|
12
|
-
// agent), ✓
|
|
12
|
+
// agent), ✓ (close) and ✕ (delete) at its top right; a closed note stays as a faded ✓ (Reopen). ✕ asks nothing: the pin
|
|
13
|
+
// goes, a notice offers Undo for a few seconds, and only then is the delete sent. An agent's new note, or its reply, opens the bubble while
|
|
13
14
|
// the note stays open. Show marks (doodle/)
|
|
14
15
|
// hides the pins with the other marks.
|
|
15
16
|
// The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
|
|
@@ -27,11 +28,11 @@ export default async function (page) {
|
|
|
27
28
|
|
|
28
29
|
const layer = document.createElement("div"); // catches clicks, so the page underneath doesn't react
|
|
29
30
|
layer.style.cssText = "position:fixed;inset:0;z-index:9998;display:none";
|
|
30
|
-
// The cursor: the button's
|
|
31
|
+
// The cursor: the button's speech bubble (white edge, so it shows on dark too); its tail's tip (2, 22) is the spot pointed at.
|
|
32
|
+
const BUBBLE = "M7.9 20A9 9 0 1 0 4 16.1L2 22z";
|
|
31
33
|
const pen = (color) => `url("data:image/svg+xml,${encodeURIComponent(
|
|
32
34
|
`<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">` +
|
|
33
|
-
`<
|
|
34
|
-
`<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`;
|
|
35
|
+
`<path d="${BUBBLE}" stroke="#fff" stroke-width="4"/><path d="${BUBBLE}" stroke="${color}" stroke-width="2"/></svg>`)}") 2 22, crosshair`;
|
|
35
36
|
const PEN = pen("#6e6e73"), PEN_ON = pen("#0071e3"); // grey: empty space; blue: over a thing
|
|
36
37
|
layer.style.cursor = PEN;
|
|
37
38
|
const input = document.createElement("input"); // the note box, opened where the user clicked
|
|
@@ -59,7 +60,7 @@ export default async function (page) {
|
|
|
59
60
|
document.body.append(layer, ink, input);
|
|
60
61
|
|
|
61
62
|
const button = document.createElement("button");
|
|
62
|
-
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="
|
|
63
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${BUBBLE}"/></svg>`; // a speech bubble
|
|
63
64
|
button.title = "Point: click anything, or hold and draw around an area, then write a note (Esc to stop)";
|
|
64
65
|
button.dataset.name = "note"; // names an agent's pen can point at
|
|
65
66
|
page.slot("status").prepend(button); // the first tool in the palette, whichever script loads first
|
|
@@ -78,8 +79,13 @@ export default async function (page) {
|
|
|
78
79
|
[...e.classList].slice(0, 2).map((c) => `.${c}`).join("");
|
|
79
80
|
|
|
80
81
|
function inMain(x, y) { // window px -> main-space px (the main space scrolls; card_show's place uses these)
|
|
81
|
-
const main = page.slot("top"), m = main.getBoundingClientRect();
|
|
82
|
-
return { x: Math.round(x - m.left + main.scrollLeft), y: Math.round(y - m.top + main.scrollTop) };
|
|
82
|
+
const main = page.slot("top"), m = main.getBoundingClientRect(), z = zoom(); // the board's zoom (board/browser.js)
|
|
83
|
+
return { x: Math.round((x - m.left) / z + main.scrollLeft), y: Math.round((y - m.top) / z + main.scrollTop) };
|
|
84
|
+
}
|
|
85
|
+
function zoom() { return parseFloat(page.slot("top").style.zoom) || 1; } // window px per main px (and per app-frame px)
|
|
86
|
+
function inFrame(fr, x, y) { // window px -> px in an app's frame (zoomed with the main space)
|
|
87
|
+
const r = fr.getBoundingClientRect(), z = zoom();
|
|
88
|
+
return [(x - r.left) / z - fr.clientLeft, (y - r.top) / z - fr.clientTop];
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
function target(x, y) {
|
|
@@ -88,9 +94,9 @@ export default async function (page) {
|
|
|
88
94
|
let inner = null, frame = null, box = el.getBoundingClientRect();
|
|
89
95
|
if (el.localName === "iframe") { // look inside the frame (an App): same origin, so its document is readable
|
|
90
96
|
try {
|
|
91
|
-
const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
|
|
92
|
-
inner = el.contentDocument?.elementFromPoint(x
|
|
93
|
-
if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(b.x
|
|
97
|
+
const r = el.getBoundingClientRect(), z = zoom(), dx = r.left + el.clientLeft * z, dy = r.top + el.clientTop * z;
|
|
98
|
+
inner = el.contentDocument?.elementFromPoint(...inFrame(el, x, y));
|
|
99
|
+
if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(dx + b.x * z, dy + b.y * z, b.width * z, b.height * z); }
|
|
94
100
|
} catch {}
|
|
95
101
|
}
|
|
96
102
|
const hitEl = inner || el;
|
|
@@ -109,8 +115,7 @@ export default async function (page) {
|
|
|
109
115
|
let part = null; // what the thing itself says is at this spot (partAt), e.g. {"name": "enemy-2"}
|
|
110
116
|
try {
|
|
111
117
|
if (frame?.contentWindow?.partAt) {
|
|
112
|
-
|
|
113
|
-
part = frame.contentWindow.partAt(x - r.left - frame.clientLeft, y - r.top - frame.clientTop);
|
|
118
|
+
part = frame.contentWindow.partAt(...inFrame(frame, x, y));
|
|
114
119
|
} else {
|
|
115
120
|
const owner = [hitEl, ...[...document.elementsFromPoint(x, y)]].find((e) => typeof e?.partAt === "function");
|
|
116
121
|
if (owner) { const r = owner.getBoundingClientRect(); part = owner.partAt(x - r.left, y - r.top); }
|
|
@@ -187,8 +192,7 @@ export default async function (page) {
|
|
|
187
192
|
const el = document.elementsFromPoint(x, y).find((e) => e !== layer && e !== ink);
|
|
188
193
|
if (el?.localName === "iframe") {
|
|
189
194
|
try {
|
|
190
|
-
|
|
191
|
-
if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => [px - dx, py - dy]));
|
|
195
|
+
if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => inFrame(el, px, py)));
|
|
192
196
|
} catch (err) { console.warn("partIn:", err); }
|
|
193
197
|
}
|
|
194
198
|
const owner = document.elementsFromPoint(x, y).find((e) => typeof e.partIn === "function");
|
|
@@ -203,7 +207,7 @@ export default async function (page) {
|
|
|
203
207
|
const left = Math.min(...xs), top = Math.min(...ys), width = Math.max(...xs) - left, height = Math.max(...ys) - top;
|
|
204
208
|
if (width < 12 && height < 12) return openNote(target(e.clientX, e.clientY), e.clientX, e.clientY);
|
|
205
209
|
const t = target(left + width / 2, top + height / 2); // what is under the middle of the drawing
|
|
206
|
-
if (t) t.area = { ...inMain(left, top), width: Math.round(width), height: Math.round(height) };
|
|
210
|
+
if (t) t.area = { ...inMain(left, top), width: Math.round(width / zoom()), height: Math.round(height / zoom()) };
|
|
207
211
|
const inside = t && (await partIn(left + width / 2, top + height / 2, points));
|
|
208
212
|
if (inside) t.part = inside;
|
|
209
213
|
openNote(t, e.clientX, e.clientY, points);
|
|
@@ -246,6 +250,9 @@ export default async function (page) {
|
|
|
246
250
|
.note-pin .bubble .busy { color: var(--muted, #888); font-style: italic; }
|
|
247
251
|
.note-pin .bubble textarea { width: 100%; box-sizing: border-box; min-height: 48px; resize: vertical; font: inherit; padding: 6px 8px;
|
|
248
252
|
border-radius: 8px; border: 1px solid rgba(0, 0, 0, .15); outline: none; background: rgba(255, 255, 255, .8); }
|
|
253
|
+
.note-pin .bubble .top { float: right; display: flex; gap: 2px; margin: -4px -6px 0 6px; }
|
|
254
|
+
.note-pin .bubble .top button { width: 24px; height: 24px; border-radius: 6px; font-size: 13px; color: var(--soft, #555); }
|
|
255
|
+
.note-pin .bubble .top button:hover { background: var(--hover, rgba(0, 0, 0, .05)); color: var(--text, #1d1d1f); }
|
|
249
256
|
.note-pin .bubble .acts { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
|
250
257
|
.note-pin .bubble .acts button { padding: 3px 10px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(0, 0, 0, .12); }
|
|
251
258
|
.note-pin .bubble .acts button.primary { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
|
|
@@ -262,7 +269,21 @@ export default async function (page) {
|
|
|
262
269
|
if (card) { const r = card.getBoundingClientRect(), c = inMain(r.left, r.top); return { x: c.x + n.anchor.dx, y: c.y + n.anchor.dy }; }
|
|
263
270
|
return n.at ?? { x: 0, y: 0 };
|
|
264
271
|
}
|
|
272
|
+
// Deleting: the pin goes at once, and a notice offers Undo for UNDO_MS; only then is the delete sent (so an undo
|
|
273
|
+
// needs nothing from the server, and other pages keep the note until it is really gone)
|
|
274
|
+
const UNDO_MS = 6000;
|
|
275
|
+
const leaving = {}; // id -> timer, while its undo is offered
|
|
276
|
+
function remove(id) {
|
|
277
|
+
openId = null;
|
|
278
|
+
pins[id]?.remove(); delete pins[id];
|
|
279
|
+
const undo = Object.assign(document.createElement("button"), { type: "button", className: "go", textContent: "Undo" });
|
|
280
|
+
const notice = page.notice(["Note deleted", undo], () => {});
|
|
281
|
+
const gone = () => { delete leaving[id]; notice.close(); page.send({ type: "note_delete", id }); };
|
|
282
|
+
leaving[id] = setTimeout(gone, UNDO_MS);
|
|
283
|
+
undo.onclick = () => { clearTimeout(leaving[id]); delete leaving[id]; notice.close(); if (notes[id]) draw(id); };
|
|
284
|
+
}
|
|
265
285
|
function draw(id) {
|
|
286
|
+
if (id in leaving) return; // deleted, its undo still offered
|
|
266
287
|
const n = notes[id];
|
|
267
288
|
let el = pins[id];
|
|
268
289
|
if (!el) {
|
|
@@ -283,6 +304,7 @@ export default async function (page) {
|
|
|
283
304
|
}
|
|
284
305
|
const b = Object.assign(document.createElement("button"), { className: "pin", type: "button", textContent: n.state === "open" ? id.slice(1) : "✓",
|
|
285
306
|
title: `${n.thread[0]?.text ?? ""}${n.state === "open" ? "" : " (closed)"}` });
|
|
307
|
+
b.setAttribute("aria-label", `note ${id}${n.state === "open" ? "" : " (closed)"}: ${n.thread[0]?.text ?? ""}`); // what read() and screen readers say
|
|
286
308
|
Object.assign(b.style, { left: `${p.x}px`, top: `${p.y}px` });
|
|
287
309
|
b.onclick = (e) => { e.stopPropagation(); openId = openId === id ? null : id; for (const k of Object.keys(pins)) draw(k); };
|
|
288
310
|
el.append(b);
|
|
@@ -309,6 +331,16 @@ export default async function (page) {
|
|
|
309
331
|
b.onclick = fn;
|
|
310
332
|
acts.append(b);
|
|
311
333
|
};
|
|
334
|
+
// top right: ✓ closes (a faded pin, can be reopened), ✕ deletes (no question: a few seconds to undo instead)
|
|
335
|
+
const top = Object.assign(document.createElement("div"), { className: "top" });
|
|
336
|
+
const corner = (text, title, fn) => {
|
|
337
|
+
const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, title });
|
|
338
|
+
b.onclick = fn;
|
|
339
|
+
top.append(b);
|
|
340
|
+
};
|
|
341
|
+
if (n.state === "open") corner("✓", "Done: close this note", () => { openId = null; page.send({ type: "note_close", id }); });
|
|
342
|
+
corner("✕", "Delete this note", () => remove(id));
|
|
343
|
+
d.prepend(top);
|
|
312
344
|
if (n.state === "open") {
|
|
313
345
|
const ta = Object.assign(document.createElement("textarea"), { placeholder: "Reply… (Enter sends)", value: drafts[id] ?? "" });
|
|
314
346
|
ta.oninput = () => { drafts[id] = ta.value; };
|
|
@@ -319,13 +351,9 @@ export default async function (page) {
|
|
|
319
351
|
if (e.key === "Escape") { openId = null; draw(id); }
|
|
320
352
|
};
|
|
321
353
|
d.append(ta);
|
|
322
|
-
button("✓ Done", () => { openId = null; page.send({ type: "note_close", id }); });
|
|
323
354
|
button("Reply", go, true);
|
|
324
355
|
setTimeout(() => ta.focus(), 0);
|
|
325
|
-
} else {
|
|
326
|
-
button("Delete", () => { if (confirm("Delete this note for good?")) { openId = null; page.send({ type: "note_delete", id }); } });
|
|
327
|
-
button("Reopen", () => page.send({ type: "note_reopen", id }));
|
|
328
|
-
}
|
|
356
|
+
} else button("Reopen", () => page.send({ type: "note_reopen", id }));
|
|
329
357
|
d.append(acts);
|
|
330
358
|
return d;
|
|
331
359
|
}
|
|
@@ -338,8 +366,9 @@ export default async function (page) {
|
|
|
338
366
|
setInterval(layout, 1500);
|
|
339
367
|
page.on("note_pin", ({ id, type, agent, ts, ...n }) => {
|
|
340
368
|
const reply = notes[id] && n.thread.length > notes[id].thread.length && n.thread.at(-1).by !== "user" && n.state === "open"; // an answer still waiting on the user
|
|
369
|
+
const started = !notes[id] && n.by !== "user" && n.state === "open"; // an agent's new note (the notes there at load come from /note/all)
|
|
341
370
|
notes[id] = n;
|
|
342
|
-
if (reply && openId !== id) openId = id; // an agent
|
|
371
|
+
if ((reply || started) && openId !== id) openId = id; // an agent wrote: show it open, so the user sees it
|
|
343
372
|
if (openId === id && n.state !== "open" && n.thread.at(-1)?.by !== "user") openId = null; // and closed it: done, so out of the way
|
|
344
373
|
draw(id);
|
|
345
374
|
});
|
|
@@ -4,5 +4,6 @@ A note is a thread pinned on a thing; you and the user can both start one. The u
|
|
|
4
4
|
- Change that exact thing. If its text is data (a row, a score), change the card's data, not the block.
|
|
5
5
|
- Answer in the note's thread (note_reply), not in chat. Ask or explain on a thing with note_add(on, text).
|
|
6
6
|
- When you did what it asked, call note_done(id): the pin stays as a faded ✓ the user can reopen. If your answer
|
|
7
|
-
leaves anything to them (a suggestion, a question, a choice), leave it open for their reply.
|
|
7
|
+
leaves anything to them (a suggestion, a question, a choice), leave it open for their reply. A note you started
|
|
8
|
+
that has served its purpose can go: note_done(id, delete: true).
|
|
8
9
|
Before you handle your first note, read README.md.
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
// note_add (page) click anything on the page (or draw around an area) and write: the target is the card,
|
|
4
4
|
// block, element, part (partAt), its text and source file, the spot and the area.
|
|
5
5
|
// Files dropped on the page come as a note too ("files": paths in <workspace>/drops).
|
|
6
|
+
// Written by an agent's code through the page (msg.by, page/browser.js): that agent's own note.
|
|
6
7
|
// note_combine the user dragged card a onto card b and wrote how to combine them. The agent changes b to
|
|
7
8
|
// take a in, reading a's data live ({"ref"} props); a stays as it is.
|
|
8
9
|
// note_reply the user wrote in a note's thread (its bubble): it goes to the note's agent, with the thread so far
|
|
9
|
-
// note_close / note_reopen / note_delete the user's ✓
|
|
10
|
+
// note_close / note_reopen / note_delete the user's ✓ (close) or ✕ (delete, for good; open or closed) at the
|
|
11
|
+
// bubble's top right, Reopen on a closed one
|
|
10
12
|
// The agents' tools: note_add(on, text) starts a thread on a thing (on: as doodle_mark's, checked on the open page),
|
|
11
|
-
// note_reply(id, text) writes in one, note_done(id) closes one
|
|
12
|
-
//
|
|
13
|
+
// note_reply(id, text) writes in one, note_done(id) closes one, note_done(id, delete: true) removes one the agent started
|
|
14
|
+
// (never the user's or another agent's). A closed note stays as a faded ✓ pin (the user can reopen or delete it); a
|
|
15
|
+
// removed board takes its notes with it.
|
|
13
16
|
// Each note goes to the card's owner (else the agent picked in the chat; an agent's own note: that agent), gets an id
|
|
14
17
|
// (n1, n2, ...: never reused) and is saved: data/note.json {"last", "notes": {id: {thread: [{by, text, ts}], to, by,
|
|
15
18
|
// board, state: "open" | "done", at: {x, y} (main px), anchor: {card, dx, dy}, stroke, target?, box?}}}. The pin sits
|
|
@@ -40,6 +43,12 @@ const save = () => fs.writeFileSync(FILE, JSON.stringify(D));
|
|
|
40
43
|
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
|
|
41
44
|
const show = (id: string) => host.broadcast("note_pin", { id, ...D.notes[id] });
|
|
42
45
|
const opened = () => Object.keys(D.notes).filter((id) => D.notes[id].state === "open");
|
|
46
|
+
const remove = (id: string) => { // a note, for good: off every page
|
|
47
|
+
delete D.notes[id];
|
|
48
|
+
items.delete(id);
|
|
49
|
+
save();
|
|
50
|
+
host.broadcast("note_gone", { id });
|
|
51
|
+
};
|
|
43
52
|
|
|
44
53
|
function pin(note: any) { // a new note: saved, pinned on every page
|
|
45
54
|
const id = `n${++D.last}`;
|
|
@@ -114,10 +123,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
123
|
|
|
115
124
|
pi.registerTool({
|
|
116
125
|
name: "note_done", label: "Note done", description: "Close a note that is dealt with (you did what it asked): its pin turns into a faded ✓ (the user can "
|
|
117
|
-
+ "reopen it). Not while your answer waits on the user (a suggestion, a question)."
|
|
118
|
-
|
|
126
|
+
+ "reopen it). Not while your answer waits on the user (a suggestion, a question). With delete: true, a note you started "
|
|
127
|
+
+ "goes from the page instead (open or closed), e.g. one that explained a step and has served its purpose.",
|
|
128
|
+
parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }),
|
|
129
|
+
delete: Type.Optional(Type.Boolean({ description: "remove it from the page for good: only notes you started" })) }),
|
|
119
130
|
async execute(_id, a) {
|
|
120
131
|
const n = D.notes[a.id];
|
|
132
|
+
if (a.delete) {
|
|
133
|
+
if (!n) return result(`error: no note '${a.id}'`);
|
|
134
|
+
if (n.by !== me()) return result(`error: ${a.id} was started by ${n.by === "user" ? "the user" : n.by}: only its starter deletes it. Close it instead (note_done without delete).`);
|
|
135
|
+
remove(a.id);
|
|
136
|
+
return result(`note ${a.id} is gone from the page`);
|
|
137
|
+
}
|
|
121
138
|
if (!n || n.state !== "open") return result(`error: no open note '${a.id}'. Open notes: ${opened().join(", ") || "none"}`);
|
|
122
139
|
n.state = "done";
|
|
123
140
|
items.delete(a.id);
|
|
@@ -142,12 +159,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
142
159
|
return;
|
|
143
160
|
}
|
|
144
161
|
if (msg.type === "note_reopen" && n?.state === "done") { n.state = "open"; save(); return show(msg.id); }
|
|
145
|
-
if (msg.type === "note_delete" && n
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (host.agents.has(n.to)) team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page.`);
|
|
162
|
+
if (msg.type === "note_delete" && n) { // open or closed, for good
|
|
163
|
+
const item = items.get(msg.id);
|
|
164
|
+
remove(msg.id);
|
|
165
|
+
if (host.agents.has(n.to) && !(item && team?.withdraw(item))) // not seen yet: just take it back
|
|
166
|
+
team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page; drop it if you haven't finished it.`);
|
|
151
167
|
return;
|
|
152
168
|
}
|
|
153
169
|
if (msg.type === "note_reply" && n && team && String(msg.text ?? "").trim()) {
|
|
@@ -163,10 +179,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
163
179
|
}
|
|
164
180
|
if (!["note_add", "note_combine"].includes(msg.type) || !team) return;
|
|
165
181
|
host.api.doodle?.dim();
|
|
166
|
-
const to = host.agents.has(msg.to) ? msg.to : "agent1";
|
|
167
182
|
const text = msg.text ?? "";
|
|
168
|
-
const
|
|
183
|
+
const base = { board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
|
|
169
184
|
stroke: msg.stroke ?? null }; // with its drawing, if any (main px)
|
|
185
|
+
// an agent's code wrote it through the page's tools (msg.by, page/browser.js): that agent's own note, as if it
|
|
186
|
+
// called note_add; nothing to tell it, it knows (the reply to page_run says what happened)
|
|
187
|
+
if (host.agents.has(msg.by)) {
|
|
188
|
+
pin({ ...base, to: msg.by, by: msg.by, thread: [{ by: msg.by, text: msg.type === "note_combine" ? `combine ${msg.a} into ${msg.b}: ${text}` : text, ts: Date.now() }] });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const to = host.agents.has(msg.to) ? msg.to : "agent1";
|
|
192
|
+
const note = { ...base, to, by: "user" };
|
|
170
193
|
if (msg.type === "note_combine") {
|
|
171
194
|
const { a, b } = msg;
|
|
172
195
|
const madeFrom = [...(host.shared.get("card")?.cards[b]?.made_from ?? []).filter((m: string) => m !== a), a];
|