@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.
Files changed (137) hide show
  1. package/README.md +16 -5
  2. package/agent/SYSTEM.md +36 -0
  3. package/agent/extensions/app/README.md +72 -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/app/skills/test-your-app/SKILL.md +23 -0
  9. package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
  10. package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
  11. package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
  12. package/agent/extensions/card/README.md +88 -0
  13. package/agent/extensions/card/blocks/Button.js +34 -0
  14. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  15. package/agent/extensions/card/blocks/Markdown.js +37 -0
  16. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  18. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  19. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -129
  20. package/agent/extensions/card/guide.md +13 -0
  21. package/agent/extensions/card/index.ts +370 -0
  22. package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
  23. package/agent/extensions/command/guide.md +2 -0
  24. package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
  25. package/agent/extensions/doodle/README.md +57 -0
  26. package/agent/extensions/doodle/browser.js +1013 -0
  27. package/agent/extensions/doodle/guide.md +22 -0
  28. package/agent/extensions/doodle/index.ts +274 -0
  29. package/agent/extensions/file/browser.js +182 -0
  30. package/agent/extensions/file/guide.md +5 -0
  31. package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
  32. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  33. package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
  34. package/agent/extensions/frame/guide.md +3 -0
  35. package/agent/extensions/frame/index.ts +90 -0
  36. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  37. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -33
  38. package/{app → agent}/extensions/mcp/browser.js +1 -1
  39. package/agent/extensions/mcp/guide.md +3 -0
  40. package/{app → agent}/extensions/mcp/index.ts +2 -4
  41. package/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
  42. package/{app → agent}/extensions/model/index.ts +8 -4
  43. package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
  44. package/agent/extensions/note/README.md +37 -0
  45. package/agent/extensions/note/browser.js +382 -0
  46. package/agent/extensions/note/guide.md +9 -0
  47. package/agent/extensions/note/index.ts +215 -0
  48. package/agent/extensions/page/browser.js +453 -0
  49. package/{app → agent}/extensions/page/index.ts +72 -3
  50. package/{app → agent}/extensions/page/page.html +102 -49
  51. package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
  52. package/agent/extensions/prompt/index.ts +38 -0
  53. package/{app → agent}/extensions/reload/index.ts +3 -3
  54. package/{app → agent}/extensions/screen/browser.js +10 -23
  55. package/{app → agent}/extensions/screen/index.ts +11 -11
  56. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  57. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  58. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  59. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  60. package/{app/extensions/versions → agent/extensions/version}/browser.js +19 -10
  61. package/agent/extensions/version/index.ts +205 -0
  62. package/{app → agent}/extensions/viewer/README.md +4 -4
  63. package/{app → agent}/extensions/viewer/browser.js +8 -8
  64. package/agent/extensions/viewer/guide.md +3 -0
  65. package/{app → agent}/extensions/viewer/index.ts +22 -17
  66. package/{app → agent}/extensions/voice/browser.js +4 -3
  67. package/{app → agent}/extensions/voice/index.ts +8 -6
  68. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  69. package/agent/settings.json +8 -0
  70. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  71. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  72. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +15 -15
  73. package/agent/skills/extension-map/SKILL.md +56 -0
  74. package/agent/skills/show-me/SKILL.md +66 -0
  75. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  76. package/bin/chalky.mjs +2 -2
  77. package/package.json +9 -8
  78. package/server.ts +313 -58
  79. package/app/SYSTEM.md +0 -31
  80. package/app/extensions/appdata/README.md +0 -10
  81. package/app/extensions/appdata/guide.md +0 -4
  82. package/app/extensions/appdata/index.ts +0 -47
  83. package/app/extensions/commands/guide.md +0 -3
  84. package/app/extensions/doodle/README.md +0 -15
  85. package/app/extensions/doodle/browser.js +0 -398
  86. package/app/extensions/doodle/guide.md +0 -6
  87. package/app/extensions/doodle/index.ts +0 -54
  88. package/app/extensions/files/browser.js +0 -96
  89. package/app/extensions/files/guide.md +0 -7
  90. package/app/extensions/frames/guide.md +0 -7
  91. package/app/extensions/frames/index.ts +0 -85
  92. package/app/extensions/mcp/guide.md +0 -5
  93. package/app/extensions/point/README.md +0 -22
  94. package/app/extensions/point/browser.js +0 -247
  95. package/app/extensions/point/guide.md +0 -6
  96. package/app/extensions/point/index.ts +0 -112
  97. package/app/extensions/prompt/index.ts +0 -19
  98. package/app/extensions/ui/README.md +0 -69
  99. package/app/extensions/ui/components/App.js +0 -28
  100. package/app/extensions/ui/components/Button.js +0 -28
  101. package/app/extensions/ui/components/Markdown.js +0 -37
  102. package/app/extensions/ui/guide.md +0 -7
  103. package/app/extensions/ui/index.ts +0 -291
  104. package/app/extensions/versions/index.ts +0 -168
  105. package/app/extensions/viewer/guide.md +0 -5
  106. package/app/mcp.json +0 -3
  107. package/app/settings.json +0 -3
  108. package/app/skills/plugin-map/SKILL.md +0 -49
  109. package/app/ui/apps/.keep +0 -0
  110. package/node_modules/xlsx/CHANGELOG.md +0 -361
  111. package/node_modules/xlsx/LICENSE +0 -201
  112. package/node_modules/xlsx/README.md +0 -41
  113. package/node_modules/xlsx/bin/xlsx.njs +0 -310
  114. package/node_modules/xlsx/bower.json +0 -22
  115. package/node_modules/xlsx/dist/LICENSE +0 -201
  116. package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
  117. package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
  118. package/node_modules/xlsx/dist/cpexcel.js +0 -1506
  119. package/node_modules/xlsx/dist/shim.min.js +0 -2
  120. package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
  121. package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
  122. package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
  123. package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
  124. package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
  125. package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
  126. package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
  127. package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
  128. package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
  129. package/node_modules/xlsx/dist/zahl.d.ts +0 -4
  130. package/node_modules/xlsx/package.json +0 -183
  131. package/node_modules/xlsx/types/index.d.ts +0 -1065
  132. package/node_modules/xlsx/types/tsconfig.json +0 -15
  133. package/node_modules/xlsx/xlsx.js +0 -28103
  134. package/node_modules/xlsx/xlsx.mjs +0 -28228
  135. package/node_modules/xlsx/xlsxworker.js +0 -14
  136. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  137. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
