@chalky/chalky 0.1.3 → 0.1.5

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.
Files changed (103) hide show
  1. package/README.md +10 -6
  2. package/{app → agent}/SYSTEM.md +16 -11
  3. package/agent/extensions/app/README.md +68 -0
  4. package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
  5. package/agent/extensions/app/guide.md +5 -0
  6. package/agent/extensions/app/index.ts +66 -0
  7. package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
  8. package/agent/extensions/board/browser.js +359 -0
  9. package/agent/extensions/board/guide.md +4 -0
  10. package/agent/extensions/board/index.ts +100 -0
  11. package/agent/extensions/card/README.md +88 -0
  12. package/agent/extensions/card/blocks/Button.js +34 -0
  13. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  14. package/agent/extensions/card/blocks/Markdown.js +37 -0
  15. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  16. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  18. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
  19. package/agent/extensions/card/guide.md +13 -0
  20. package/agent/extensions/card/index.ts +370 -0
  21. package/agent/extensions/command/browser.js +13 -0
  22. package/agent/extensions/command/guide.md +2 -0
  23. package/agent/extensions/command/index.ts +94 -0
  24. package/agent/extensions/doodle/README.md +57 -0
  25. package/agent/extensions/doodle/browser.js +1013 -0
  26. package/agent/extensions/doodle/guide.md +22 -0
  27. package/agent/extensions/doodle/index.ts +274 -0
  28. package/agent/extensions/file/browser.js +143 -0
  29. package/agent/extensions/file/guide.md +5 -0
  30. package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
  31. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  32. package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
  33. package/agent/extensions/frame/guide.md +3 -0
  34. package/agent/extensions/frame/index.ts +81 -0
  35. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  36. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
  37. package/{app → agent}/extensions/mcp/browser.js +1 -1
  38. package/agent/extensions/mcp/guide.md +3 -0
  39. package/{app → agent}/extensions/mcp/index.ts +2 -4
  40. package/agent/extensions/model/browser.js +363 -0
  41. package/{app → agent}/extensions/model/index.ts +8 -4
  42. package/agent/extensions/model/login.ts +215 -0
  43. package/agent/extensions/note/README.md +35 -0
  44. package/agent/extensions/note/browser.js +356 -0
  45. package/agent/extensions/note/guide.md +8 -0
  46. package/agent/extensions/note/index.ts +192 -0
  47. package/{app → agent}/extensions/page/index.ts +2 -2
  48. package/{app → agent}/extensions/page/page.html +55 -49
  49. package/agent/extensions/prompt/index.ts +38 -0
  50. package/{app → agent}/extensions/reload/index.ts +3 -3
  51. package/{app → agent}/extensions/screen/browser.js +4 -4
  52. package/{app → agent}/extensions/screen/index.ts +11 -11
  53. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  54. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  55. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  56. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  57. package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
  58. package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
  59. package/{app → agent}/extensions/viewer/README.md +4 -4
  60. package/{app → agent}/extensions/viewer/browser.js +8 -8
  61. package/agent/extensions/viewer/guide.md +3 -0
  62. package/{app → agent}/extensions/viewer/index.ts +22 -17
  63. package/agent/extensions/voice/browser.js +72 -0
  64. package/agent/extensions/voice/index.ts +39 -0
  65. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  66. package/agent/settings.json +8 -0
  67. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  68. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  69. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
  70. package/agent/skills/extension-map/SKILL.md +56 -0
  71. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  72. package/bin/chalky.mjs +3 -3
  73. package/package.json +8 -8
  74. package/server.ts +162 -63
  75. package/app/extensions/appdata/README.md +0 -10
  76. package/app/extensions/appdata/guide.md +0 -4
  77. package/app/extensions/appdata/index.ts +0 -47
  78. package/app/extensions/doodle/README.md +0 -15
  79. package/app/extensions/doodle/browser.js +0 -388
  80. package/app/extensions/doodle/guide.md +0 -6
  81. package/app/extensions/doodle/index.ts +0 -54
  82. package/app/extensions/files/browser.js +0 -96
  83. package/app/extensions/files/guide.md +0 -7
  84. package/app/extensions/frames/guide.md +0 -7
  85. package/app/extensions/frames/index.ts +0 -76
  86. package/app/extensions/mcp/guide.md +0 -5
  87. package/app/extensions/point/README.md +0 -22
  88. package/app/extensions/point/browser.js +0 -245
  89. package/app/extensions/point/guide.md +0 -6
  90. package/app/extensions/point/index.ts +0 -97
  91. package/app/extensions/prompt/index.ts +0 -19
  92. package/app/extensions/ui/README.md +0 -69
  93. package/app/extensions/ui/components/App.js +0 -28
  94. package/app/extensions/ui/components/Button.js +0 -28
  95. package/app/extensions/ui/components/Markdown.js +0 -37
  96. package/app/extensions/ui/guide.md +0 -7
  97. package/app/extensions/ui/index.ts +0 -271
  98. package/app/extensions/viewer/guide.md +0 -5
  99. package/app/mcp.json +0 -3
  100. package/app/skills/plugin-map/SKILL.md +0 -49
  101. package/app/ui/apps/.keep +0 -0
  102. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  103. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
