@chalky/chalky 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/agent/SYSTEM.md +36 -0
- package/agent/extensions/app/README.md +72 -0
- package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
- package/agent/extensions/app/guide.md +5 -0
- package/agent/extensions/app/index.ts +66 -0
- package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
- package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
- package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
- package/agent/extensions/card/README.md +88 -0
- package/agent/extensions/card/blocks/Button.js +34 -0
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
- package/agent/extensions/card/blocks/Markdown.js +37 -0
- package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
- package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -129
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
- package/agent/extensions/command/guide.md +2 -0
- package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
- package/agent/extensions/doodle/README.md +57 -0
- package/agent/extensions/doodle/browser.js +1013 -0
- package/agent/extensions/doodle/guide.md +22 -0
- package/agent/extensions/doodle/index.ts +274 -0
- package/agent/extensions/file/browser.js +182 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +90 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -33
- package/{app → agent}/extensions/mcp/browser.js +1 -1
- package/agent/extensions/mcp/guide.md +3 -0
- package/{app → agent}/extensions/mcp/index.ts +2 -4
- package/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
- package/agent/extensions/note/README.md +37 -0
- package/agent/extensions/note/browser.js +382 -0
- package/agent/extensions/note/guide.md +9 -0
- package/agent/extensions/note/index.ts +215 -0
- package/agent/extensions/page/browser.js +453 -0
- package/{app → agent}/extensions/page/index.ts +72 -3
- package/{app → agent}/extensions/page/page.html +102 -49
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +10 -23
- package/{app → agent}/extensions/screen/index.ts +11 -11
- package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
- package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
- package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
- package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
- package/{app/extensions/versions → agent/extensions/version}/browser.js +19 -10
- package/agent/extensions/version/index.ts +205 -0
- package/{app → agent}/extensions/viewer/README.md +4 -4
- package/{app → agent}/extensions/viewer/browser.js +8 -8
- package/agent/extensions/viewer/guide.md +3 -0
- package/{app → agent}/extensions/viewer/index.ts +22 -17
- package/{app → agent}/extensions/voice/browser.js +4 -3
- package/{app → agent}/extensions/voice/index.ts +8 -6
- package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
- package/agent/settings.json +8 -0
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
- package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +15 -15
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/agent/skills/show-me/SKILL.md +66 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +2 -2
- package/package.json +9 -8
- package/server.ts +313 -58
- package/app/SYSTEM.md +0 -31
- package/app/extensions/appdata/README.md +0 -10
- package/app/extensions/appdata/guide.md +0 -4
- package/app/extensions/appdata/index.ts +0 -47
- package/app/extensions/commands/guide.md +0 -3
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -398
- package/app/extensions/doodle/guide.md +0 -6
- package/app/extensions/doodle/index.ts +0 -54
- package/app/extensions/files/browser.js +0 -96
- package/app/extensions/files/guide.md +0 -7
- package/app/extensions/frames/guide.md +0 -7
- package/app/extensions/frames/index.ts +0 -85
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -247
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -112
- package/app/extensions/prompt/index.ts +0 -19
- package/app/extensions/ui/README.md +0 -69
- package/app/extensions/ui/components/App.js +0 -28
- package/app/extensions/ui/components/Button.js +0 -28
- package/app/extensions/ui/components/Markdown.js +0 -37
- package/app/extensions/ui/guide.md +0 -7
- package/app/extensions/ui/index.ts +0 -291
- package/app/extensions/versions/index.ts +0 -168
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/settings.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- package/node_modules/xlsx/CHANGELOG.md +0 -361
- package/node_modules/xlsx/LICENSE +0 -201
- package/node_modules/xlsx/README.md +0 -41
- package/node_modules/xlsx/bin/xlsx.njs +0 -310
- package/node_modules/xlsx/bower.json +0 -22
- package/node_modules/xlsx/dist/LICENSE +0 -201
- package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
- package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
- package/node_modules/xlsx/dist/cpexcel.js +0 -1506
- package/node_modules/xlsx/dist/shim.min.js +0 -2
- package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
- package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
- package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
- package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
- package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
- package/node_modules/xlsx/dist/zahl.d.ts +0 -4
- package/node_modules/xlsx/package.json +0 -183
- package/node_modules/xlsx/types/index.d.ts +0 -1065
- package/node_modules/xlsx/types/tsconfig.json +0 -15
- package/node_modules/xlsx/xlsx.js +0 -28103
- package/node_modules/xlsx/xlsx.mjs +0 -28228
- package/node_modules/xlsx/xlsxworker.js +0 -14
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// The page side of the note extension. 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 card_show's place puts a card).
|
|
5
|
+
// The user sees only the box; the target goes to the agent with the note: card and block (from
|
|
6
|
+
// card/browser.js's data-card / data-block), 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 (index.ts passes it to the card's owner, gives it an id and
|
|
9
|
+
// tells every open page to pin it, with its drawing). Esc or the button turns it off.
|
|
10
|
+
// Pins: a numbered pin per note (the agents' in their colour), on the thing it is on, scrolling with the main space
|
|
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), ✓ (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
|
|
14
|
+
// the note stays open. Show marks (doodle/)
|
|
15
|
+
// hides the pins with the other marks.
|
|
16
|
+
// The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
|
|
17
|
+
// combine card/browser.js fires "card-combine" {a, b, x, y} when a card is dropped onto another: the note
|
|
18
|
+
// says how to combine them and goes to b's owner as note_combine (b changes; a stays)
|
|
19
|
+
// files the file extension fires "file-dropped" {x, y, files} when files land on the page (from the
|
|
20
|
+
// computer, or a row from its panel): a note with their paths, on what they were dropped on
|
|
21
|
+
// Parts: an element (or an App page's window) with partAt(x, y) is asked what is at the spot (an enemy in a
|
|
22
|
+
// canvas game); its answer goes with the note as "part". For a drawing, the thing under its middle is asked
|
|
23
|
+
// partIn(points) instead, if it has it: what is inside the drawn shape (the circled cells, paragraphs, an
|
|
24
|
+
// image of the area). points: [[x, y], ...] in window px (an App's window: px in its frame); it may be async.
|
|
25
|
+
export default async function (page) {
|
|
26
|
+
let names = {}; // "block-table" -> "Table" (tags are lower case; catalog names are not)
|
|
27
|
+
try { for (const n of Object.keys(await (await fetch("/card/blocks")).json())) names[`block-${n.toLowerCase()}`] = n; } catch {}
|
|
28
|
+
|
|
29
|
+
const layer = document.createElement("div"); // catches clicks, so the page underneath doesn't react
|
|
30
|
+
layer.style.cssText = "position:fixed;inset:0;z-index:9998;display:none";
|
|
31
|
+
// The cursor: the button's pen (white edge, so it shows on dark too), its tip (4, 20) is the spot pointed at.
|
|
32
|
+
const pen = (color) => `url("data:image/svg+xml,${encodeURIComponent(
|
|
33
|
+
`<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">` +
|
|
34
|
+
`<g stroke="#fff" stroke-width="4"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g>` +
|
|
35
|
+
`<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`;
|
|
36
|
+
const PEN = pen("#6e6e73"), PEN_ON = pen("#0071e3"); // grey: empty space; blue: over a thing
|
|
37
|
+
layer.style.cursor = PEN;
|
|
38
|
+
const input = document.createElement("input"); // the note box, opened where the user clicked
|
|
39
|
+
input.id = "note-input";
|
|
40
|
+
input.placeholder = "Write a note, Enter to send";
|
|
41
|
+
input.style.cssText = "position:fixed;z-index:9999;width:280px;box-sizing:border-box;font:inherit;font-size:14px;" +
|
|
42
|
+
"padding:8px 12px;border-radius:12px;outline:none;color:#1d1d1f;background:var(--glass-strong,#fff);" + // the page's glass
|
|
43
|
+
"backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);border:1px solid var(--edge,#eee);" +
|
|
44
|
+
"box-shadow:0 8px 30px rgba(0,0,0,.1);display:none";
|
|
45
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
46
|
+
function strokeSvg(points) { // a freehand stroke, drawn over the whole window (fixed, like the pins)
|
|
47
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
48
|
+
svg.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none";
|
|
49
|
+
const line = document.createElementNS(SVG, "polyline");
|
|
50
|
+
line.style.cssText = "fill:none;stroke:#f5b700;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;opacity:.85";
|
|
51
|
+
line.setAttribute("points", points.map((p) => p.join(",")).join(" "));
|
|
52
|
+
svg.append(line);
|
|
53
|
+
return svg;
|
|
54
|
+
}
|
|
55
|
+
const ink = strokeSvg([]); // the stroke being drawn
|
|
56
|
+
ink.style.zIndex = 9999;
|
|
57
|
+
ink.style.display = "none";
|
|
58
|
+
let current = null; // {target, x, y, stroke} of the open note
|
|
59
|
+
let stroke = null; // [[x, y], ...] while the mouse button is held down
|
|
60
|
+
document.body.append(layer, ink, input);
|
|
61
|
+
|
|
62
|
+
const button = document.createElement("button");
|
|
63
|
+
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>`;
|
|
64
|
+
button.title = "Point: click anything, or hold and draw around an area, then write a note (Esc to stop)";
|
|
65
|
+
button.dataset.name = "note"; // names an agent's pen can point at
|
|
66
|
+
page.slot("status").prepend(button); // the first tool in the palette, whichever script loads first
|
|
67
|
+
const setOn = (on) => {
|
|
68
|
+
layer.style.display = on ? "block" : "none";
|
|
69
|
+
button.classList.toggle("on", on);
|
|
70
|
+
if (!on) input.style.display = ink.style.display = "none";
|
|
71
|
+
if (!on) current = stroke = null;
|
|
72
|
+
if (on) document.dispatchEvent(new CustomEvent("tool-on", { detail: "note" })); // the other tools turn off
|
|
73
|
+
};
|
|
74
|
+
button.onclick = () => setOn(layer.style.display === "none");
|
|
75
|
+
document.addEventListener("tool-on", (e) => { if (e.detail !== "note" && layer.style.display !== "none") setOn(false); }); // one tool at a time
|
|
76
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
|
|
77
|
+
|
|
78
|
+
const describe = (e) => e.localName + (e.id ? `#${e.id}` : "") + (e.dataset?.name ? `[data-name="${e.dataset.name}"]` : "") +
|
|
79
|
+
[...e.classList].slice(0, 2).map((c) => `.${c}`).join("");
|
|
80
|
+
|
|
81
|
+
function inMain(x, y) { // window px -> main-space px (the main space scrolls; card_show's place uses these)
|
|
82
|
+
const main = page.slot("top"), m = main.getBoundingClientRect();
|
|
83
|
+
return { x: Math.round(x - m.left + main.scrollLeft), y: Math.round(y - m.top + main.scrollTop) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function target(x, y) {
|
|
87
|
+
const el = document.elementsFromPoint(x, y).find((e) => e !== layer);
|
|
88
|
+
if (!el) return null;
|
|
89
|
+
let inner = null, frame = null, box = el.getBoundingClientRect();
|
|
90
|
+
if (el.localName === "iframe") { // look inside the frame (an App): same origin, so its document is readable
|
|
91
|
+
try {
|
|
92
|
+
const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
|
|
93
|
+
inner = el.contentDocument?.elementFromPoint(x - dx, y - dy);
|
|
94
|
+
if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(b.x + dx, b.y + dy, b.width, b.height); }
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
const hitEl = inner || el;
|
|
98
|
+
const position = inMain(x, y);
|
|
99
|
+
if (el === page.slot("top")) return { owner: null, card: null, blocks: [], element: "empty space in the main space",
|
|
100
|
+
text: "", file: "", position, box: new DOMRect(x - 12, y - 12, 24, 24) };
|
|
101
|
+
const blocks = []; // outermost first, e.g. ["root (Panel)", "chart1 (Table)"]
|
|
102
|
+
for (let e = el.closest("[data-block]"); e; e = e.parentElement?.closest("[data-block]"))
|
|
103
|
+
blocks.unshift(`${e.dataset.block} (${names[e.localName] || e.localName})`);
|
|
104
|
+
const path = []; // a short selector: up to the nearest element with an id, at most 4 steps
|
|
105
|
+
for (let e = hitEl; e && path.length < 4 && e.localName !== "body"; e = e.parentElement) {
|
|
106
|
+
path.unshift(describe(e));
|
|
107
|
+
if (e.id) break;
|
|
108
|
+
}
|
|
109
|
+
const innermost = el.closest("[data-block]");
|
|
110
|
+
let part = null; // what the thing itself says is at this spot (partAt), e.g. {"name": "enemy-2"}
|
|
111
|
+
try {
|
|
112
|
+
if (frame?.contentWindow?.partAt) {
|
|
113
|
+
const r = frame.getBoundingClientRect();
|
|
114
|
+
part = frame.contentWindow.partAt(x - r.left - frame.clientLeft, y - r.top - frame.clientTop);
|
|
115
|
+
} else {
|
|
116
|
+
const owner = [hitEl, ...[...document.elementsFromPoint(x, y)]].find((e) => typeof e?.partAt === "function");
|
|
117
|
+
if (owner) { const r = owner.getBoundingClientRect(); part = owner.partAt(x - r.left, y - r.top); }
|
|
118
|
+
}
|
|
119
|
+
} catch (e) { console.warn("partAt:", e); }
|
|
120
|
+
const file = frame ? new URL(frame.contentWindow.location.href).pathname.slice(1) // /built/apps/x/... is built/apps/x/...
|
|
121
|
+
: innermost ? `agent/extensions/card/blocks/${names[innermost.localName] || innermost.localName}.js`
|
|
122
|
+
: "agent/extensions/page/page.html (the page itself)"; // paths in the home folder (the server puts it in front)
|
|
123
|
+
return {
|
|
124
|
+
owner: el.closest("[data-owner]")?.dataset.owner || null,
|
|
125
|
+
card: el.closest("[data-card]")?.dataset.card || null,
|
|
126
|
+
blocks,
|
|
127
|
+
element: path.join(" > "),
|
|
128
|
+
part,
|
|
129
|
+
text: (hitEl.innerText || hitEl.value || hitEl.alt || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
130
|
+
file,
|
|
131
|
+
at: { x: Math.round(((x - box.x) / box.width) * 100) + "%", y: Math.round(((y - box.y) / box.height) * 100) + "%" },
|
|
132
|
+
position,
|
|
133
|
+
box,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The note box at (x, y). `note`: what Enter sends besides the text (target, combine, files);
|
|
138
|
+
// `suggestion`: the faded text, sent as it is if the box is left empty (else the box needs words).
|
|
139
|
+
function openBox(x, y, note, suggestion = "") {
|
|
140
|
+
input.style.display = "block";
|
|
141
|
+
input.style.left = Math.min(x + 12, innerWidth - input.offsetWidth - 8) + "px";
|
|
142
|
+
input.style.top = Math.min(y + 12, innerHeight - input.offsetHeight - 8) + "px";
|
|
143
|
+
input.placeholder = suggestion || "Write a note, Enter to send";
|
|
144
|
+
current = { x, y, suggestion, ...note };
|
|
145
|
+
input.value = "";
|
|
146
|
+
input.focus();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function openNote(t, x, y, drawn = null) { // a note on target t (and the drawn stroke)
|
|
150
|
+
if (!t) return;
|
|
151
|
+
const { box, ...rest } = t;
|
|
152
|
+
openBox(x, y, { type: "note_add", target: rest, stroke: drawn, to: rest.owner });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Combine: a card was dropped onto another (card/browser.js). The note goes to the target's owner.
|
|
156
|
+
document.addEventListener("card-combine", ({ detail: { a, b, x, y } }) => {
|
|
157
|
+
const to = document.querySelector(`[data-card="${CSS.escape(b)}"]`)?.dataset.owner;
|
|
158
|
+
openBox(x, y, { type: "note_combine", a, b, to }, `Add ${a} to ${b}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Files dropped on the page (the file extension: from the computer, or a row from its panel; already
|
|
162
|
+
// stored in /workspace/drops): a note on what they landed on. Esc closes the box; the files stay stored.
|
|
163
|
+
document.addEventListener("file-dropped", ({ detail: { x, y, files } }) => {
|
|
164
|
+
const t = target(x, y) || { owner: null, card: null, blocks: [], element: "", text: "", file: "", position: inMain(x, y) };
|
|
165
|
+
const { box, ...rest } = t;
|
|
166
|
+
const paths = files.map((f) => `${f.path} (${Math.ceil(f.size / 1024)} KB)`);
|
|
167
|
+
openBox(x, y, { type: "note_add", target: rest, files: paths, to: rest.owner }, `Use ${files.map((f) => f.name).join(", ")} here`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Press, move, release: a small movement is a click on one thing; a bigger one is a drawing.
|
|
171
|
+
layer.onmousedown = (e) => {
|
|
172
|
+
stroke = [[e.clientX, e.clientY]];
|
|
173
|
+
input.style.display = ink.style.display = "none";
|
|
174
|
+
};
|
|
175
|
+
layer.onmousemove = (e) => {
|
|
176
|
+
if (!stroke) {
|
|
177
|
+
const t = target(e.clientX, e.clientY);
|
|
178
|
+
layer.style.cursor = t && t.card ? PEN_ON : PEN;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const [lx, ly] = stroke.at(-1);
|
|
182
|
+
if (Math.hypot(e.clientX - lx, e.clientY - ly) < 3) return; // keep the stroke light
|
|
183
|
+
stroke.push([e.clientX, e.clientY]);
|
|
184
|
+
ink.style.display = "block";
|
|
185
|
+
ink.firstChild.setAttribute("points", stroke.map((p) => p.join(",")).join(" "));
|
|
186
|
+
};
|
|
187
|
+
async function partIn(x, y, points) { // what the thing at (x, y) says is inside the drawing, or null
|
|
188
|
+
const el = document.elementsFromPoint(x, y).find((e) => e !== layer && e !== ink);
|
|
189
|
+
if (el?.localName === "iframe") {
|
|
190
|
+
try {
|
|
191
|
+
const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
|
|
192
|
+
if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => [px - dx, py - dy]));
|
|
193
|
+
} catch (err) { console.warn("partIn:", err); }
|
|
194
|
+
}
|
|
195
|
+
const owner = document.elementsFromPoint(x, y).find((e) => typeof e.partIn === "function");
|
|
196
|
+
try { return owner ? await owner.partIn(points) : null; } catch (err) { console.warn("partIn:", err); return null; }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
layer.onmouseup = async (e) => {
|
|
200
|
+
const points = stroke;
|
|
201
|
+
stroke = null;
|
|
202
|
+
if (!points) return;
|
|
203
|
+
const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]);
|
|
204
|
+
const left = Math.min(...xs), top = Math.min(...ys), width = Math.max(...xs) - left, height = Math.max(...ys) - top;
|
|
205
|
+
if (width < 12 && height < 12) return openNote(target(e.clientX, e.clientY), e.clientX, e.clientY);
|
|
206
|
+
const t = target(left + width / 2, top + height / 2); // what is under the middle of the drawing
|
|
207
|
+
if (t) t.area = { ...inMain(left, top), width: Math.round(width), height: Math.round(height) };
|
|
208
|
+
const inside = t && (await partIn(left + width / 2, top + height / 2, points));
|
|
209
|
+
if (inside) t.part = inside;
|
|
210
|
+
openNote(t, e.clientX, e.clientY, points);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
input.onkeydown = (e) => {
|
|
214
|
+
const text = input.value.trim() || current?.suggestion;
|
|
215
|
+
if (e.key !== "Enter" || !text || !current) return;
|
|
216
|
+
const { suggestion, x, y, stroke: drawn, ...note } = current;
|
|
217
|
+
// where the pin goes, in main px (so it scrolls with the cards), and where on its card (so it moves with it)
|
|
218
|
+
const at = inMain(x, y), cardEl = note.target?.card && document.querySelector(`[data-card="${CSS.escape(note.target.card)}"]`);
|
|
219
|
+
const c = cardEl && inMain(cardEl.getBoundingClientRect().left, cardEl.getBoundingClientRect().top);
|
|
220
|
+
page.send({ ...note, text, to: note.to || document.getElementById("to")?.value, // else the agent picked in the chat
|
|
221
|
+
board: Number(document.body.dataset.board) || 1, // the board shown (board/): what it points at is on it
|
|
222
|
+
at, ...(c && { anchor: { card: note.target.card, dx: at.x - c.x, dy: at.y - c.y } }),
|
|
223
|
+
stroke: drawn ? drawn.map(([px, py]) => { const q = inMain(px, py); return [q.x, q.y]; }) : null });
|
|
224
|
+
setOn(false);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ---- pins: a numbered pin per note on the thing it is on; click it for its thread (the bubble) ----
|
|
228
|
+
// A pin lives in the main space (data-board: on its board), so it scrolls with the cards. A user's note sits where
|
|
229
|
+
// they clicked on its card (anchor), an agent's on the thing it named (target, found by doodle's find); else at.
|
|
230
|
+
let marks = null;
|
|
231
|
+
page.use("marks").then((m) => { marks = m; layout(); });
|
|
232
|
+
const colour = (a) => marks?.colour(a) ?? "#0071e3";
|
|
233
|
+
const css = document.createElement("style");
|
|
234
|
+
css.textContent = `
|
|
235
|
+
.note-pin { position: absolute; left: 0; top: 0; width: 0; height: 0; overflow: visible; z-index: 100002; }
|
|
236
|
+
.note-pin > svg { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; pointer-events: none; }
|
|
237
|
+
.note-pin > svg polyline { fill: none; stroke: #f5b700; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; opacity: .85; }
|
|
238
|
+
.note-pin > .pin { position: absolute; width: 22px; height: 22px; border-radius: 50% 50% 50% 4px; display: grid; place-items: center;
|
|
239
|
+
font: 600 11px/1 -apple-system, system-ui, sans-serif; color: #fff; background: var(--c); box-shadow: 0 1px 4px rgba(0, 0, 0, .3); cursor: pointer; }
|
|
240
|
+
.note-pin.done > .pin { opacity: .45; }
|
|
241
|
+
.note-pin.done > svg { opacity: .3; }
|
|
242
|
+
.note-pin .bubble { position: absolute; width: 260px; padding: 10px 12px; border-radius: 12px; font-size: 13px; color: var(--text, #1d1d1f);
|
|
243
|
+
background: var(--glass-strong, #fff); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); border: 1px solid var(--edge, #eee);
|
|
244
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, .14); }
|
|
245
|
+
.note-pin .bubble .entry { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
246
|
+
.note-pin .bubble .entry b { font-weight: 600; margin-right: 6px; }
|
|
247
|
+
.note-pin .bubble .busy { color: var(--muted, #888); font-style: italic; }
|
|
248
|
+
.note-pin .bubble textarea { width: 100%; box-sizing: border-box; min-height: 48px; resize: vertical; font: inherit; padding: 6px 8px;
|
|
249
|
+
border-radius: 8px; border: 1px solid rgba(0, 0, 0, .15); outline: none; background: rgba(255, 255, 255, .8); }
|
|
250
|
+
.note-pin .bubble .top { float: right; display: flex; gap: 2px; margin: -4px -6px 0 6px; }
|
|
251
|
+
.note-pin .bubble .top button { width: 24px; height: 24px; border-radius: 6px; font-size: 13px; color: var(--soft, #555); }
|
|
252
|
+
.note-pin .bubble .top button:hover { background: var(--hover, rgba(0, 0, 0, .05)); color: var(--text, #1d1d1f); }
|
|
253
|
+
.note-pin .bubble .acts { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
|
254
|
+
.note-pin .bubble .acts button { padding: 3px 10px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(0, 0, 0, .12); }
|
|
255
|
+
.note-pin .bubble .acts button.primary { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
|
|
256
|
+
document.head.append(css);
|
|
257
|
+
const notes = {}, pins = {}; // note id -> the note; -> its element
|
|
258
|
+
let openId = null, busy = {}; // the open bubble; agent -> working? (from "status")
|
|
259
|
+
const drafts = {}; // note id -> what is typed in its reply box (kept when the bubble is redrawn)
|
|
260
|
+
|
|
261
|
+
function where(id) { // the pin's spot, main px
|
|
262
|
+
const n = notes[id];
|
|
263
|
+
if (n.target && marks) { const f = marks.find(n.target); const b = f.box || n.box; if (b) return { x: b.x + b.w - 4, y: b.y - 16 }; }
|
|
264
|
+
if (n.target && n.box) return { x: n.box.x + n.box.w - 4, y: n.box.y - 16 };
|
|
265
|
+
const card = n.anchor && document.querySelector(`[data-card="${CSS.escape(n.anchor.card)}"]`);
|
|
266
|
+
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 }; }
|
|
267
|
+
return n.at ?? { x: 0, y: 0 };
|
|
268
|
+
}
|
|
269
|
+
// Deleting: the pin goes at once, and a notice offers Undo for UNDO_MS; only then is the delete sent (so an undo
|
|
270
|
+
// needs nothing from the server, and other pages keep the note until it is really gone)
|
|
271
|
+
const UNDO_MS = 6000;
|
|
272
|
+
const leaving = {}; // id -> timer, while its undo is offered
|
|
273
|
+
function remove(id) {
|
|
274
|
+
openId = null;
|
|
275
|
+
pins[id]?.remove(); delete pins[id];
|
|
276
|
+
const undo = Object.assign(document.createElement("button"), { type: "button", className: "go", textContent: "Undo" });
|
|
277
|
+
const notice = page.notice(["Note deleted", undo], () => {});
|
|
278
|
+
const gone = () => { delete leaving[id]; notice.close(); page.send({ type: "note_delete", id }); };
|
|
279
|
+
leaving[id] = setTimeout(gone, UNDO_MS);
|
|
280
|
+
undo.onclick = () => { clearTimeout(leaving[id]); delete leaving[id]; notice.close(); if (notes[id]) draw(id); };
|
|
281
|
+
}
|
|
282
|
+
function draw(id) {
|
|
283
|
+
if (id in leaving) return; // deleted, its undo still offered
|
|
284
|
+
const n = notes[id];
|
|
285
|
+
let el = pins[id];
|
|
286
|
+
if (!el) {
|
|
287
|
+
el = pins[id] = Object.assign(document.createElement("div"), { className: "note-pin" });
|
|
288
|
+
el.dataset.note = id;
|
|
289
|
+
page.slot("top").append(el);
|
|
290
|
+
}
|
|
291
|
+
el.dataset.board = n.board ?? 1;
|
|
292
|
+
el.classList.toggle("done", n.state !== "open");
|
|
293
|
+
el.style.setProperty("--c", n.by === "user" ? "#e0a800" : colour(n.by));
|
|
294
|
+
el.replaceChildren();
|
|
295
|
+
const p = where(id), base = n.at ?? p;
|
|
296
|
+
if (n.stroke) { // the user's drawing, moved with the pin
|
|
297
|
+
const svg = document.createElementNS(SVG, "svg"), line = document.createElementNS(SVG, "polyline");
|
|
298
|
+
line.setAttribute("points", n.stroke.map(([sx, sy]) => `${sx + p.x - base.x},${sy + p.y - base.y}`).join(" "));
|
|
299
|
+
svg.append(line);
|
|
300
|
+
el.append(svg);
|
|
301
|
+
}
|
|
302
|
+
const b = Object.assign(document.createElement("button"), { className: "pin", type: "button", textContent: n.state === "open" ? id.slice(1) : "✓",
|
|
303
|
+
title: `${n.thread[0]?.text ?? ""}${n.state === "open" ? "" : " (closed)"}` });
|
|
304
|
+
b.setAttribute("aria-label", `note ${id}${n.state === "open" ? "" : " (closed)"}: ${n.thread[0]?.text ?? ""}`); // what read() and screen readers say
|
|
305
|
+
Object.assign(b.style, { left: `${p.x}px`, top: `${p.y}px` });
|
|
306
|
+
b.onclick = (e) => { e.stopPropagation(); openId = openId === id ? null : id; for (const k of Object.keys(pins)) draw(k); };
|
|
307
|
+
el.append(b);
|
|
308
|
+
el.dataset.at = `${Math.round(p.x)},${Math.round(p.y)}`;
|
|
309
|
+
if (openId === id) el.append(bubble(id, p));
|
|
310
|
+
}
|
|
311
|
+
function bubble(id, p) {
|
|
312
|
+
const n = notes[id], d = Object.assign(document.createElement("div"), { className: "bubble" });
|
|
313
|
+
const room = page.slot("top").scrollWidth;
|
|
314
|
+
Object.assign(d.style, { left: `${Math.max(4, Math.min(room - 270, p.x - 110))}px`, top: `${p.y + 28}px` });
|
|
315
|
+
for (const ev of ["pointerdown", "mousedown", "click"]) d.addEventListener(ev, (e) => e.stopPropagation());
|
|
316
|
+
for (const t of n.thread) {
|
|
317
|
+
const e = Object.assign(document.createElement("div"), { className: "entry" });
|
|
318
|
+
const who = Object.assign(document.createElement("b"), { textContent: t.by === "user" ? "you" : t.by });
|
|
319
|
+
who.style.color = t.by === "user" ? "#a07800" : colour(t.by);
|
|
320
|
+
e.append(who, document.createTextNode(t.text));
|
|
321
|
+
d.append(e);
|
|
322
|
+
}
|
|
323
|
+
if (n.state === "open" && n.thread.at(-1)?.by === "user" && busy[n.to])
|
|
324
|
+
d.append(Object.assign(document.createElement("div"), { className: "entry busy", textContent: `${n.to} is working…` }));
|
|
325
|
+
const acts = Object.assign(document.createElement("div"), { className: "acts" });
|
|
326
|
+
const button = (text, fn, primary = false) => {
|
|
327
|
+
const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, className: primary ? "primary" : "" });
|
|
328
|
+
b.onclick = fn;
|
|
329
|
+
acts.append(b);
|
|
330
|
+
};
|
|
331
|
+
// top right: ✓ closes (a faded pin, can be reopened), ✕ deletes (no question: a few seconds to undo instead)
|
|
332
|
+
const top = Object.assign(document.createElement("div"), { className: "top" });
|
|
333
|
+
const corner = (text, title, fn) => {
|
|
334
|
+
const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, title });
|
|
335
|
+
b.onclick = fn;
|
|
336
|
+
top.append(b);
|
|
337
|
+
};
|
|
338
|
+
if (n.state === "open") corner("✓", "Done: close this note", () => { openId = null; page.send({ type: "note_close", id }); });
|
|
339
|
+
corner("✕", "Delete this note", () => remove(id));
|
|
340
|
+
d.prepend(top);
|
|
341
|
+
if (n.state === "open") {
|
|
342
|
+
const ta = Object.assign(document.createElement("textarea"), { placeholder: "Reply… (Enter sends)", value: drafts[id] ?? "" });
|
|
343
|
+
ta.oninput = () => { drafts[id] = ta.value; };
|
|
344
|
+
const go = () => { const t = ta.value.trim(); if (t) { page.send({ type: "note_reply", id, text: t }); ta.value = drafts[id] = ""; } };
|
|
345
|
+
ta.onkeydown = (e) => {
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); go(); }
|
|
348
|
+
if (e.key === "Escape") { openId = null; draw(id); }
|
|
349
|
+
};
|
|
350
|
+
d.append(ta);
|
|
351
|
+
button("Reply", go, true);
|
|
352
|
+
setTimeout(() => ta.focus(), 0);
|
|
353
|
+
} else button("Reopen", () => page.send({ type: "note_reopen", id }));
|
|
354
|
+
d.append(acts);
|
|
355
|
+
return d;
|
|
356
|
+
}
|
|
357
|
+
function layout() { // things move (a card dragged, a row redrawn): pins follow
|
|
358
|
+
for (const id of Object.keys(pins)) {
|
|
359
|
+
const p = where(id);
|
|
360
|
+
if (pins[id].dataset.at !== `${Math.round(p.x)},${Math.round(p.y)}` && openId !== id) draw(id);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
setInterval(layout, 1500);
|
|
364
|
+
page.on("note_pin", ({ id, type, agent, ts, ...n }) => {
|
|
365
|
+
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
|
|
366
|
+
const started = !notes[id] && n.by !== "user" && n.state === "open"; // an agent's new note (the notes there at load come from /note/all)
|
|
367
|
+
notes[id] = n;
|
|
368
|
+
if ((reply || started) && openId !== id) openId = id; // an agent wrote: show it open, so the user sees it
|
|
369
|
+
if (openId === id && n.state !== "open" && n.thread.at(-1)?.by !== "user") openId = null; // and closed it: done, so out of the way
|
|
370
|
+
draw(id);
|
|
371
|
+
});
|
|
372
|
+
page.on("note_gone", ({ id }) => { pins[id]?.remove(); delete pins[id]; delete notes[id]; });
|
|
373
|
+
page.on("status", ({ agents }) => {
|
|
374
|
+
const was = openId && busy[notes[openId]?.to];
|
|
375
|
+
busy = Object.fromEntries(Object.entries(agents).map(([a, s]) => [a, s.state !== "idle"]));
|
|
376
|
+
if (openId && pins[openId] && was !== busy[notes[openId]?.to]) draw(openId); // only when "… is working" changes
|
|
377
|
+
});
|
|
378
|
+
document.addEventListener("pointerdown", (e) => { // a click elsewhere closes the bubble
|
|
379
|
+
if (openId && !e.target.closest?.(".note-pin")) { const id = openId; openId = null; if (pins[id]) draw(id); }
|
|
380
|
+
});
|
|
381
|
+
try { for (const [id, n] of Object.entries(await (await fetch("/note/all")).json())) { notes[id] = n; draw(id); } } catch {}
|
|
382
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Notes on the page
|
|
2
|
+
A note is a thread pinned on a thing; you and the user can both start one. The user's arrive as "[note on the page]
|
|
3
|
+
<their words>" with exactly what it is on, and their replies in a thread as "[note reply on the page]".
|
|
4
|
+
- Change that exact thing. If its text is data (a row, a score), change the card's data, not the block.
|
|
5
|
+
- Answer in the note's thread (note_reply), not in chat. Ask or explain on a thing with note_add(on, text).
|
|
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. A note you started
|
|
8
|
+
that has served its purpose can go: note_done(id, delete: true).
|
|
9
|
+
Before you handle your first note, read README.md.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Notes on the page (browser.js: the pen, the note box, the pins and their threads): a note is a thread pinned on a
|
|
2
|
+
// thing, and both the user and the agents can start one and reply in it.
|
|
3
|
+
// note_add (page) click anything on the page (or draw around an area) and write: the target is the card,
|
|
4
|
+
// block, 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
|
+
// Written by an agent's code through the page (msg.by, page/browser.js): that agent's own note.
|
|
7
|
+
// note_combine the user dragged card a onto card b and wrote how to combine them. The agent changes b to
|
|
8
|
+
// take a in, reading a's data live ({"ref"} props); a stays as it is.
|
|
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
|
|
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
|
|
12
|
+
// The agents' tools: note_add(on, text) starts a thread on a thing (on: as doodle_mark's, checked on the open page),
|
|
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.
|
|
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
|
|
17
|
+
// (n1, n2, ...: never reused) and is saved: data/note.json {"last", "notes": {id: {thread: [{by, text, ts}], to, by,
|
|
18
|
+
// board, state: "open" | "done", at: {x, y} (main px), anchor: {card, dx, dy}, stroke, target?, box?}}}. The pin sits
|
|
19
|
+
// on its card (anchor: where on it), so it moves with it; an agent's note sits on the thing it names (target).
|
|
20
|
+
// If the user closes a note its agent hasn't started on, it is taken out of its inbox (the agent never saw it).
|
|
21
|
+
// events note_pin {id, ...note} (new or changed) note_gone {id} GET /note/all
|
|
22
|
+
// guide.md: the short prompt section; README.md: the whole of it, read before a first note.
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Type } from "typebox";
|
|
27
|
+
|
|
28
|
+
const host = (globalThis as any).chalky;
|
|
29
|
+
const FILE = path.join(host.data, "note.json");
|
|
30
|
+
if (!host.shared.has("note")) {
|
|
31
|
+
const data = fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, notes: {} };
|
|
32
|
+
if (data.open) { // the older shape: {last, open: {id: {text, to, x, y, stroke, board}}}
|
|
33
|
+
data.notes = Object.fromEntries(Object.entries(data.open).map(([id, n]: any) =>
|
|
34
|
+
[id, { thread: [{ by: "user", text: n.text, ts: Date.now() }], to: n.to, by: "user", board: n.board ?? 1, state: "open", at: { x: n.x, y: n.y }, stroke: n.stroke }]));
|
|
35
|
+
delete data.open;
|
|
36
|
+
}
|
|
37
|
+
host.shared.set("note", data);
|
|
38
|
+
}
|
|
39
|
+
const D = host.shared.get("note");
|
|
40
|
+
if (!host.shared.has("note-items")) host.shared.set("note-items", new Map());
|
|
41
|
+
const items: Map<string, any> = host.shared.get("note-items"); // note id -> its latest team inbox item (not saved)
|
|
42
|
+
const save = () => fs.writeFileSync(FILE, JSON.stringify(D));
|
|
43
|
+
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
|
|
44
|
+
const show = (id: string) => host.broadcast("note_pin", { id, ...D.notes[id] });
|
|
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
|
+
};
|
|
52
|
+
|
|
53
|
+
function pin(note: any) { // a new note: saved, pinned on every page
|
|
54
|
+
const id = `n${++D.last}`;
|
|
55
|
+
D.notes[id] = { state: "open", ...note };
|
|
56
|
+
save();
|
|
57
|
+
show(id);
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describe(sid: string) { // a source of a combine, for the agent: cut short if long
|
|
62
|
+
const r = host.shared.get("card")?.cards[sid] ?? {};
|
|
63
|
+
const text = JSON.stringify({ owner: r.owner, about: r.about, ...(r.app ? { app: r.app } : { blocks: r.blocks }), data: r.data });
|
|
64
|
+
return text.length < 3000 ? text : text.slice(0, 3000) + ` … (more in ${host.built}/card.json and ${host.data}/card.json)`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const thread = (n: any) => n.thread.map((t: any) => ` ${t.by === "user" ? "user" : t.by}: ${t.text}`);
|
|
68
|
+
|
|
69
|
+
// A thing on the page, as the agent names it (as doodle_mark's "on")
|
|
70
|
+
const Target = Type.Object({
|
|
71
|
+
card: Type.Optional(Type.String({ description: "card id" })),
|
|
72
|
+
block: Type.Optional(Type.String({ description: "block id in that card" })),
|
|
73
|
+
element: Type.Optional(Type.String({ description: "a CSS selector, or an element's id or data-name (also inside an app's frame)" })),
|
|
74
|
+
text: Type.Optional(Type.String({ description: "exact text shown on the page (in the card/element if given)" })),
|
|
75
|
+
mark: Type.Optional(Type.String({ description: "a mark's id (m3)" })),
|
|
76
|
+
}, { description: "What the note is on: give as few fields as name it exactly, e.g. {card, text}." });
|
|
77
|
+
|
|
78
|
+
export default function (pi: ExtensionAPI) {
|
|
79
|
+
const me = () => pi.getSessionName()!;
|
|
80
|
+
// a removed board (board/) takes its notes with it; a note its agent hasn't seen yet is just taken back
|
|
81
|
+
pi.on("session_start", () => host.api.board?.onRemove("note", (n: number) => {
|
|
82
|
+
const gone: { owner: string; what: string }[] = [];
|
|
83
|
+
for (const id of Object.keys(D.notes).filter((id) => (D.notes[id].board ?? 1) === n)) {
|
|
84
|
+
const note = D.notes[id], item = items.get(id);
|
|
85
|
+
delete D.notes[id];
|
|
86
|
+
items.delete(id);
|
|
87
|
+
host.broadcast("note_gone", { id });
|
|
88
|
+
if (note.state === "open" && !(item && host.api.team?.withdraw(item))) gone.push({ owner: note.to, what: `note ${id} ("${note.thread[0]?.text}")` });
|
|
89
|
+
}
|
|
90
|
+
save();
|
|
91
|
+
return gone;
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: "note_add", label: "Note add",
|
|
96
|
+
description: "Pin a note (a thread) on a thing on the page, to explain something there or ask the user about it. "
|
|
97
|
+
+ "The user replies in it; their reply comes to you. Short pointers go on the board instead (doodle_mark).",
|
|
98
|
+
parameters: Type.Object({ on: Target, text: Type.String() }),
|
|
99
|
+
async execute(_id, a) {
|
|
100
|
+
const where = await host.api.doodle?.check(a.on);
|
|
101
|
+
if (where?.error) return result(`error: ${where.error}. Name it as it is on the page: a card id, a block id, an element's id or data-name, or its exact text.`);
|
|
102
|
+
const id = pin({ thread: [{ by: me(), text: a.text, ts: Date.now() }], to: me(), by: me(), target: a.on,
|
|
103
|
+
board: where?.board ?? host.api.board?.current() ?? 1, ...(where?.box && { box: where.box }) });
|
|
104
|
+
return result(`pinned note ${id}; the user reads it there (don't repeat it in your reply) and their replies come to you as "[note reply on the page]"`
|
|
105
|
+
+ (where ? "" : " (no page open to check the target: it shows when one opens)"));
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.registerTool({
|
|
110
|
+
name: "note_reply", label: "Note reply",
|
|
111
|
+
description: "Write in a note's thread (answer the user there, any length). A closed note opens again.",
|
|
112
|
+
parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }), text: Type.String() }),
|
|
113
|
+
async execute(_id, a) {
|
|
114
|
+
const n = D.notes[a.id];
|
|
115
|
+
if (!n) return result(`error: no note '${a.id}'. Notes: ${Object.keys(D.notes).join(", ") || "none"}`);
|
|
116
|
+
n.thread.push({ by: me(), text: a.text, ts: Date.now() });
|
|
117
|
+
n.state = "open";
|
|
118
|
+
save();
|
|
119
|
+
show(a.id);
|
|
120
|
+
return result(`replied in note ${a.id}; the user reads it there: don't repeat it in your reply`);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.registerTool({
|
|
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 "
|
|
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" })) }),
|
|
130
|
+
async execute(_id, a) {
|
|
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
|
+
}
|
|
138
|
+
if (!n || n.state !== "open") return result(`error: no open note '${a.id}'. Open notes: ${opened().join(", ") || "none"}`);
|
|
139
|
+
n.state = "done";
|
|
140
|
+
items.delete(a.id);
|
|
141
|
+
save();
|
|
142
|
+
show(a.id);
|
|
143
|
+
return result(`note ${a.id} is closed; its pin shows a faded ✓`);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
host.routes.set("/note/all", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(D.notes)));
|
|
148
|
+
|
|
149
|
+
host.pageHandlers.set("note", (msg: any) => {
|
|
150
|
+
const team = host.api.team, n = D.notes[msg.id];
|
|
151
|
+
if (msg.type === "note_close" && n?.state === "open") {
|
|
152
|
+
n.state = "done";
|
|
153
|
+
save();
|
|
154
|
+
show(msg.id);
|
|
155
|
+
const item = items.get(msg.id);
|
|
156
|
+
items.delete(msg.id);
|
|
157
|
+
if (host.agents.has(n.to) && !(item && team?.withdraw(item)))
|
|
158
|
+
team?.fyi("page", n.to, `[note on the page] the user closed note ${msg.id} ("${n.thread[0]?.text}"): done with it; drop it if you haven't finished it.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (msg.type === "note_reopen" && n?.state === "done") { n.state = "open"; save(); return show(msg.id); }
|
|
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.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (msg.type === "note_reply" && n && team && String(msg.text ?? "").trim()) {
|
|
170
|
+
n.thread.push({ by: "user", text: msg.text.trim(), ts: Date.now() });
|
|
171
|
+
n.state = "open";
|
|
172
|
+
save();
|
|
173
|
+
show(msg.id);
|
|
174
|
+
host.api.doodle?.dim();
|
|
175
|
+
const to = host.agents.has(n.to) ? n.to : "agent1";
|
|
176
|
+
const on = n.target ? Object.entries(n.target).map(([k, v]) => `${k} "${v}"`).join(", ") : n.anchor?.card ? `card ${n.anchor.card}` : "the page";
|
|
177
|
+
return items.set(msg.id, team.send("user", to, [`[note reply on the page] ${msg.text.trim()}`,
|
|
178
|
+
` id: ${msg.id} (on ${on}, board ${n.board}; answer in this thread: note_reply("${msg.id}", ...))`, " thread so far:", ...thread(n)].join("\n")));
|
|
179
|
+
}
|
|
180
|
+
if (!["note_add", "note_combine"].includes(msg.type) || !team) return;
|
|
181
|
+
host.api.doodle?.dim();
|
|
182
|
+
const text = msg.text ?? "";
|
|
183
|
+
const base = { board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
|
|
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" };
|
|
193
|
+
if (msg.type === "note_combine") {
|
|
194
|
+
const { a, b } = msg;
|
|
195
|
+
const madeFrom = [...(host.shared.get("card")?.cards[b]?.made_from ?? []).filter((m: string) => m !== a), a];
|
|
196
|
+
const id = pin({ ...note, thread: [{ by: "user", text: `combine ${a} into ${b}: ${text}`, ts: Date.now() }] });
|
|
197
|
+
return items.set(id, team.send("user", to, [`[combine on the page] ${text}`, ` id: ${id} (call note_done("${id}") when dealt with)`,
|
|
198
|
+
` board: ${note.board}`,
|
|
199
|
+
` dragged: ${a} (keep it as it is)`, ` onto: ${b} (change this one)`,
|
|
200
|
+
` so: card_show(card="${b}", ..., made_from=${JSON.stringify(madeFrom)}, instruction=<their words>), reading ${a}'s data with {"ref": "${a}", "path": ...}`,
|
|
201
|
+
` ${a}: ${describe(a)}`, ` ${b}: ${describe(b)}`].join("\n")));
|
|
202
|
+
}
|
|
203
|
+
const t = msg.target ?? {};
|
|
204
|
+
const at = t.at && `${t.at.x} across, ${t.at.y} down the element`;
|
|
205
|
+
const pos = t.position && `x=${t.position.x}, y=${t.position.y} px in the main space (for card_show's place)`;
|
|
206
|
+
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 `
|
|
207
|
+
+ (t.card ? "(the user drew around this part of the card: the note is about it)" : "(the user drew around empty space: build inside it)");
|
|
208
|
+
const id = pin({ ...note, thread: [{ by: "user", text, ts: Date.now() }] });
|
|
209
|
+
const lines = [`[note on the page] ${text}`, ` id: ${id} (answer in its thread: note_reply("${id}", ...); note_done("${id}") when dealt with)`,
|
|
210
|
+
...([["board", note.board], ["card", t.card], ["block", (t.blocks ?? []).join(" → ")], ["element", t.element],
|
|
211
|
+
["part", t.part && JSON.stringify(t.part)], ["at", at], ["text", t.text], ["file", t.file && path.join(host.home, t.file)], ["position", pos],
|
|
212
|
+
["area", area], ["files", msg.files?.join(", ")]] as [string, any][]).filter(([, v]) => v).map(([k, v]) => ` ${k}: ${v}`)];
|
|
213
|
+
items.set(id, team.send("user", to, lines.join("\n")));
|
|
214
|
+
});
|
|
215
|
+
}
|