@@ -0,0 +1,370 @@
1
+ // Cards: what the agents show the user, each one thing on a board in the page's main space (browser.js draws them).
2
+ // A card shows blocks (a JSON list of pieces from blocks/*.js: Text, Button, Table...) or an app (a page of its own in
3
+ // built/apps/<name>/, in a frame; the app extension serves its data and its bridge to the agents).
4
+ // Tools card_blocks, card_show, card_update, card_remove. card_show of an app opens the page in the browser-check
5
+ // skill's headless browser and says what it found: errors, and whether the app fits its card. The cards are shared by every agent: one Cards object on
6
+ // the host, made by whichever agent loads first.
7
+ // One door: every change goes through change(): it saves, tells every open page, and calls every watcher with the
8
+ // card, who did it ("user" or the agent) and the words for it, without who ("moved s1"; none: part of a bigger
9
+ // change). restore() puts back a saved state (not a change: no watchers). Watchers, registered from session_start:
10
+ // host.api.card.watch(name, fn(card, by, what)) name: the extension's, so its reload replaces its own
11
+ // (history: a step per change with words; frame: a frame is done when its card appears; version: a version's label).
12
+ // built/card.json each card but its data (versioned) data/card.json each card's data (what users typed)
13
+ // A card: {owner, blocks | app, data, place, board, about, pinned, options, made_from, instruction, draggable,
14
+ // resizable, height (an app's, when nothing else sizes it), kinds (each block's about.version, for upgrades)}.
15
+ // app: the page's path in built/, e.g. "apps/habit/index.html" (card_show takes the full path).
16
+ // GET /card/all every card GET /card/about block name -> its about block
17
+ // GET /card/blocks block name -> mtime GET /card/blocks/<Name>.js, /card/node_modules/{marked,dompurify}/...
18
+ // GET /card/lib/... libraries the agents added for blocks (lib/ here: ES-module builds)
19
+ // page messages card_set, card_prop, card_move, card_board, card_pin, card_keep, card_close, card_action
20
+ // page event "card" {op: "render", card, ...record} | {op: "delete", card} | {op: "reset", cards}
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { execFile } from "node:child_process";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { Type } from "typebox";
26
+
27
+ const host = (globalThis as any).chalky;
28
+ const DESIGN = path.join(host.built, "card.json");
29
+ const DATA = path.join(host.data, "card.json");
30
+ const BLOCKS = path.join(import.meta.dirname, "blocks");
31
+ const LIB = path.join(import.meta.dirname, "lib");
32
+ const LIBS = ["marked/", "dompurify/"]; // the browser libraries blocks import, from the host's node_modules (its package.json)
33
+ const read = (p: string, d: any) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : d);
34
+ const clone = (v: any) => structuredClone(v);
35
+ const isRef = (v: any) => v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).sort().join() === "path,ref";
36
+ const STRUCTURE = ["id", "block", "children", "child"]; // what makes the tree: changed only by card_show
37
+ const propsOf = (b: any) => Object.fromEntries(Object.entries(b).filter(([k]) => ![...STRUCTURE, "when"].includes(k)));
38
+
39
+ function getPath(data: any, p: string) {
40
+ for (const k of p.split("/").filter(Boolean)) data = data?.[Array.isArray(data) ? Number(k) : k];
41
+ return data;
42
+ }
43
+ function setPath(data: any, p: string, value: any) {
44
+ const keys = p.split("/").filter(Boolean);
45
+ for (const k of keys.slice(0, -1)) data = data[k] ??= {};
46
+ data[keys.at(-1)!] = value;
47
+ }
48
+
49
+ // On the host, so they outlive this file's reloads; one per watching extension
50
+ const watchers: Map<string, (id: string, by?: string, what?: string) => void> = host.shared.get("card-watchers") ?? new Map();
51
+ host.shared.set("card-watchers", watchers);
52
+ host.api.card = { watch: (name: string, fn: (id: string, by?: string, what?: string) => void) => watchers.set(name, fn) };
53
+
54
+ class Cards {
55
+ cards: Record<string, any> = read(DESIGN, {});
56
+ constructor() {
57
+ const data = read(DATA, {});
58
+ for (const [id, r] of Object.entries(this.cards)) r.data = data[id] ?? {};
59
+ }
60
+ save() {
61
+ fs.writeFileSync(DESIGN, JSON.stringify(Object.fromEntries(Object.entries(this.cards)
62
+ .map(([id, r]) => [id, { ...r, data: undefined }])), null, 1));
63
+ fs.writeFileSync(DATA, JSON.stringify(Object.fromEntries(Object.entries(this.cards).map(([id, r]) => [id, r.data ?? {}]))));
64
+ }
65
+ get(id: string) { return clone(this.cards[id]); }
66
+ snapshot() { return clone(this.cards); }
67
+ // record null: delete. by: "user" or an agent; what: the history's words for it (none: part of a bigger change,
68
+ // so no step of its own; the last change makes one)
69
+ change(id: string, record: any, by?: string, what?: string) {
70
+ if (record === null) delete this.cards[id];
71
+ else this.cards[id] = record;
72
+ this.save();
73
+ host.broadcast("card", record === null ? { op: "delete", card: id } : { op: "render", card: id, ...record });
74
+ for (const [name, fn] of watchers) {
75
+ try { fn(id, by, what); }
76
+ catch (e) { host.broadcast("error", { agent: "server", text: `card watcher ${name}: ${e}` }); } // one broken watcher doesn't stop the rest
77
+ }
78
+ }
79
+ load() { // read the files again (after a rollback changed built/card.json); true if the cards changed
80
+ const before = JSON.stringify(this.cards), design = read(DESIGN, {}), data = read(DATA, {});
81
+ for (const [id, r] of Object.entries(design) as any) r.data = data[id] ?? {};
82
+ this.cards = design;
83
+ host.broadcast("card", { op: "reset", cards: this.cards });
84
+ return JSON.stringify(this.cards) !== before;
85
+ }
86
+ restore(cards: Record<string, any>) { // a whole saved state (undo, redo, rewind): not a change of its own
87
+ this.cards = clone(cards);
88
+ this.save();
89
+ host.broadcast("card", { op: "reset", cards: this.cards });
90
+ }
91
+ locked(id: string) {
92
+ return this.cards[id]?.pinned ? `error: card '${id}' is pinned by the user, so agents can't change or remove it. `
93
+ + `Make a new card instead (made_from=['${id}'] keeps the link), or ask the user to unpin it.` : null;
94
+ }
95
+ copyIntoUsers(id: string) { // before id goes: cards reading from it get its current values
96
+ const users = Object.keys(this.cards).filter((o) => o !== id && (this.cards[o].blocks ?? [])
97
+ .some((b: any) => Object.values(propsOf(b)).some((v: any) => isRef(v) && v.ref === id)));
98
+ for (const o of users) {
99
+ const r = this.get(o);
100
+ for (const b of r.blocks)
101
+ for (const [k, v] of Object.entries(propsOf(b)) as any)
102
+ if (isRef(v) && v.ref === id) b[k] = clone(getPath(this.cards[id].data, v.path));
103
+ this.change(o, r);
104
+ }
105
+ return users;
106
+ }
107
+ }
108
+
109
+ if (!host.shared.has("card")) host.shared.set("card", new Cards());
110
+ const cards: Cards = host.shared.get("card");
111
+
112
+ function problems(blocks: any[], madeFrom: string[] = []) {
113
+ const catalog = readCatalog(), ids = new Set(blocks.map((b) => b.id));
114
+ const errors = ids.has("root") ? [] : ['one block must have "id": "root"'];
115
+ for (const b of blocks) {
116
+ if (!(b.block in catalog)) { errors.push(`${b.id}: unknown block '${b.block}' (have: ${Object.keys(catalog).join(", ")})`); continue; }
117
+ for (const child of [...(b.children ?? []), ...(b.child ? [b.child] : [])])
118
+ if (!ids.has(child)) errors.push(`${b.id}: child '${child}' is not defined`);
119
+ if (b.when && !(typeof b.when === "object" && typeof b.when.path === "string" && "is" in b.when))
120
+ errors.push(`${b.id}: when must be {"path": "/x", "is": value}`);
121
+ const known = catalog[b.block].about?.props;
122
+ for (const [k, v] of Object.entries(propsOf(b)) as any) {
123
+ if (known && !(k in known)) errors.push(`${b.id}: ${b.block} has no prop '${k}' (props: ${Object.keys(known).join(", ") || "none"})`);
124
+ if (isRef(v) && !(v.ref in cards.cards)) errors.push(`${b.id}.${k}: refers to card '${v.ref}', which does not exist`);
125
+ }
126
+ }
127
+ const byId = new Map(blocks.map((b) => [b.id, b])), drawn = new Set<string>();
128
+ const walk = (id: string) => { // what is drawn: root and what it contains, down
129
+ if (drawn.has(id) || !byId.has(id)) return;
130
+ drawn.add(id);
131
+ const b = byId.get(id);
132
+ for (const c of [...(b.children ?? []), ...(b.child ? [b.child] : [])]) walk(c);
133
+ };
134
+ walk("root");
135
+ const orphans = ids.has("root") ? [...ids].filter((id) => !drawn.has(id)) : [];
136
+ if (orphans.length) errors.push(`${orphans.join(", ")}: not inside root (no block lists ${orphans.length > 1 ? "them" : "it"} in children or child), so never drawn`);
137
+ return [...errors, ...madeFrom.filter((m) => !(m in cards.cards)).map((m) => `made_from: no card '${m}'`)];
138
+ }
139
+
140
+ // An app's page, given as its name ("habit": apps/habit/index.html), its path in built/ or its full path: its path in
141
+ // built/, or an error that says where it should be
142
+ function appPath(given: string): { app?: string; error?: string } {
143
+ const apps = path.join(host.built, "apps");
144
+ const full = !given.includes("/") ? path.join(apps, given, "index.html")
145
+ : path.isAbsolute(given) ? given : path.join(host.built, given.replace(/^\/?(built\/)?/, ""));
146
+ const p = path.resolve(full);
147
+ if (!p.startsWith(apps + path.sep)) return { error: `an app's page is a file in ${apps}/<name>/ (e.g. ${apps}/habit/index.html); ${full} is not` };
148
+ if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return { error: `no file ${p}: write the app's page there first` };
149
+ return { app: path.relative(host.built, p).split(path.sep).join("/") };
150
+ }
151
+
152
+ // The blocks (blocks/*.js): {name: {doc, mtime, about, problem}}; doc = the comment lines at the top
153
+ function readCatalog() {
154
+ const out: Record<string, any> = {};
155
+ for (const f of fs.readdirSync(BLOCKS).filter((f) => f.endsWith(".js")).sort()) {
156
+ const text = fs.readFileSync(path.join(BLOCKS, f), "utf8");
157
+ const doc: string[] = [];
158
+ for (const line of text.split("\n")) {
159
+ if (!line.startsWith("//")) break;
160
+ doc.push(line.slice(2).trim());
161
+ }
162
+ let about = null, problem = null;
163
+ const i = text.indexOf("export const about = ");
164
+ if (i >= 0) {
165
+ try { about = JSON.parse(text.slice(i + 21, text.indexOf("};", i) + 1)); }
166
+ catch (e) { problem = `its about block is not valid JSON (${e})`; }
167
+ }
168
+ out[f.slice(0, -3)] = { doc: doc.join("\n"), mtime: Math.floor(fs.statSync(path.join(BLOCKS, f)).mtimeMs / 1000), about, problem };
169
+ }
170
+ return out;
171
+ }
172
+
173
+ // An app card as the user sees it: the page opened in the headless browser of the browser-check skill, its errors and
174
+ // whether the app fits its card. A few seconds; what it can't do (no browser yet) it says, and the card stays.
175
+ function checkOnBoard(id: string): Promise<string> {
176
+ const script = path.join(host.agent, "skills/browser-check/scripts/check.mjs");
177
+ const measure = `eval:(()=>{const f=document.querySelector('[data-card="${id}"] iframe'); if(!f) return 'none';
178
+ const d=f.contentDocument; return f.clientHeight+' '+d.documentElement.scrollHeight+' '+f.clientWidth+' '+d.documentElement.scrollWidth})()`;
179
+ return new Promise((resolve) => execFile(process.execPath, [script, `http://127.0.0.1:${host.port}/`, "wait:2500", measure],
180
+ { timeout: 45000 }, (err, stdout) => {
181
+ const errors = /errors \((\d+)\):\n?([\s\S]*?)\n?layout problems/.exec(stdout ?? "");
182
+ const m = /-> "(\d+) (\d+) (\d+) (\d+)"/.exec(stdout ?? "");
183
+ if (!errors) return resolve(`not checked (${String(err?.message ?? stdout).split("\n")[0].slice(0, 200)})`);
184
+ const said = Number(errors[1]) ? `${errors[1]} error(s): ${errors[2].trim().slice(0, 600)}` : "no errors";
185
+ const fit = !m ? "not on the board the user is on, so not measured"
186
+ : Number(m[2]) > Number(m[1]) + 2 ? `its content is ${m[2]}px tall in a ${m[1]}px card, so it scrolls inside: make it fit (or pass height=${Number(m[2]) + 8})`
187
+ : Number(m[4]) > Number(m[3]) + 2 ? `its content is ${m[4]}px wide in a ${m[3]}px card: make it fit` : "it fits its card";
188
+ resolve(`checked on the board: ${said}; ${fit}`);
189
+ }));
190
+ }
191
+
192
+ const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
193
+ const Place = Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number(), width: Type.Optional(Type.Number()), height: Type.Optional(Type.Number()) },
194
+ { description: "pin it at a spot (px from the main space's top-left); leave out to stack it" }));
195
+
196
+ export default function (pi: ExtensionAPI) {
197
+ // a removed board (board/) takes its cards with it: one step in the history (undo brings them back)
198
+ pi.on("session_start", () => host.api.board?.onRemove("card", (n: number) => {
199
+ const ids = Object.keys(cards.cards).filter((id) => (cards.cards[id].board ?? 1) === n);
200
+ const gone = ids.map((id) => ({ owner: cards.cards[id].owner, what: `card ${id}` }));
201
+ ids.forEach((id, i) => {
202
+ cards.copyIntoUsers(id); // cards elsewhere reading from it keep its values
203
+ cards.change(id, null, "user", i === ids.length - 1 ? `removed board ${n} (${ids.join(", ")})` : undefined);
204
+ });
205
+ return gone;
206
+ }));
207
+
208
+ pi.registerTool({
209
+ name: "card_blocks", label: "Card blocks", exposure: "deferred", description: "List the blocks you can build a card from, with their props.",
210
+ parameters: Type.Object({}),
211
+ async execute() {
212
+ const catalog = readCatalog();
213
+ const broken = Object.entries(catalog).filter(([, m]: any) => m.problem).map(([n, m]: any) => `${n}: ${m.problem}`);
214
+ return result(`Before your first blocks card or a new block, read ${path.join(import.meta.dirname, "README.md")} (bindings, `
215
+ + `when, tabs, refs, options, new blocks).\n\n` + Object.values(catalog).map((m: any) => m.doc + (m.about ? `\nabout: ${JSON.stringify(m.about)}` : "")).join("\n\n")
216
+ + (broken.length ? `\n\nProblems:\n${broken.join("\n")}` : ""));
217
+ },
218
+ });
219
+
220
+ pi.registerTool({
221
+ name: "card_show", label: "Card show", description: "Show a card on a board, or replace one: built from blocks, or an app "
222
+ + "(a page you wrote in the apps folder of what you built).",
223
+ parameters: Type.Object({
224
+ card: Type.String({ description: "its id: a clear name (the user points at things by name)" }),
225
+ blocks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()), { description: 'e.g. [{"id": "root", "block": "Text", "text": "hi"}]' })),
226
+ app: Type.Optional(Type.String({ description: "instead of blocks: the app's name (its page is <built>/apps/<name>/index.html)" })),
227
+ height: Type.Optional(Type.Number({ description: "an app's height in px when nothing else sizes it (default 400)" })),
228
+ data: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "the blocks' data; leave out to keep the card's data" })),
229
+ place: Place,
230
+ draggable: Type.Optional(Type.Boolean()), resizable: Type.Optional(Type.Boolean()),
231
+ about: Type.Optional(Type.String({ description: "what it is for and why it is built this way (its back side)" })),
232
+ options: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()), { description: 'alternatives instead of blocks or app: [{"label", "blocks" or "app", "data"}]' })),
233
+ made_from: Type.Optional(Type.Array(Type.String())), instruction: Type.Optional(Type.String()),
234
+ board: Type.Optional(Type.Union([Type.Number(), Type.Literal("new")], { description:
235
+ 'a board number, or "new" for a new board; leave out: where the card is, else the board the user is on' })),
236
+ }),
237
+ async execute(_id, a) {
238
+ const agent = pi.getSessionName() ?? "agent";
239
+ const locked = cards.locked(a.card);
240
+ if (locked) return result(locked);
241
+ if ([a.blocks, a.app, a.options].filter((v) => v !== undefined).length !== 1) return result("error: give one of blocks, app or options");
242
+ const old = cards.cards[a.card] ?? {}, catalog = readCatalog(), boards = host.api.board;
243
+ // the content: {blocks, data} or {app}, checked; each option is one too
244
+ const content = (o: any, label = "") => {
245
+ if (o.app !== undefined) {
246
+ const { app, error } = appPath(o.app);
247
+ return error ? { error: (label && `${label}: `) + error } : { app };
248
+ }
249
+ const errors = problems(o.blocks ?? [], a.made_from);
250
+ return errors.length ? { error: (label && `${label}: `) + errors.join("; ") } : { blocks: o.blocks ?? [] };
251
+ };
252
+ const options = [];
253
+ for (const [i, o] of (a.options ?? []).entries()) {
254
+ const label = o.label ?? `option ${i + 1}`, c: any = content(o, label);
255
+ if (c.error) return result("error: " + c.error);
256
+ options.push({ label, ...c, data: o.data ?? {} });
257
+ }
258
+ const main: any = options[0] ?? content(a);
259
+ if (main.error) return result("error: " + main.error);
260
+ if (typeof a.board === "number" && boards && !boards.has(a.board))
261
+ return result(`error: no board ${a.board}. Boards: ${boards.list().join(", ")} (or board "new")`);
262
+ const record: any = { owner: agent, ...(main.app ? { app: main.app } : { blocks: main.blocks }),
263
+ data: options.length ? main.data : a.data ?? old.data ?? {}, place: a.place ?? old.place ?? null,
264
+ draggable: a.draggable ?? true, resizable: a.resizable ?? true, about: a.about ?? old.about ?? "",
265
+ kinds: Object.fromEntries((options.length ? options : [main]).flatMap((o: any) => o.blocks ?? [])
266
+ .map((b: any) => [b.block, catalog[b.block].about?.version ?? 1])) };
267
+ for (const [k, v] of [["height", main.app ? a.height ?? old.height : undefined], ["made_from", a.made_from ?? old.made_from],
268
+ ["instruction", a.instruction ?? old.instruction], ["options", options.length ? options : null]] as const) if (v) record[k] = v;
269
+ // its board (board/): the one asked for, a new one, where it already is, or the one the user is on
270
+ if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
271
+ cards.change(a.card, record, agent, `showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
272
+ const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
273
+ if (options.length) return result(`card '${a.card}' shows ${options.length} options ${where}; the user flips through them and keeps one`);
274
+ if (!main.app) return result(`card '${a.card}' is ${where}`);
275
+ return result(`card '${a.card}' is ${where}, showing ${host.built}/${main.app} (at http://127.0.0.1:${host.port}/built/${main.app}); `
276
+ + await checkOnBoard(a.card) + ". Errors and fit are all this checked: now use it as the user would (load the "
277
+ + "test-your-app skill) before you tell the user what it does.");
278
+ },
279
+ });
280
+
281
+ pi.registerTool({
282
+ name: "card_update", label: "Card update", description: "Change one value in a card's data (e.g. a score or a list), "
283
+ + "or with block, one prop of that block (e.g. a label): the rest of the card stays as it is.",
284
+ parameters: Type.Object({ card: Type.String(),
285
+ path: Type.String({ description: "a data path, e.g. /score; with block: the prop, e.g. text" }), value: Type.Any(),
286
+ block: Type.Optional(Type.String({ description: "a block's id: change its prop instead of data" })) }),
287
+ async execute(_id, a) {
288
+ if (!(a.card in cards.cards)) return result(`error: no card '${a.card}'. Cards: ${Object.keys(cards.cards).join(", ")}`);
289
+ const locked = cards.locked(a.card);
290
+ if (locked) return result(locked);
291
+ const r = cards.get(a.card), agent = pi.getSessionName() ?? "agent";
292
+ if (a.block !== undefined) { // as the page's card_prop: the structure only via card_show
293
+ const b = (r.blocks ?? []).find((b: any) => b.id === a.block);
294
+ if (!b) return result(`error: no block '${a.block}' in ${a.card}. Blocks: ${(r.blocks ?? []).map((b: any) => b.id).join(", ") || "none (an app)"}`);
295
+ if (STRUCTURE.includes(a.path)) return result(`error: '${a.path}' changes the structure: use card_show`);
296
+ b[a.path] = a.value;
297
+ const errors = problems(r.blocks, r.made_from);
298
+ if (errors.length) return result("error: " + errors.join("; "));
299
+ cards.change(a.card, r, agent, `set ${a.path} of ${a.block} in ${a.card}`);
300
+ return result("updated");
301
+ }
302
+ setPath(r.data, a.path, a.value);
303
+ cards.change(a.card, r, agent, `changed ${a.path} in ${a.card}`);
304
+ return result("updated");
305
+ },
306
+ });
307
+
308
+ pi.registerTool({
309
+ name: "card_remove", label: "Card remove", description: "Take a card off its board.",
310
+ parameters: Type.Object({ card: Type.String() }),
311
+ async execute(_id, a) {
312
+ if (!(a.card in cards.cards)) return result(`error: no card '${a.card}'. Cards: ${Object.keys(cards.cards).join(", ")}`);
313
+ const locked = cards.locked(a.card);
314
+ if (locked) return result(locked);
315
+ const users = cards.copyIntoUsers(a.card), agent = pi.getSessionName() ?? "agent";
316
+ cards.change(a.card, null, agent, `removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
317
+ return result("removed" + (users.length ? `; ${users.join(", ")} read from it, so they got a copy of its data` : ""));
318
+ },
319
+ });
320
+
321
+ // The page side: registered once for all agents (keyed by name, so each agent's load replaces the last one)
322
+ const json = (res: any, v: unknown) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(v));
323
+ host.routes.set("/card/all", (_req: any, res: any) => json(res, cards.cards));
324
+ host.routes.set("/card/blocks", (_req: any, res: any) => json(res, Object.fromEntries(Object.entries(readCatalog()).map(([n, m]: any) => [n, m.mtime]))));
325
+ host.routes.set("/card/about", (_req: any, res: any) =>
326
+ json(res, Object.fromEntries(Object.entries(readCatalog()).filter(([, m]: any) => m.about).map(([n, m]: any) => [n, m.about]))));
327
+ host.routes.set("/card/", (req: any, res: any) => { // the blocks' code and the libraries they import
328
+ let url = "";
329
+ try { url = path.posix.normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)); } catch {} // bad %-escape: 404
330
+ if (url.startsWith("/card/blocks/") && url.endsWith(".js")) return host.file(res, path.join(BLOCKS, url.slice("/card/blocks/".length)));
331
+ if (url.startsWith("/card/lib/")) return host.file(res, path.join(LIB, url.slice("/card/lib/".length)));
332
+ const lib = url.slice("/card/node_modules/".length);
333
+ if (url.startsWith("/card/node_modules/") && LIBS.some((l) => lib.startsWith(l))) return host.file(res, path.join(host.modules, lib));
334
+ res.writeHead(404).end(); // also a ".." out of /card/
335
+ });
336
+ host.pageHandlers.set("card", (msg: any) => {
337
+ const { type, card: id } = msg;
338
+ const r = typeof id === "string" ? cards.get(id) : undefined;
339
+ if (!r || !type?.startsWith("card_")) return;
340
+ if (type === "card_close") {
341
+ const users = cards.copyIntoUsers(id);
342
+ cards.change(id, null, "user", `closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
343
+ }
344
+ else if (type === "card_move") {
345
+ if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
346
+ r.place = msg.place; cards.change(id, r, "user", `moved ${id}`);
347
+ } else if (type === "card_board" && host.api.board?.has(msg.board)) { // dropped on another board's thumbnail
348
+ if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
349
+ r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `moved ${id} to board ${msg.board}`);
350
+ } else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `changed ${msg.path} in ${id}`); }
351
+ else if (type === "card_prop") {
352
+ for (const b of r.blocks ?? []) if (b.id === msg.block && !STRUCTURE.includes(msg.prop)) b[msg.prop] = msg.value;
353
+ cards.change(id, r, "user", `set ${msg.prop} of ${msg.block} in ${id}`);
354
+ } else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user", `${r.pinned ? "pinned" : "unpinned"} ${id}`); }
355
+ else if (type === "card_keep" && r.options) {
356
+ const o = r.options[Number(msg.index ?? 0)];
357
+ delete r.options;
358
+ if (o) {
359
+ delete r.blocks; delete r.app;
360
+ Object.assign(r, o.app ? { app: o.app } : { blocks: o.blocks }, { data: o.data });
361
+ cards.change(id, r, "user", `kept ${o.label} of ${id}`);
362
+ }
363
+ } else if (type === "card_action") {
364
+ r.data = msg.data ?? r.data;
365
+ cards.change(id, r, "user", `used ${msg.name} in ${id}`);
366
+ host.send("user", r.owner, `[card action] ${msg.name} on card '${id}' (${msg.block === "app" ? "its app" : `block ${msg.block}`})\n`
367
+ + `context: ${JSON.stringify(msg.context ?? {})}\ndata: ${JSON.stringify(r.data)}`);
368
+ }
369
+ });
370
+ }
@@ -1,9 +1,9 @@
1
- // Browser half of the commands extension: the agents' pi commands (GET /commands) join the "/" list in the chat
1
+ // The page side of the command extension: the agents' pi commands (GET /command) join the "/" list in the chat
2
2
  // input. One runs on the agent picked in "to" (agent1 if none), with the words after it.