@@ -0,0 +1,22 @@
1
+ # The board is shared: answer on it
2
+ The user marks the page (doodles, handwriting, notes) and you can too, like a colleague at the same desk with a pen.
3
+ - Prefer answering on the board when you show or point at something, or ask a specific clarification (mark the
4
+ options and ask which one). Use chat text only when it is clearly the better format.
5
+ - Pen (doodle_mark): a gesture on one exact thing (the cell, not the column), with at most a few handwritten words.
6
+ "Circle it", "point at it", "show me which" mean the pen, not a change: never build a mark into the page or an app.
7
+ If the pen can't find the thing, say so and ask. The page's own controls have names ({element: "pen"}, "note",
8
+ "tools", "message-line", ...); for something with no name, look (screen_see) and use at: {x, y, width, height}.
9
+ - If what they ask of you is unclear ("circle it": which?), ask before you act; don't guess.
10
+ - Notes (note_add, note_reply): to explain or ask, in a thread on the thing. Answer a note in its own thread.
11
+ - A question is not a request to change: answer it (on the board), and change things only when they ask for it.
12
+ - Don't repeat in chat what you drew or wrote in a note: the user sees it on the board. Chat is one short line with
13
+ only what isn't there, or nothing.
14
+ - If a mark of theirs could mean two different things, don't change anything: ask, on the board or in a note.
15
+ - Marks come in groups (g1, g2, ...). doodle_clear a group when it is done:
16
+ - they asked for a change and you made it: clear it then (the change is the answer; no mark to confirm it);
17
+ - your answer is on the board (marks, a note, a question): leave it for them to see; clear it in a later turn,
18
+ once they have moved on or said it's fine.
19
+ Each message lists the other groups still on the page: clear the ones that are done. When you deal with one of
20
+ several things you marked, take that one mark away: doodle_clear(mark=...).
21
+ A message "[doodles on the page]" is the user's drawing and clicks, with a picture per step: read README.md before
22
+ you handle one, or before your first doodle_mark.
@@ -0,0 +1,274 @@
1
+ // Doodle: draw on the page, click through it, and send the whole sequence to an agent. browser.js adds a marker, Text and an eraser to the palette: while the marker
2
+ // is on, a drag draws, typing writes words where the pointer is, and a click works the page as usual. A click after
3
+ // drawing makes the drawing a step: a picture of the screen as the user saw it (from the tab, shared once: the
4
+ // screen extension), cropped to the drawing. Enter in the message line sends the steps and the words typed there,
5
+ // together, to the agent picked in the chat (else agent1), with the pictures attached to the message itself
6
+ // (the team's send, with images), in step order.
7
+ // page message doodle_send {to, text, first: [click], steps: [{shot, area, drawn, words: [str], then: [click]}],
8
+ // marks: [{kind: "line" | "words", points, text?, said, anchor?}]}
9
+ // (shot: a path from /screen/shot, or null when the tab wasn't shared; area: the part of the main
10
+ // space the picture shows, main px; drawn: anything drawn on it; words: what the user typed on it,
11
+ // exactly; clicks are words browser.js wrote; marks: what is still drawn at Enter, main px, and
12
+ // what the page can tell about each from geometry alone ("said": closed loop around "Total", ...), and
13
+ // anchor: the thing it is most about ({card, text, row?} ..., at: where it was), so it moves with it)
14
+ // The board is shared: the user's marks stay after Enter, and the agents draw too.
15
+ // groups each Enter with marks is a group (g1, g2, ...): the user's marks, then the agent's marks in reply.
16
+ // data/doodle.json {last, lastMark, groups: {id: {board, to, by, state, dim, label, ts}},
17
+ // marks: {id: {group, by, kind, board, points?, text?, said?, gesture?, on?, to?, words?, box?}}}
18
+ // state: open (its agent hasn't answered yet) → answered (its turn ended) → cleared (by the user's ✓ or
19
+ // the agent's doodle_clear; it can be brought back from the chat log: doodle_restore). Nothing clears by
20
+ // itself. dim: an answered group, after the user sent something newer.
21
+ // tools doodle_mark a gesture on a thing (ring, underline, strike, tick, cross, arrow to another thing, or
22
+ // none) with a few handwritten words; the page works out where, so no coordinates
23
+ // doodle_clear a group is dealt with: its marks go for everyone (or one of the agent's own marks, for good)
24
+ // check host.api.doodle.check(target): asks an open page where a thing is ({box} in main px, or {error});
25
+ // null when no page answers. note/ uses it too, for the agent's notes.
26
+ // events doodle_group {group, marks} doodle_gone {group} doodle_mark {mark} doodle_erased {id} doodle_check
27
+ // page doodle_clear {group} doodle_restore {group} doodle_erase {id} doodle_dim doodle_checked {req, box|error}
28
+ // GET /doodle/all {groups, marks}
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
32
+ import { Type } from "typebox";
33
+
34
+ const host = (globalThis as any).chalky;
35
+ const s = (n: number) => (n === 1 ? "" : "s");
36
+ const FILE = path.join(host.data, "doodle.json");
37
+ if (!host.shared.has("doodle"))
38
+ host.shared.set("doodle", fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, lastMark: 0, groups: {}, marks: {} });
39
+ const D = host.shared.get("doodle");
40
+ if (!host.shared.has("doodle-run")) host.shared.set("doodle-run", { own: {}, current: {}, started: {}, checks: new Map() });
41
+ const run = host.shared.get("doodle-run"); // own: agent -> the group it started this turn; current: agent -> the user's group this turn answers;
42
+ // started: agent -> its turn began; checks: req -> resolve
43
+ const save = () => fs.writeFileSync(FILE, JSON.stringify(D));
44
+ const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
45
+ const marksOf = (g: string) => Object.entries(D.marks).filter(([, m]: any) => m.group === g) as [string, any][];
46
+
47
+ function dim() { // the user sent something new: answered groups step back
48
+ const changed = Object.entries(D.groups).filter(([, g]: any) => g.state === "answered" && !g.dim);
49
+ for (const [, g] of changed as any) g.dim = true;
50
+ if (changed.length) { save(); for (const [id] of changed) host.broadcast("doodle_group", { group: id, ...D.groups[id] }); }
51
+ }
52
+
53
+ function check(target: any): Promise<any> { // where is this thing on the page? The first page to answer says
54
+ const req = `c${Date.now()}${Math.random().toString(36).slice(2, 6)}`;
55
+ return new Promise((resolve) => {
56
+ const timer = setTimeout(() => { run.checks.delete(req); resolve(null); }, 4000);
57
+ run.checks.set(req, (answer: any) => { clearTimeout(timer); run.checks.delete(req); resolve(answer); });
58
+ host.broadcast("doodle_check", { req, target });
59
+ });
60
+ }
61
+
62
+ function label(m: any) { return m.said?.replace(/, box x=.*$/, "") || (m.text ? `"${m.text}"` : "a mark"); } // without the numbers
63
+
64
+ function newGroup(by: string, to: string, board: number, labelText: string) {
65
+ const id = `g${++D.last}`;
66
+ D.groups[id] = { board, to, by, state: by === "user" ? "open" : "answered", dim: false, label: labelText, ts: Date.now() };
67
+ return id;
68
+ }
69
+
70
+ // What is still on the board besides this group, for the agent (ids it can point at, or clear)
71
+ function others(except: string) {
72
+ return Object.entries(D.groups).filter(([id, g]: any) => id !== except && g.state !== "cleared").map(([id, g]: any) =>
73
+ ` ${id} (${g.state}${g.dim ? ", dimmed" : ""}, board ${g.board}): ` + marksOf(id).map(([mid, m]) =>
74
+ m.by === "user" ? `${mid} ${label(m)}` : `${mid} ${m.by}'s ${m.gesture}${m.words ? ` "${m.words}"` : ""}`).join("; "));
75
+ }
76
+
77
+ function message(msg: any, group: string | null, ids: string[]) {
78
+ const steps: any[] = msg.steps ?? [];
79
+ const shots: string[] = steps.map((st) => st.shot).filter((p) => host.api.screen?.isShot(p));
80
+ const words = String(msg.text ?? "").trim();
81
+ const lines = [`[doodles on the page] ${words || "(no words: the drawing says it)"}`];
82
+ if (group) {
83
+ lines.push(`group: ${group} (your marks join it. If you make the change it asks for, doodle_clear("${group}") once it is made; if your answer is on the board, leave it for the user to see)`,
84
+ "marks still on the page (main-space px; what the page can tell from the geometry; the picture shows the rest):",
85
+ ...ids.map((id) => ` ${id}: ${D.marks[id].said}`));
86
+ const rest = others(group);
87
+ if (rest.length) lines.push("other groups still on the page (doodle_clear the ones that are done: their change is made, or their answer seen):", ...rest);
88
+ }
89
+ lines.push(`${steps.length} step${s(steps.length)}, ${shots.length} picture${s(shots.length)} attached in this order`
90
+ + (shots.length === steps.length ? "" : " (the tab wasn't shared for the others: words only)") + ":");
91
+ if (msg.first?.length) lines.push(` first: ${msg.first.join("; ")}`);
92
+ let n = 0;
93
+ steps.forEach((st, i) => {
94
+ const has = shots.includes(st.shot), a = st.area;
95
+ if (has) n++;
96
+ lines.push(`Step ${i + 1}` + (st.board ? ` on board ${st.board}` : "") + (has ? ` (picture ${n})` : " (no picture)")
97
+ + (has && a ? `, showing x=${a.x} y=${a.y} w=${a.width} h=${a.height} of the page` : "")
98
+ + (st.drawn ? "" : ": nothing drawn, how the screen looked after those clicks"));
99
+ for (const w of st.words ?? []) lines.push(` words: "${w}"`);
100
+ if (st.then?.length) lines.push(` then: ${st.then.join("; ")}`);
101
+ });
102
+ return { text: lines.join("\n"), shots };
103
+ }
104
+
105
+ function clear(group: string, by: string) {
106
+ const g = D.groups[group];
107
+ g.state = "cleared";
108
+ g.clearedBy = by;
109
+ save();
110
+ host.broadcast("doodle_group", { group, ...g });
111
+ }
112
+
113
+ function erase(id: string) { // a mark, for good (the eraser; an agent taking one of its marks away)
114
+ const group = D.marks[id].group;
115
+ delete D.marks[id];
116
+ if (!marksOf(group).length) delete D.groups[group];
117
+ save();
118
+ host.broadcast("doodle_erased", { id, group });
119
+ }
120
+
121
+ host.api.doodle = { check, dim };
122
+
123
+ // A thing on the page, as the agent names it (the tools' "on" and "to")
124
+ const Target = Type.Object({
125
+ card: Type.Optional(Type.String({ description: "card id" })),
126
+ block: Type.Optional(Type.String({ description: "block id in that card" })),
127
+ element: Type.Optional(Type.String({ description: "a CSS selector, or an element's id or data-name (also inside an app's frame)" })),
128
+ text: Type.Optional(Type.String({ description: "exact text shown on the page (in the card/element if given): the mark goes on just those words" })),
129
+ mark: Type.Optional(Type.String({ description: "a mark's id (m3): the user's mark or yours" })),
130
+ note: Type.Optional(Type.String({ description: "a note's id (n2)" })),
131
+ at: Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number(), width: Type.Number(), height: Type.Number() }, {
132
+ description: "last resort, for something with no name: a box in window px, as screen_see's picture shows it" })),
133
+ }, { description: "What the mark is on: give as few fields as name it exactly, e.g. {card, text}. The page's own controls "
134
+ + "have names too: {element: \"pen\"} (also text, eraser, show-marks, note, frames, files, dictate, tools, rail, messages, "
135
+ + "versions, message-line, boards, new-board)." });
136
+ const named = (t: any) => Object.entries(t ?? {}).filter(([, v]) => v)
137
+ .map(([k, v]) => k === "at" ? `at x=${v.x} y=${v.y} w=${v.width} h=${v.height}` : `${k} "${v}"`).join(", ");
138
+
139
+ export default function (pi: ExtensionAPI) {
140
+ const me = () => pi.getSessionName()!;
141
+ // a removed board (board/) takes its marks with it
142
+ pi.on("session_start", () => host.api.board?.onRemove("doodle", (n: number) => {
143
+ for (const [id, g] of Object.entries(D.groups) as any) if ((g.board ?? 1) === n) {
144
+ for (const [mid] of marksOf(id)) delete D.marks[mid];
145
+ delete D.groups[id];
146
+ host.broadcast("doodle_gone", { group: id });
147
+ }
148
+ save();
149
+ return [];
150
+ }));
151
+ pi.on("agent_start", () => { // the group this turn answers: the newest the user sent this agent that it hasn't answered yet
152
+ delete run.own[me()];
153
+ run.started[me()] = Date.now();
154
+ run.current[me()] = Object.entries(D.groups).filter(([, g]: any) => g.to === me() && g.by === "user" && g.state === "open")
155
+ .sort(([, x]: any, [, y]: any) => y.ts - x.ts)[0]?.[0];
156
+ });
157
+ pi.on("agent_settled", () => { // this agent's turn is over: the groups it had when it started are answered
158
+ let changed = false;
159
+ for (const [id, g] of Object.entries(D.groups) as any)
160
+ if (g.to === me() && g.state === "open" && g.ts <= (run.started[me()] ?? 0)) { g.state = "answered"; changed = true; host.broadcast("doodle_group", { group: id, ...g }); }
161
+ if (changed) save();
162
+ });
163
+
164
+ pi.registerTool({
165
+ name: "doodle_mark", label: "Doodle mark",
166
+ description: "Draw on the page in your own ink, by hand, as a colleague would with a pen: a gesture on a thing, and/or a few "
167
+ + "handwritten words beside it. The page works out where; you only name the thing. Use it to show, point out or "
168
+ + "ask about one exact thing. Explanations go in a note (note_add), not here.",
169
+ parameters: Type.Object({
170
+ gesture: Type.Union(["ring", "underline", "strike", "tick", "cross", "arrow", "none"].map((g) => Type.Literal(g)),
171
+ { description: "ring: circle it (look here, a problem, an option); underline; strike: through it (this is wrong); tick: ✓ beside it (yes, this one); cross: ✕ over it (only for: remove this); arrow: from on to to; none: only words" }),
172
+ on: Target,
173
+ to: Type.Optional(Target),
174
+ words: Type.Optional(Type.String({ description: "at most 40 characters, handwritten beside it" })),
175
+ group: Type.Optional(Type.String({ description: "the group you are answering (g3, from the message); left out: the group of the message you are answering, else a new group of your own" })),
176
+ }),
177
+ async execute(_id, a) {
178
+ const agent = me();
179
+ if (a.words && a.words.length > 40) return result(`error: words are at most 40 characters (yours: ${a.words.length}). Put the longer text in a note (note_add).`);
180
+ if (a.gesture === "arrow" && !a.to) return result("error: an arrow needs to (the thing it points at)");
181
+ if (a.gesture === "none" && !a.words) return result("error: gesture none needs words");
182
+ if (!named(a.on)) return result("error: on names nothing: give card, block, element, text, mark, note or at");
183
+ const unknown = a.group && !D.groups[a.group] ? a.group : null; // a made-up group: use the usual one instead
184
+ if (unknown) a.group = undefined;
185
+ const where = await check(a.on);
186
+ if (where?.error) return result(`error: ${where.error} (on: ${named(a.on)}). 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.`);
187
+ const to = a.to ? await check(a.to) : null;
188
+ if (to?.error) return result(`error: ${to.error} (to: ${named(a.to)})`);
189
+ let group = a.group ?? run.current[agent] ?? run.own[agent]; // else this turn's own group (not an older one of the user's)
190
+ if (!group || D.groups[group]?.state === "cleared") { // nothing of the user's to answer: a group of the agent's own
191
+ group = run.own[agent] = newGroup(agent, agent, where?.board ?? host.api.board?.current() ?? 1, `${agent}'s ${a.gesture} on ${named(a.on)}`);
192
+ host.broadcast("doodle_group", { group, ...D.groups[group] });
193
+ }
194
+ const id = `m${++D.lastMark}`;
195
+ D.marks[id] = { group, by: agent, kind: "gesture", board: where?.board ?? D.groups[group].board, gesture: a.gesture, on: a.on,
196
+ ...(a.to && { to: a.to }), ...(a.words && { words: a.words }), ...(where?.box && { box: where.box }), ...(to?.box && { toBox: to.box }),
197
+ ...(where?.fixed && { fixed: true }), ...(to?.fixed && { toFixed: true }) }; // fixed: on the page's own controls (window px)
198
+ save();
199
+ host.broadcast("doodle_mark", { id, ...D.marks[id] });
200
+ return result(`drew ${id} (${a.gesture}${a.words ? ` "${a.words}"` : ""} on ${named(a.on)}) in group ${group}`
201
+ + (unknown ? ` (there is no group '${unknown}': leave group out next time)` : "") + "; the user sees it: don't repeat it in your reply"
202
+ + (where ? "" : " (no page open to check the target: it shows when one opens)"));
203
+ },
204
+ });
205
+
206
+ pi.registerTool({
207
+ name: "doodle_clear", label: "Doodle clear",
208
+ description: "A group of marks is dealt with: clear it (the user's marks and yours in it go, for everyone; the user can bring "
209
+ + "them back from the chat log). Clear it when you have made the change it asks for (the change is the answer), or in a "
210
+ + "later turn once the user has seen your marks and moved on. Not while your answer on the board is still to be seen, or its question is open.",
211
+ parameters: Type.Object({
212
+ group: Type.Optional(Type.String({ description: "e.g. g3: the whole group" })),
213
+ mark: Type.Optional(Type.String({ description: "e.g. m7: just this one of your marks, for good (one of several things you marked is dealt with)" })),
214
+ }),
215
+ async execute(_id, a) {
216
+ if (a.mark) {
217
+ const m = D.marks[a.mark];
218
+ if (!m) return result(`error: no mark '${a.mark}'`);
219
+ if (m.by !== me()) return result(`error: ${a.mark} is ${m.by === "user" ? "the user's" : m.by + "'s"}: you can only take away your own marks (clear the group instead)`);
220
+ erase(a.mark);
221
+ return result(`took ${a.mark} away; the rest of ${m.group} stays`);
222
+ }
223
+ if (!a.group) return result("error: give group, or mark");
224
+ const g = D.groups[a.group];
225
+ if (!g) return result(`error: no group '${a.group}'. Groups: ${Object.keys(D.groups).join(", ") || "none"}`);
226
+ if (g.state === "cleared") return result(`${a.group} is already cleared`);
227
+ clear(a.group, me());
228
+ return result(`cleared ${a.group}: its marks are gone from the page`);
229
+ },
230
+ });
231
+
232
+ host.routes.set("/doodle/all", (_req: any, res: any) =>
233
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ groups: D.groups, marks: D.marks })));
234
+
235
+ host.pageHandlers.set("doodle", (msg: any) => {
236
+ const team = host.api.team;
237
+ if (msg.type === "doodle_checked") return run.checks.get(msg.req)?.(msg);
238
+ if (msg.type === "doodle_dim") return dim();
239
+ if (msg.type === "doodle_clear" && D.groups[msg.group]) {
240
+ clear(msg.group, "user");
241
+ const g = D.groups[msg.group];
242
+ if (host.agents.has(g.to)) team?.fyi("page", g.to, `[doodles on the page] the user cleared group ${msg.group} (${g.label}): done with it.`);
243
+ return;
244
+ }
245
+ if (msg.type === "doodle_restore" && D.groups[msg.group]) {
246
+ const g = D.groups[msg.group];
247
+ Object.assign(g, { state: "answered", dim: false });
248
+ delete g.clearedBy;
249
+ save();
250
+ host.broadcast("doodle_group", { group: msg.group, ...g });
251
+ if (host.agents.has(g.to)) team?.fyi("page", g.to, `[doodles on the page] the user brought group ${msg.group} (${g.label}) back onto the page.`);
252
+ return;
253
+ }
254
+ if (msg.type === "doodle_erase" && D.marks[msg.id]) return erase(msg.id); // the eraser: for good
255
+ if (msg.type !== "doodle_send" || !team) return;
256
+ const to = msg.to in team.status() ? msg.to : "agent1";
257
+ dim();
258
+ let group: string | null = null;
259
+ const ids: string[] = [];
260
+ if (msg.marks?.length) {
261
+ const board = Number(msg.board) || 1;
262
+ group = newGroup("user", to, board, msg.marks.map(label).slice(0, 2).join(", ").slice(0, 80));
263
+ for (const m of msg.marks) {
264
+ const id = `m${++D.lastMark}`;
265
+ D.marks[id] = { group, by: "user", kind: m.kind, board, points: m.points, ...(m.text && { text: m.text }), said: m.said, ...(m.anchor && { anchor: m.anchor }) };
266
+ ids.push(id);
267
+ }
268
+ save();
269
+ host.broadcast("doodle_group", { group, ...D.groups[group], marks: Object.fromEntries(ids.map((id) => [id, D.marks[id]])) });
270
+ }
271
+ const { text, shots } = message(msg, group, ids);
272
+ team.send("user", to, text, false, shots);
273
+ });
274
+ }
@@ -0,0 +1,143 @@
1
+ // The page side of the file extension: the file panel on the right edge (slot "side"), one name per row,
2
+ // collapsed and opened by its folder button (remembered in this browser). Hidden while there are no files.
3
+ // Files dragged from the computer onto the page: uploaded (POST /file/upload), then a "file-dropped"
4
+ // DOM event {x, y, files: [{name, path, size}]} at the drop spot (the note extension opens its note box
5
+ // there). Dropped onto the panel itself: only stored.
6
+ // A row dragged out of the panel onto the page: the same event, with that file.
7
+ // A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
8
+ // {file, row} (the viewer extension opens a preview window attached to the row; rows carry data-path).
9
+ // Each row's checkbox (on hover, or on all rows once one is ticked) selects it; the bar then deletes the ticked
10
+ // files for good, after a confirm (POST /file/delete). The bar sits below the rows (sticky), so ticking one never
11
+ // moves the rows.
12
+ // The list comes from GET /file, then from "file" events (after an upload, and after each agent turn).
13
+ export default async function (page) {
14
+ const side = page.slot("side");
15
+ const style = document.createElement("style");
16
+ style.textContent = `
17
+ #side .file-toggle { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; color: var(--muted);
18
+ align-self: flex-end; margin: 16px 9px 0 0; transition: background 0.15s, color 0.15s; }
19
+ #side .file-toggle:hover { background: var(--hover); color: var(--text); }
20
+ #side[data-open] .file-toggle { background: var(--glass-strong); color: var(--text);
21
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06), inset 0 0 0 1px var(--edge); }
22
+ #side .files { display: none; width: 220px; max-height: calc(100vh - 140px); overflow-y: auto; margin: 8px 9px 0 0;
23
+ padding: 8px; box-sizing: border-box; border-radius: var(--radius); font-size: 13px;
24
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
25
+ border: 1px solid var(--edge); box-shadow: var(--shadow); }
26
+ #side[data-open] .files { display: block; }
27
+ #side[data-over] .files { outline: 2px solid var(--accent); outline-offset: -2px; }
28
+ #side .file-row { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 8px; color: var(--soft); cursor: grab; }
29
+ #side .file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ #side .file-row input { appearance: none; -webkit-appearance: none; margin: 0; flex: none; visibility: hidden;
31
+ width: 16px; height: 16px; border-radius: 50%; cursor: pointer; background: var(--glass-strong);
32
+ box-shadow: inset 0 0 0 1px rgba(60, 60, 67, .3); transition: background-color .15s, box-shadow .15s; }
33
+ #side .file-row input:hover { box-shadow: inset 0 0 0 1px var(--accent); }
34
+ #side .file-row input:checked { box-shadow: none; background: var(--accent) center / 10px no-repeat
35
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 6.2l2.3 2.3 4.7-5' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
36
+ #side .file-row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
37
+ #side .file-row:hover input, #side .file-list[data-selecting] .file-row input { visibility: visible; }
38
+ #side .file-bar { display: flex; gap: 6px; position: sticky; bottom: -8px; margin: 4px -8px -8px; padding: 8px;
39
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
40
+ border-top: 1px solid var(--line); }
41
+ #side .file-bar[hidden] { display: none; }
42
+ #side .file-bar button { font: inherit; color: var(--text); background: var(--glass-strong); border: 1px solid var(--line);
43
+ border-radius: 8px; padding: 3px 10px; cursor: pointer; }
44
+ #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); }`;
46
+ document.head.append(style);
47
+
48
+ const toggle = document.createElement("button");
49
+ toggle.className = "file-toggle";
50
+ toggle.dataset.name = "files";
51
+ toggle.title = "Files: drop files anywhere on the page; drag a name onto the page to use it there";
52
+ toggle.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`;
53
+ const list = document.createElement("div");
54
+ list.className = "file-list";
55
+ side.append(toggle, list);
56
+
57
+ const setOpen = (open) => {
58
+ side.toggleAttribute("data-open", open);
59
+ try { localStorage.setItem("file-panel-open", open ? "1" : ""); } catch {}
60
+ };
61
+ let open = true;
62
+ try { open = localStorage.getItem("file-panel-open") !== ""; } catch {}
63
+ setOpen(open);
64
+ toggle.onclick = () => setOpen(!side.hasAttribute("data-open"));
65
+
66
+ const selected = new Set(); // names ticked for deleting
67
+ const bar = Object.assign(document.createElement("div"), { className: "file-bar", hidden: true }); // under the rows
68
+ const del = Object.assign(document.createElement("button"), { className: "delete" });
69
+ const cancel = Object.assign(document.createElement("button"), { textContent: "Cancel" });
70
+ bar.append(del, cancel);
71
+ const showBar = () => {
72
+ bar.hidden = !selected.size;
73
+ list.toggleAttribute("data-selecting", selected.size > 0);
74
+ del.textContent = `Delete ${selected.size} file${selected.size === 1 ? "" : "s"}`;
75
+ };
76
+ cancel.onclick = () => { selected.clear(); list.querySelectorAll(".file-row input").forEach((t) => (t.checked = false)); showBar(); };
77
+ del.onclick = async () => {
78
+ const names = [...selected];
79
+ if (!confirm(`Delete ${names.length === 1 ? names[0] : `${names.length} files`} for good?`)) return;
80
+ try {
81
+ const out = await (await fetch("/file/delete", { method: "POST", body: JSON.stringify({ names }) })).json();
82
+ if (out.errors.length) page.note(`Could not delete: ${out.errors.join("; ")}`);
83
+ } catch (err) { page.note(`Could not delete: ${err}`); }
84
+ selected.clear();
85
+ showBar(); // the rows go with the "file" event
86
+ };
87
+
88
+ const ROW = "application/x-chalky-file"; // a row being dragged: its file as JSON
89
+ function render(files) {
90
+ list.replaceChildren();
91
+ side.hidden = !files.length; // no files yet: no panel (a drop from the computer still works anywhere)
92
+ for (const name of selected) if (!files.some((f) => f.name === name)) selected.delete(name);
93
+ showBar();
94
+ for (const f of files) {
95
+ const row = Object.assign(document.createElement("div"), { className: "file-row", draggable: true });
96
+ const tick = Object.assign(document.createElement("input"), { type: "checkbox", checked: selected.has(f.name), title: "Select to delete" });
97
+ tick.onclick = (e) => { e.stopPropagation(); tick.checked ? selected.add(f.name) : selected.delete(f.name); showBar(); };
98
+ row.append(tick, Object.assign(document.createElement("span"), { textContent: f.name }));
99
+ row.title = `${f.path} · ${Math.ceil(f.size / 1024)} KB · click to preview`;
100
+ row.dataset.path = f.path;
101
+ row.onclick = () => opened(f); // a drag never ends in a click, so dragging a row still works
102
+ row.ondragstart = (e) => { e.dataTransfer.setData(ROW, JSON.stringify(f)); e.dataTransfer.effectAllowed = "copy"; };
103
+ list.append(row);
104
+ }
105
+ list.append(bar);
106
+ }
107
+ page.on("file", ({ files }) => render(files));
108
+ try { render(await (await fetch("/file")).json()); } catch { render([]); }
109
+
110
+ const kind = (e) => {
111
+ const types = [...(e.dataTransfer?.types || [])];
112
+ return types.includes(ROW) ? "row" : types.includes("Files") ? "files" : null;
113
+ };
114
+ const onPanel = (e) => side.contains(e.target);
115
+ const opened = (file) => document.dispatchEvent(new CustomEvent("file-open",
116
+ { detail: { file, row: list.querySelector(`[data-path="${CSS.escape(file.path)}"]`) } }));
117
+ const dropped = (x, y, files) => document.dispatchEvent(new CustomEvent("file-dropped", { detail: { x, y, files } }));
118
+
119
+ document.addEventListener("dragover", (e) => {
120
+ if (!kind(e)) return;
121
+ e.preventDefault();
122
+ side.toggleAttribute("data-over", kind(e) === "files" && onPanel(e));
123
+ });
124
+ document.addEventListener("dragleave", (e) => { if (!e.relatedTarget) side.removeAttribute("data-over"); });
125
+ document.addEventListener("drop", async (e) => {
126
+ const k = kind(e);
127
+ if (!k) return;
128
+ e.preventDefault();
129
+ side.removeAttribute("data-over");
130
+ const { clientX: x, clientY: y } = e;
131
+ if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
132
+ const stored = [];
133
+ for (const f of [...e.dataTransfer.files].slice(0, 10)) {
134
+ try {
135
+ const out = await (await fetch(`/file/upload?name=${encodeURIComponent(f.name)}`, { method: "POST", body: f })).json();
136
+ if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
137
+ else page.note(`Could not upload ${f.name}: ${out.error}`);
138
+ } catch (err) { page.note(`Could not upload ${f.name}: ${err}`); }
139
+ }
140
+ if (stored.length) opened(stored[0]); // the list has its row by now (the "file" event comes before the reply)
141
+ if (stored.length && !onPanel(e)) dropped(x, y, stored);
142
+ });
143
+ }
@@ -0,0 +1,5 @@
1
+ # The user's files
2
+ {workspace}/drops/ holds the user's files (dropped on the page, or saved there by you); they see them in the file
3
+ panel. Read one only when the user points at it or names it; a document (docx, pptx, xlsx, pdf): viewer_read gives
4
+ its text (load it with tool_search). To hand them a file (an export, an image), save it in
5
+ {workspace}/drops/; other work files stay in {workspace}.
@@ -1,19 +1,20 @@
1
- // Files: the user's files, in one folder, <workspace>/drops (port of the Python files plugin; the browser half,
1
+ // Files: the user's files, in one folder, workspace/drops (the browser half,
2
2
  // browser.js, is its files.js). browser.js shows them in a panel on the page's right edge and handles files dragged onto
3
- // the page (from the computer: uploaded here, listed, and a "files-dropped" DOM event so point's browser.js opens its note
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
6
  // sent again when an agent's turn is over: pi's agent_settled).
7
- // POST /files/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
8
- // GET /files [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
9
- // event "files" {files: [...]} to every open page, when the list changed
10
- // guide.md (copied from files.py, short enough to be whole) names pi's read tool for pictures (Python: view_image).
7
+ // POST /file/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
8
+ // GET /file [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
9
+ // POST /file/delete {names: [...]} (names as listed): deleted for good, returns {deleted, errors}
10
+ // event "file" {files: [...]} to every open page, when the list changed
11
+ // guide.md (short enough to be whole) names pi's read tool for pictures.
11
12
  import fs from "node:fs";
12
13
  import path from "node:path";
13
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
 
15
16
  const host = (globalThis as any).chalky;
16
- const DIR = path.join(host.work, "drops");
17
+ const DIR = path.join(host.workspace, "drops");
17
18
  const MAX_FILE = 25 * 1024 * 1024; // bytes per uploaded file
18
19
  const MAX_LISTED = 500; // rows in the panel (the newest)
19
20
  fs.mkdirSync(DIR, { recursive: true }); // there from the first turn, so the agent can save into it
@@ -28,21 +29,38 @@ function listing() { // the files in DIR and its folders (not hidden ones), new
28
29
  .map(({ name, path, size }) => ({ name, path, size }));
29
30
  }
30
31
 
31
- function changed() { // tell the pages, if the list is not what they were last sent
32
+ function changed(always = false) { // tell the pages, if the list is not what they were last sent (or always)
32
33
  const now = JSON.stringify(listing());
33
- if (now === host.shared.get("files-last")) return;
34
- host.shared.set("files-last", now);
35
- host.broadcast("files", { files: JSON.parse(now) });
34
+ if (now === host.shared.get("file-last") && !always) return;
35
+ host.shared.set("file-last", now);
36
+ host.broadcast("file", { files: JSON.parse(now) });
36
37
  }
37
38
 
38
39
  export default function (pi: ExtensionAPI) {
39
- const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
40
- pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.files = guide; });
41
40
  pi.on("agent_settled", () => changed()); // the agent may have saved files there
42
- if (!host.shared.has("files-last")) host.shared.set("files-last", JSON.stringify(listing()));
41
+ if (!host.shared.has("file-last")) host.shared.set("file-last", JSON.stringify(listing()));
43
42
 
44
- host.routes.set("/files", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
45
- host.routes.set("/files/upload", (req: any, res: any) => { // save one file in DIR (a new name if it exists)
43
+ host.routes.set("/file", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
44
+ host.routes.set("/file/delete", (req: any, res: any) => { // delete files in DIR; a name may not lead outside it
45
+ if (req.method !== "POST") return res.writeHead(405).end();
46
+ let body = "";
47
+ req.on("data", (chunk: Buffer) => { body += chunk; });
48
+ req.on("end", () => {
49
+ const deleted: string[] = [], errors: string[] = [];
50
+ let names: string[] = [];
51
+ try { names = JSON.parse(body).names ?? []; } catch {}
52
+ for (const name of names) {
53
+ const p = path.resolve(DIR, String(name));
54
+ if (!p.startsWith(DIR + path.sep)) { errors.push(`${name}: not in the files folder`); continue; }
55
+ if (!fs.statSync(p, { throwIfNoEntry: false })?.isFile()) { errors.push(`${name}: no such file`); continue; }
56
+ fs.rmSync(p);
57
+ deleted.push(name);
58
+ }
59
+ changed(true); // always: files added outside the panel may have made the last list sent stale
60
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ deleted, errors }));
61
+ });
62
+ });
63
+ host.routes.set("/file/upload", (req: any, res: any) => { // save one file in DIR (a new name if it exists)
46
64
  if (req.method !== "POST") return res.writeHead(405).end();
47
65
  const given = path.basename(new URL(req.url, "http://x").searchParams.get("name") ?? "file");
48
66
  const name = given.replace(/[^\w.\-]+/g, "_").replace(/^[._]+|[._]+$/g, "") || "file";
@@ -1,13 +1,13 @@
1
- # Wireframes (the frames extension)
1
+ # Wireframes (the frame extension)
2
2
  The user can lay out placeholder boxes (frames) on the page and write in each what goes there, then send
3
- them all at once: "[frames on the UI]" lists each frame's id, place and words. Frames sent together are
3
+ them all at once: "[frames on the page]" lists each frame's id, place and words. Frames sent together are
4
4
  one design, not separate requests: they share one look, and often each other's data.
5
5
  - The words in a frame are a title, not a spec: "my notes" means a real notes app. Build what a good
6
6
  version of that thing does, the way the user would expect it from any app they know (notes are added
7
7
  and kept, a list can be added to and edited, things saved somewhere can be found there again), working
8
8
  on the page from the first version. Not the least that matches the words, and not a static mock with
9
9
  sample data where the thing should hold the user's own. Say your assumptions in one line at the end.
10
- - Before building, look for the links: between the frames, and between them and the surfaces already on
10
+ - Before building, look for the links: between the frames, and between them and the cards already on
11
11
  the page. Does one show, file, pick from, summarise or act on another's items ("book of the week" from
12
12
  "a reading list"; "my notes on what i'm reading": notes per book)? Do two hold the same things? Should a
13
13
  frame use something already there (an existing notepad) instead of starting its own? The words are short,
@@ -18,20 +18,20 @@ one design, not separate requests: they share one look, and often each other's d
18
18
  Build it like that?" That reply is your answer for this turn; build when they answer. When the links are
19
19
  obvious, build straight away. Never ask about what you can decide well yourself (look, layout, details).
20
20
  - Linked frames that change each other's data (pick a book, file a note into it, mark it read) are one
21
- app: one App folder ({code}/ui/apps/<name>/) with a page per frame (reading/list.html, week.html,
22
- notes.html), each frame its own surface showing its page, all keeping their data in the app's shared
23
- store (agents.load / agents.save, see "App data"). A click in one updates the others at once, on the
24
- page, with no agent in between. Separate component surfaces with {"ref"} props are for a frame that only
21
+ app: one app folder ({built}/apps/<name>/) with a page per frame (reading/list.html, week.html,
22
+ notes.html), each frame its own card showing its page, all keeping their data in the app's shared
23
+ store (agents.load / agents.save: the app extension's README). A click in one updates the others at once,
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 ui_render(surface=<its id>, place=<its place>, ...): its dashed box goes when that
28
- surface appears. Keep the place: the user chose where and how big.
27
+ - Fill a frame with card_show(card=<its id>, place=<its place>, ...): its dashed box goes when that
28
+ card appears. 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
- go to helpers in parallel (spawn_agent, or idle agents you have): tell each one its frame, and what done
31
- means: "surface <id> rendered at <place>, doing <their words>". Say the shared look in each message
32
- (colours, type, spacing, the components to reuse), so the pieces match. A frame that needs another's data
30
+ go to helpers in parallel (team_spawn, or idle agents you have): tell each one its frame, and what done
31
+ means: "card <id> shown at <place>, doing <their words>". Say the shared look in each message
32
+ (colours, type, spacing, the blocks to reuse), so the pieces match. A frame that needs another's data
33
33
  waits for it: hand it out once that one is done. Small or tightly linked frames: do them yourself.
34
34
  - When all are filled, look at the whole page once (the pieces side by side) and fix what doesn't match,
35
35
  and try each link end to end (add a note: does it show in the filer?) by clicking through it with the
36
- ui-check skill.
36
+ browser-check skill.
37
37
  Put back any data your clicks changed, so the user finds what you built, not your test.