3
3
  export default function (page) {
4
4
  const target = () => document.getElementById("to")?.value || "main";
5
- page.on("commands", (ev) => page.note(`${ev.agent}: ${ev.text}`));
6
- fetch("/commands").then((r) => r.ok ? r.json() : []).then((list) => {
5
+ page.on("command", (ev) => page.note(`${ev.agent}: ${ev.text}`));
6
+ fetch("/command").then((r) => r.ok ? r.json() : []).then((list) => {
7
7
  for (const c of list) page.command(c.name, (rest) => {
8
8
  const agent = target() === "main" ? "agent1" : target();
9
9
  page.note(`/${c.name} → ${agent}`);
@@ -0,0 +1,2 @@
1
+ # Commands
2
+ The user's "/" commands for an agent (e.g. /compact) are pi commands in {agent}/extensions/command/index.ts.
@@ -1,10 +1,10 @@
1
- // Agent commands: pi's built-in "/" commands live in its terminal UI only, so an SDK session doesn't have them. Each
1
+ // Commands: pi's built-in "/" commands live in its terminal UI only, so an SDK session doesn't have them. Each
2
2
  // one here is a pi command (pi.registerCommand) that calls what the built-in calls (pi's docs/extensions.md).
3
3
  // The page lists every pi command an agent has and sends one to the agent picked in "to" (pi runs it at once, even
4
4
  // mid-run: session.prompt checks for commands first).
5
- // GET /commands -> [{name, description}] (agent1's, from app/extensions)
5
+ // GET /command -> [{name, description}] (agent1's, from agent/extensions)
6
6
  // page message command {agent, text: "/name args"}
7
- // page event commands {agent, text} -> a plain note (e.g. /compact with nothing new)
7
+ // page event command {agent, text} -> a plain note (e.g. /compact with nothing new)
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -12,12 +12,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  const host = (globalThis as any).chalky;
13
13
 
14
14
  export default function (pi: ExtensionAPI) {
15
- const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
16
- pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.commands = guide; });
17
15
 
18
16
  // pi's /compact: summarize the conversation now (the page shows "history compacted" when it's done). Whatever its
19
17
  // size: keepRecentTokens is 0 for this one (in memory, not saved), so all but the newest message is summarized;
20
- // the automatic compaction keeps app/settings.json's.
18
+ // the automatic compaction keeps agent/settings.json's.
21
19
  pi.registerCommand("compact", {
22
20
  description: "Summarize the agent's conversation now (words after it: what the summary should focus on)",
23
21
  handler: async (args, ctx) => {
@@ -25,7 +23,7 @@ export default function (pi: ExtensionAPI) {
25
23
  // nothing said since the last compaction (its note is the newest entry): skip, it would only re-summarize the summary
26
24
  const last = ctx.sessionManager.getLeafEntry();
27
25
  if (last?.type === "compaction" || (last?.type === "custom_message" && last.customType === "compacted"))
28
- return host.broadcast("commands", { agent, text: "nothing new since the last compaction" });
26
+ return host.broadcast("command", { agent, text: "nothing new since the last compaction" });
29
27
  const settings = host.agents.get(agent).session.settingsManager;
30
28
  const keep = settings.getSettings().compaction?.keepRecentTokens;
31
29
  settings.applyOverrides({ compaction: { keepRecentTokens: 0 } });
@@ -47,11 +45,11 @@ export default function (pi: ExtensionAPI) {
47
45
  if (level && !session.getAvailableThinkingLevels().includes(level))
48
46
  return host.broadcast("error", { agent, text: `thinking: "${level}" isn't one of ${levels}` });
49
47
  if (level) pi.setThinkingLevel(level);
50
- host.broadcast("commands", { agent, text: `thinking ${pi.getThinkingLevel()}${level ? "" : ` (${levels})`}` });
48
+ host.broadcast("command", { agent, text: `thinking ${pi.getThinkingLevel()}${level ? "" : ` (${levels})`}` });
51
49
  },
52
50
  });
53
51
 
54
- // pi's /new: an empty conversation for this agent (the swarm's reset: a new session file, role and thinking kept;
52
+ // pi's /new: an empty conversation for this agent (the team's reset: a new session file, role and thinking kept;
55
53
  // the old file stays). After this command returns: the reset replaces the session that is running it.
56
54
  pi.registerCommand("new", {
57
55
  description: "Start a new, empty conversation for the agent (its role stays; the old one is kept on disk)",
@@ -59,10 +57,10 @@ export default function (pi: ExtensionAPI) {
59
57
  const agent = pi.getSessionName()!;
60
58
  const thinking = pi.getThinkingLevel();
61
59
  setTimeout(async () => {
62
- const error = await host.api.swarm.reset([agent]);
60
+ const error = await host.api.team.reset([agent]);
63
61
  if (error) return host.broadcast("error", { agent, text: `new: ${error}` });
64
62
  host.agents.get(agent).session.setThinkingLevel(thinking);
65
- host.broadcast("commands", { agent, text: "new conversation" });
63
+ host.broadcast("command", { agent, text: "new conversation" });
66
64
  });
67
65
  },
68
66
  });
@@ -71,21 +69,21 @@ export default function (pi: ExtensionAPI) {
71
69
  // happened and where the full conversation still is (pi only appends to the session file; nothing is deleted)
72
70
  pi.on("session_compact", (event, ctx) => {
73
71
  const time = new Date().toTimeString().slice(0, 8);
74
- pi.sendMessage({ customType: "compacted", display: false, content: `[note from system · ${time}]\nYour ` +
72
+ pi.sendMessage({ customType: "compacted", display: false, content: `[fyi from system · ${time}]\nYour ` +
75
73
  `conversation was just compacted (${event.reason}): what came before is the summary above. The full ` +
76
74
  `conversation is still in ${ctx.sessionManager.getSessionFile()} when the summary isn't enough. Its lines are ` +
77
75
  `long JSON entries, so print only around a match: grep -oE '.{0,300}words.{0,300}' <file>` });
78
76
  });
79
77
 
80
- host.routes.set("/commands", (_req: any, res: any) => {
81
- // only ours (app/extensions): pi's built-in extensions' commands are terminal screens (e.g. its MCP's /mcp)
78
+ host.routes.set("/command", (_req: any, res: any) => {
79
+ // only ours (agent/extensions): pi's built-in extensions' commands are terminal screens (e.g. its MCP's /mcp)
82
80
  const runner = host.agents.get("agent1")?.session.extensionRunner;
83
- const list = (runner?.getRegisteredCommands() ?? []).filter((c: any) => c.sourceInfo?.path?.startsWith(host.app))
81
+ const list = (runner?.getRegisteredCommands() ?? []).filter((c: any) => c.sourceInfo?.path?.startsWith(host.agent))
84
82
  .map((c: any) => ({ name: c.invocationName, description: c.description ?? "" }));
85
83
  res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(list));
86
84
  });
87
85
 
88
- host.pageHandlers.set("commands", async (msg: any) => {
86
+ host.pageHandlers.set("command", async (msg: any) => {
89
87
  if (msg.type !== "command") return;
90
88
  const name = host.agents.has(msg.agent) ? msg.agent : "agent1";
91
89
  // a running turn is stopped first, on purpose (compact would abort it anyway; this way it isn't shown as an error)
@@ -0,0 +1,57 @@
1
+ # Doodles (the doodle extension)
2
+ The page is a shared board. The user draws on it (lines, arrows, circles, words in handwriting), clicks through it
3
+ while drawing, then sends it all with a few words; you answer on the same board with your own pen, in your colour.
4
+
5
+ ## What you get: "[doodles on the page] <their words>"
6
+ - group: the id of this batch (g3). Their marks stay on the page after they send; your marks in reply join the group.
7
+ - marks still on the page: one line per mark (m7, m8, ...) with what the page can tell from the geometry alone:
8
+ "closed loop around "12.50" (card shop)", "line under "Total"", "open line from "Milk" to "Bread"", "handwriting
9
+ "why?" beside "Total"", with its box in main-space px. It names what is near; the picture shows what they meant.
10
+ - other groups still on the page: earlier batches, answered or dimmed (older), that nobody has cleared yet.
11
+ - the steps, in order: a picture of the screen as they saw it, with their drawing on it (cropped to the drawing; the
12
+ pictures are attached to the message, in step order), which part of the page it shows (main-space px), the words
13
+ they wrote on it (exactly), then what they clicked to get to the next step.
14
+
15
+ Read it as a story: step 1 is how it is and what they marked; their clicks lead to step 2; and so on. A step with
16
+ nothing drawn after clicks is how the screen looked after them: often the point ("after Save, this should show the date").
17
+ Look at every picture closely: the drawing is the message. A line from A to B links them ("this goes there", "this
18
+ feeds that"); a circle or a box points at the thing to change; a cross removes; handwriting says what.
19
+ - To place something where they drew (on empty space): the picture's area maps to the page, so a mark's spot in the
20
+ picture is a spot on the page; card_show(..., place=<that box>).
21
+ - If the drawing could mean two quite different things, don't change anything: mark the options and ask which.
22
+
23
+ ## Your pen: doodle_mark(gesture, on, to?, words?, group?)
24
+ You name the thing; the page draws it by hand in your colour, and keeps it on that thing if the card moves.
25
+ - gesture: ring (circle it: look here, a problem, an option), underline, strike (through it: this is wrong), tick (✓
26
+ beside it: yes, this one), cross (✕ over it: only for "remove this"; a crossed-out row reads as delete), arrow
27
+ (from on to to), none (only words).
28
+ - on / to: {card, block, element, text, mark, note}, as few as name it exactly. text is the exact words shown on the
29
+ page (the mark goes on just those characters), element a CSS selector or an element's id or data-name (also inside
30
+ an app's frame), mark one of the marks (theirs or yours: m7). Example: {"card": "shop", "text": "12.50"}.
31
+ - The page's own controls can be marked too, by name: {element: "pen"} (the marker), "text", "eraser", "show-marks"
32
+ (the eye), "note" (the note pencil), "frames", "files", "dictate" (the mic), "tools" (the whole palette, top centre),
33
+ "rail" (left edge), "messages", "versions", "message-line" (bottom), "boards" (the shelf above it), "new-board". Such
34
+ marks stay fixed to the window. Good for a tour: ring "pen" with "draw here".
35
+ - at: {x, y, width, height} in window px, as screen_see's picture shows it: a last resort for something with no name
36
+ (empty space, a spot in a picture). Look first (screen_see), then mark.
37
+ - words: at most 40 characters, handwritten beside it. Longer things go in a note (note_add).
38
+ - group: the group you are answering; left out, the group of the message you are answering. With none (a plain
39
+ message, a review you were asked for), your marks start a group of your own.
40
+ The tool checks the thing on the open page and says if it can't find it: then name it as it is on the page.
41
+
42
+ When to use it: to show (ring the cell that is wrong), to point (an arrow from the input to the total), to confirm
43
+ (tick the one you changed), to ask which one (ring two options, words "this one or this?"). A mark is a pointer, not an
44
+ essay: one mark per thing, a few words. Explanations, questions with context, anything they may answer: a note.
45
+
46
+ ## Groups and clearing
47
+ Each send with marks is a group: their marks, then yours. Nothing clears by itself. The user taps a group's ✓ to clear
48
+ it; you call doodle_clear(group) when a group is done. Two cases:
49
+ - They asked for a change ("remove this", "make it green") and you made it: clear the group in that same turn. The
50
+ change is the answer, and their marks now point at something that has changed or gone. Don't add a tick to confirm.
51
+ - Your answer is on the board (you marked options, pointed something out, asked, left a note): leave the group, or
52
+ your marks vanish before they are seen. Clear it in a later turn, once they have moved on or said it's fine.
53
+ Don't clear a group whose question is still open. Earlier groups still on the page (listed with each message) that
54
+ are done: clear them too. When you marked several things (a review) and deal with one of them, take just that mark
55
+ away: doodle_clear(mark="m7") (your own marks only; that one goes for good), and leave the rest up. Cleared marks go
56
+ for everyone; the user can bring them back from the chat log (you get an fyi when they clear or bring one back).
57
+ When the user sends something new, answered groups dim: still there, but older.