@chalky/chalky 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/agent/SYSTEM.md +2 -1
- package/agent/extensions/app/README.md +15 -11
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/agent/extensions/board/browser.js +5 -9
- package/agent/extensions/card/index.ts +18 -18
- package/agent/extensions/file/browser.js +46 -7
- package/agent/extensions/file/index.ts +3 -2
- package/agent/extensions/frame/README.md +2 -2
- package/agent/extensions/frame/index.ts +16 -7
- package/agent/extensions/history/index.ts +3 -1
- package/agent/extensions/note/README.md +3 -1
- package/agent/extensions/note/browser.js +33 -7
- package/agent/extensions/note/guide.md +2 -1
- package/agent/extensions/note/index.ts +36 -13
- package/agent/extensions/page/browser.js +453 -0
- package/agent/extensions/page/index.ts +70 -1
- package/agent/extensions/page/page.html +47 -0
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/screen/browser.js +6 -19
- package/agent/extensions/version/browser.js +9 -0
- package/agent/extensions/version/index.ts +73 -21
- package/agent/skills/design/SKILL.md +1 -1
- package/agent/skills/show-me/SKILL.md +66 -0
- package/package.json +2 -1
- package/server.ts +169 -7
- package/node_modules/xlsx/CHANGELOG.md +0 -361
- package/node_modules/xlsx/LICENSE +0 -201
- package/node_modules/xlsx/README.md +0 -41
- package/node_modules/xlsx/bin/xlsx.njs +0 -310
- package/node_modules/xlsx/bower.json +0 -22
- package/node_modules/xlsx/dist/LICENSE +0 -201
- package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
- package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
- package/node_modules/xlsx/dist/cpexcel.js +0 -1506
- package/node_modules/xlsx/dist/shim.min.js +0 -2
- package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
- package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
- package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
- package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
- package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
- package/node_modules/xlsx/dist/zahl.d.ts +0 -4
- package/node_modules/xlsx/package.json +0 -183
- package/node_modules/xlsx/types/index.d.ts +0 -1065
- package/node_modules/xlsx/types/tsconfig.json +0 -15
- package/node_modules/xlsx/xlsx.js +0 -28103
- package/node_modules/xlsx/xlsx.mjs +0 -28228
- package/node_modules/xlsx/xlsxworker.js +0 -14
package/README.md
CHANGED
|
@@ -21,6 +21,9 @@ MODEL_BASE_URL=https://your-endpoint/v1 MODEL_API_KEY=... MODEL_NAME=your-model
|
|
|
21
21
|
|
|
22
22
|
Then open http://127.0.0.1:3000.
|
|
23
23
|
|
|
24
|
+
To update: `npm update -g @chalky/chalky`, then restart `chalky`. The new version is merged with what your agents
|
|
25
|
+
changed (see `agent/` below).
|
|
26
|
+
|
|
24
27
|
| Variable | |
|
|
25
28
|
|---|---|
|
|
26
29
|
| `MODEL_BASE_URL` | the endpoint, up to `/v1` |
|
|
@@ -34,8 +37,11 @@ Then open http://127.0.0.1:3000.
|
|
|
34
37
|
|
|
35
38
|
`~/.chalky` (or `CHALKY_HOME`) holds:
|
|
36
39
|
|
|
37
|
-
- `agent/`: what the agents are: their prompt, settings and extensions (the page among them)
|
|
38
|
-
|
|
40
|
+
- `agent/`: what the agents are: their prompt, settings and extensions (the page among them). The agents edit it.
|
|
41
|
+
Each new version of the package is merged into it at the next start, as a version in the history: files only the
|
|
42
|
+
new version changed are updated, files only your agents changed stay theirs, and both changes go in when they
|
|
43
|
+
touch different lines. When both changed the same lines, yours stays, the new version waits next to it as
|
|
44
|
+
`<file>.new`, and agent1 merges the two. Undo an update like any change, in Versions.
|
|
39
45
|
- `built/`: what the agents made for you: the cards on your boards, and the apps they wrote.
|
|
40
46
|
- `data/`: what is kept while running: conversations, what you typed into cards and apps, notes, undo history.
|
|
41
47
|
- `workspace/`: the agents' working folder; `workspace/drops/` holds your files.
|
package/agent/SYSTEM.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (cards on
|
|
2
2
|
boards, each showing blocks or an app) fills the screen, and the conversation is a side panel. The user says what they want; you put a
|
|
3
|
-
|
|
3
|
+
first version on screen that is already complete and polished; then they point at things on it and leave notes, and
|
|
4
|
+
you change exactly that. The user marks up details, not missing basics.
|
|
4
5
|
- Build, don't describe. A first version they can point at beats questions you could answer by building.
|
|
5
6
|
- Your final reply is the only text they read in full: one or two sentences on what changed.
|
|
6
7
|
- The page around your work is the user's: change it only when asked. Build inside the main space.
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Making an app (the app extension)
|
|
2
|
-
Everything a usual app needs is here
|
|
2
|
+
Everything a usual app needs is here (you don't need the card README). The design skill goes deeper on charts,
|
|
3
|
+
diagrams, game art and one look across cards: load it when the app has any of those.
|
|
4
|
+
|
|
5
|
+
## Before you write
|
|
6
|
+
The first version is already complete and polished: the user marks up details, not missing basics. So first list,
|
|
7
|
+
in a short message, the 3-5 things that would make this app impressive for this subject: what someone who knows the
|
|
8
|
+
subject would expect to find, and a picture (diagram, chart, animation) wherever one explains faster than text.
|
|
9
|
+
Then build all of them in the first version, not a smaller first cut.
|
|
3
10
|
|
|
4
11
|
## Write it, show it
|
|
5
12
|
- Write {built}/apps/<name>/index.html (other files next to it), then card_show(card=<id>, app=<name>, about="...").
|
|
@@ -45,20 +52,17 @@ The page is light, glassy and quiet; match it unless the user asks for another l
|
|
|
45
52
|
- Show just the thing: no frame or panel around it unless it needs one. Group with flex/grid and gap.
|
|
46
53
|
- Open in a working state (its real data, or a marked example), never an empty shell; `tabular-nums` for digits.
|
|
47
54
|
- Buttons say what happens ("Add habit"); short plain words; status colours always with a word or icon.
|
|
55
|
+
- Ground it in the subject: its real units, terms and conventions (a chess clock shows mm:ss; a kanban card has an
|
|
56
|
+
owner and a state). Real content, never lorem ipsum.
|
|
57
|
+
- Summary before detail; show state in form (a pill, a chip, a stripe) as well as numbers.
|
|
58
|
+
- Finish every state: empty, loading, error, done. Interactive things look interactive (hover, pressed, visible
|
|
59
|
+
keyboard focus); a small transition where something changes, never content hidden waiting for an animation.
|
|
48
60
|
- Avoid the generic AI look: cream and terracotta, purple gradients, emoji as markers, the same shadowed card on
|
|
49
61
|
everything. For a chart, a diagram, a game's art or a restyle, load the design skill.
|
|
50
62
|
|
|
51
63
|
## Check it works
|
|
52
|
-
card_show covers errors and fit.
|
|
53
|
-
|
|
54
|
-
```
|
|
55
|
-
node <that check.mjs> {page}/built/apps/<name>/index.html "eval:(()=>{const i=document.querySelector('#new'); i.value='Test-1';
|
|
56
|
-
i.dispatchEvent(new Event('input',{bubbles:true})); i.form.requestSubmit(); return 'ok'})()" wait:800 \
|
|
57
|
-
"eval:document.body.innerText.includes('Test-1')"
|
|
58
|
-
```
|
|
59
|
-
Set values with eval (`type:` adds to what a field already holds). Run it again to see what a reload keeps.
|
|
60
|
-
Your test writes the user's real data: note it first (cat {data}/app/<name>.json) and put it back afterwards
|
|
61
|
-
(PUT, above). Say only what you saw working.
|
|
64
|
+
card_show covers errors and fit. Then, before telling the user what it does, use it on their page as they would:
|
|
65
|
+
load the test-your-app skill. Say only what you saw working.
|
|
62
66
|
|
|
63
67
|
## More
|
|
64
68
|
- Calculations (totals, streaks, dates) belong in the page's code, never worked out by you and typed in.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-your-app
|
|
3
|
+
description: Test an app you built by using it on the user's page as they would - its main tasks start to finish, a look at it, a reload. Use after card_show, before you tell the user what the app does or that it works.
|
|
4
|
+
---
|
|
5
|
+
# Testing an app you built
|
|
6
|
+
|
|
7
|
+
Load the interact-with-page skill first: this one only adds what testing needs.
|
|
8
|
+
|
|
9
|
+
1. Save the app's data: `cat {data}/app/<name>.json > /tmp/<name>.saved.json` (no file: it has saved nothing yet).
|
|
10
|
+
The test writes the user's real data.
|
|
11
|
+
2. List the user's main tasks, from the app's about and your plan: add, change, remove, filter, the one idea that
|
|
12
|
+
makes it worth having.
|
|
13
|
+
3. Do each task start to finish through the interface, with the helpers, one or a few steps per page_run, each
|
|
14
|
+
ending with a read. Never call the app's own functions or set its state yourself: that tests your code, not what
|
|
15
|
+
the user gets. After each step: did what you expected appear? Any errors in the answer?
|
|
16
|
+
4. Look once: screen_see, and check what reading can't: text cut off or overlapping, things outside their box,
|
|
17
|
+
colours off the palette, a chart that doesn't match its numbers, empty space. If the user doesn't share their
|
|
18
|
+
screen, take a browser-check screenshot of {page}/built/apps/<name>/index.html (--shot) and read it.
|
|
19
|
+
5. Reload: `location.reload()` in one page_run, then a read in the next: what was kept?
|
|
20
|
+
6. Fix what you found (edit the part), show it again, and redo the tasks that touched it.
|
|
21
|
+
7. Put the data back: `curl -X PUT --data @/tmp/<name>.saved.json {page}/app/data/<name>` (nothing saved: `--data '{}'`).
|
|
22
|
+
8. Tell the user what you did and what you saw ("added and ticked a habit, reloaded: kept; long names were cut off,
|
|
23
|
+
now they wrap"), not just "it works".
|
|
@@ -25,9 +25,6 @@ const CSS = `
|
|
|
25
25
|
.boards-shelf .slot .x { display: none; position: absolute; top: 3px; right: 3px; width: 16px; height: 16px; border-radius: 50%;
|
|
26
26
|
font-size: 9px; line-height: 16px; text-align: center; color: var(--danger); background: var(--glass-strong); box-shadow: var(--shadow); }
|
|
27
27
|
.boards-shelf.open .slot:hover .x { display: block; }
|
|
28
|
-
.boards-toast { position: fixed; left: 50%; top: 76px; transform: translateX(-50%); /* under the palette: clear of the shelf */ z-index: 27; display: flex; gap: 12px;
|
|
29
|
-
align-items: center; padding: 7px 14px; border-radius: 10px; background: rgba(29, 29, 31, 0.88); color: #fff; font-size: 13px; }
|
|
30
|
-
.boards-toast button { color: #9cc3f5; font-weight: 600; }
|
|
31
28
|
.boards-shelf .dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(0, 0, 0, 0.22); transition: all 0.15s; }
|
|
32
29
|
.boards-shelf .slot.on .dot { width: 11px; height: 11px; background: var(--text); }
|
|
33
30
|
/* more boards than fit (fit(): clear of the chat panel): the thumbnails scroll inside .view, between ‹ and › */
|
|
@@ -173,8 +170,7 @@ export default async function (page) {
|
|
|
173
170
|
// ✕ on a thumbnail: the board leaves the shelf with a few seconds of Undo, and only then goes from the server
|
|
174
171
|
// (board_remove: its things go with it). Leaving the page in those seconds removes it at once.
|
|
175
172
|
let pending = null; // {n, was: the board shown when ✕ was clicked, timer}
|
|
176
|
-
|
|
177
|
-
document.body.append(toast);
|
|
173
|
+
let toast = null; // the notice with Undo (pageApi.notice)
|
|
178
174
|
function removeBoard(n) {
|
|
179
175
|
if (pending) commit();
|
|
180
176
|
const i = boards.indexOf(n);
|
|
@@ -182,20 +178,20 @@ export default async function (page) {
|
|
|
182
178
|
peek.hidden = true;
|
|
183
179
|
if (current === n) show(boards.filter((b) => b !== n)[Math.max(0, i - 1)]); // the one before it (else after)
|
|
184
180
|
else render();
|
|
185
|
-
toast
|
|
186
|
-
toast
|
|
181
|
+
toast?.close();
|
|
182
|
+
toast = page.notice([`Board ${n} removed`, button("go", "Undo", "Bring it back", undo)], () => pending && commit()); // ✕: removed now
|
|
187
183
|
}
|
|
188
184
|
function commit() {
|
|
189
185
|
clearTimeout(pending.timer);
|
|
190
186
|
page.send({ type: "board_remove", board: pending.n });
|
|
191
187
|
pending = null;
|
|
192
|
-
toast
|
|
188
|
+
toast?.close();
|
|
193
189
|
}
|
|
194
190
|
function undo() {
|
|
195
191
|
clearTimeout(pending.timer);
|
|
196
192
|
const { n, was } = pending;
|
|
197
193
|
pending = null;
|
|
198
|
-
toast
|
|
194
|
+
toast?.close();
|
|
199
195
|
if (was === n) show(n);
|
|
200
196
|
else render();
|
|
201
197
|
}
|
|
@@ -5,10 +5,10 @@
|
|
|
5
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
6
|
// the host, made by whichever agent loads first.
|
|
7
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 and the words for it ("
|
|
9
|
-
// saved state (not a change: no watchers). Watchers, registered from session_start:
|
|
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
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).
|
|
11
|
+
// (history: a step per change with words; frame: a frame is done when its card appears; version: a version's label).
|
|
12
12
|
// built/card.json each card but its data (versioned) data/card.json each card's data (what users typed)
|
|
13
13
|
// A card: {owner, blocks | app, data, place, board, about, pinned, options, made_from, instruction, draggable,
|
|
14
14
|
// resizable, height (an app's, when nothing else sizes it), kinds (each block's about.version, for upgrades)}.
|
|
@@ -200,7 +200,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
200
|
const gone = ids.map((id) => ({ owner: cards.cards[id].owner, what: `card ${id}` }));
|
|
201
201
|
ids.forEach((id, i) => {
|
|
202
202
|
cards.copyIntoUsers(id); // cards elsewhere reading from it keep its values
|
|
203
|
-
cards.change(id, null, "user", i === ids.length - 1 ? `
|
|
203
|
+
cards.change(id, null, "user", i === ids.length - 1 ? `removed board ${n} (${ids.join(", ")})` : undefined);
|
|
204
204
|
});
|
|
205
205
|
return gone;
|
|
206
206
|
}));
|
|
@@ -268,13 +268,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
268
|
["instruction", a.instruction ?? old.instruction], ["options", options.length ? options : null]] as const) if (v) record[k] = v;
|
|
269
269
|
// its board (board/): the one asked for, a new one, where it already is, or the one the user is on
|
|
270
270
|
if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
|
|
271
|
-
cards.change(a.card, record, agent,
|
|
271
|
+
cards.change(a.card, record, agent, `showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
|
|
272
272
|
const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
|
|
273
273
|
if (options.length) return result(`card '${a.card}' shows ${options.length} options ${where}; the user flips through them and keeps one`);
|
|
274
274
|
if (!main.app) return result(`card '${a.card}' is ${where}`);
|
|
275
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
|
|
277
|
-
+ "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
278
|
},
|
|
279
279
|
});
|
|
280
280
|
|
|
@@ -296,11 +296,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
296
296
|
b[a.path] = a.value;
|
|
297
297
|
const errors = problems(r.blocks, r.made_from);
|
|
298
298
|
if (errors.length) return result("error: " + errors.join("; "));
|
|
299
|
-
cards.change(a.card, r, agent,
|
|
299
|
+
cards.change(a.card, r, agent, `set ${a.path} of ${a.block} in ${a.card}`);
|
|
300
300
|
return result("updated");
|
|
301
301
|
}
|
|
302
302
|
setPath(r.data, a.path, a.value);
|
|
303
|
-
cards.change(a.card, r, agent,
|
|
303
|
+
cards.change(a.card, r, agent, `changed ${a.path} in ${a.card}`);
|
|
304
304
|
return result("updated");
|
|
305
305
|
},
|
|
306
306
|
});
|
|
@@ -313,7 +313,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
313
313
|
const locked = cards.locked(a.card);
|
|
314
314
|
if (locked) return result(locked);
|
|
315
315
|
const users = cards.copyIntoUsers(a.card), agent = pi.getSessionName() ?? "agent";
|
|
316
|
-
cards.change(a.card, null, agent,
|
|
316
|
+
cards.change(a.card, null, agent, `removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
|
|
317
317
|
return result("removed" + (users.length ? `; ${users.join(", ")} read from it, so they got a copy of its data` : ""));
|
|
318
318
|
},
|
|
319
319
|
});
|
|
@@ -339,30 +339,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
339
339
|
if (!r || !type?.startsWith("card_")) return;
|
|
340
340
|
if (type === "card_close") {
|
|
341
341
|
const users = cards.copyIntoUsers(id);
|
|
342
|
-
cards.change(id, null, "user", `
|
|
342
|
+
cards.change(id, null, "user", `closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
|
|
343
343
|
}
|
|
344
344
|
else if (type === "card_move") {
|
|
345
345
|
if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
|
|
346
|
-
r.place = msg.place; cards.change(id, r, "user", `
|
|
346
|
+
r.place = msg.place; cards.change(id, r, "user", `moved ${id}`);
|
|
347
347
|
} else if (type === "card_board" && host.api.board?.has(msg.board)) { // dropped on another board's thumbnail
|
|
348
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", `
|
|
350
|
-
} else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `
|
|
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
351
|
else if (type === "card_prop") {
|
|
352
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", `
|
|
354
|
-
} else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user",
|
|
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
355
|
else if (type === "card_keep" && r.options) {
|
|
356
356
|
const o = r.options[Number(msg.index ?? 0)];
|
|
357
357
|
delete r.options;
|
|
358
358
|
if (o) {
|
|
359
359
|
delete r.blocks; delete r.app;
|
|
360
360
|
Object.assign(r, o.app ? { app: o.app } : { blocks: o.blocks }, { data: o.data });
|
|
361
|
-
cards.change(id, r, "user", `
|
|
361
|
+
cards.change(id, r, "user", `kept ${o.label} of ${id}`);
|
|
362
362
|
}
|
|
363
363
|
} else if (type === "card_action") {
|
|
364
364
|
r.data = msg.data ?? r.data;
|
|
365
|
-
cards.change(id, r, "user", `
|
|
365
|
+
cards.change(id, r, "user", `used ${msg.name} in ${id}`);
|
|
366
366
|
host.send("user", r.owner, `[card action] ${msg.name} on card '${id}' (${msg.block === "app" ? "its app" : `block ${msg.block}`})\n`
|
|
367
367
|
+ `context: ${JSON.stringify(msg.context ?? {})}\ndata: ${JSON.stringify(r.data)}`);
|
|
368
368
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Files dragged from the computer onto the page: uploaded (POST /file/upload), then a "file-dropped"
|
|
4
4
|
// DOM event {x, y, files: [{name, path, size}]} at the drop spot (the note extension opens its note box
|
|
5
5
|
// there). Dropped onto the panel itself: only stored.
|
|
6
|
+
// Pasted (Ctrl+V / ⌘V) with files on the clipboard: the same as a drop where the pointer last was (copied
|
|
7
|
+
// images, all named image.png, become pasted-<date>-<time>.png). Text pastes as usual.
|
|
6
8
|
// A row dragged out of the panel onto the page: the same event, with that file.
|
|
7
9
|
// A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
|
|
8
10
|
// {file, row} (the viewer extension opens a preview window attached to the row; rows carry data-path).
|
|
@@ -42,7 +44,11 @@ export default async function (page) {
|
|
|
42
44
|
#side .file-bar button { font: inherit; color: var(--text); background: var(--glass-strong); border: 1px solid var(--line);
|
|
43
45
|
border-radius: 8px; padding: 3px 10px; cursor: pointer; }
|
|
44
46
|
#side .file-bar .delete { color: #fff; background: var(--danger); border-color: transparent; }
|
|
45
|
-
#side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
|
|
47
|
+
#side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
|
|
48
|
+
.file-uploading { position: fixed; z-index: 200000; max-width: 240px; padding: 5px 10px; border-radius: 10px; font-size: 12px;
|
|
49
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
|
|
50
|
+
background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
51
|
+
border: 1px solid var(--edge); box-shadow: var(--shadow); }`;
|
|
46
52
|
document.head.append(style);
|
|
47
53
|
|
|
48
54
|
const toggle = document.createElement("button");
|
|
@@ -129,15 +135,48 @@ export default async function (page) {
|
|
|
129
135
|
side.removeAttribute("data-over");
|
|
130
136
|
const { clientX: x, clientY: y } = e;
|
|
131
137
|
if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
store([...e.dataTransfer.files], x, y, onPanel(e));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Upload files (at most MAX), then preview the first and fire "file-dropped" at (x, y), unless they landed on the panel
|
|
142
|
+
const MAX = 10;
|
|
143
|
+
async function store(all, x, y, panel, names = all.map((f) => f.name)) {
|
|
144
|
+
if (all.length > MAX) page.notice([`You added ${all.length} files: only the first ${MAX} were kept.`]);
|
|
145
|
+
const stored = [], files = all.slice(0, MAX), size = files.reduce((n, f) => n + f.size, 0);
|
|
146
|
+
const tag = Object.assign(document.createElement("div"), { className: "file-uploading",
|
|
147
|
+
textContent: `Uploading ${files.length > 1 ? `${files.length} files` : names[0]} · ${Math.ceil(size / 1024)} KB` });
|
|
148
|
+
Object.assign(tag.style, { left: `${Math.min(x + 14, innerWidth - 260)}px`, top: `${Math.min(y + 16, innerHeight - 40)}px` });
|
|
149
|
+
const slow = setTimeout(() => document.body.append(tag), 500); // only when it takes a while
|
|
150
|
+
for (const [i, f] of files.entries()) {
|
|
134
151
|
try {
|
|
135
|
-
const out = await (await fetch(`/file/upload?name=${encodeURIComponent(
|
|
152
|
+
const out = await (await fetch(`/file/upload?name=${encodeURIComponent(names[i])}`, { method: "POST", body: f })).json();
|
|
136
153
|
if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
|
|
137
|
-
else page.
|
|
138
|
-
} catch (err) { page.
|
|
154
|
+
else page.notice([`Could not upload ${names[i]}: ${out.error}`]);
|
|
155
|
+
} catch (err) { page.notice([`Could not upload ${names[i]}: ${err}`]); }
|
|
139
156
|
}
|
|
157
|
+
clearTimeout(slow); tag.remove();
|
|
140
158
|
if (stored.length) opened(stored[0]); // the list has its row by now (the "file" event comes before the reply)
|
|
141
|
-
if (stored.length && !
|
|
159
|
+
if (stored.length && !panel) dropped(x, y, stored);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Paste (Ctrl+V / ⌘V): files on the clipboard are added as if dropped where the pointer last was.
|
|
163
|
+
// Text pastes as usual. A paste inside a card's app goes to that app (its own page): Chalky never hears it.
|
|
164
|
+
let pointer = { x: innerWidth / 2, y: innerHeight / 2 };
|
|
165
|
+
document.addEventListener("pointermove", (e) => { pointer = { x: e.clientX, y: e.clientY }; }, { passive: true });
|
|
166
|
+
document.addEventListener("paste", (e) => {
|
|
167
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
168
|
+
if (!files.length) { // a Linux file manager copies the file's address, not the file
|
|
169
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
170
|
+
const typing = e.target.closest?.("input, textarea, [contenteditable]");
|
|
171
|
+
if (!typing && /^file:\/\//.test(text.trim())) page.notice(["You copied a file's address, not the file. Drag the file onto the page instead."]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
const d = new Date(), p = (n) => String(n).padStart(2, "0");
|
|
176
|
+
const stamp = `pasted-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
177
|
+
let n = 0; // every copied image arrives as image.png
|
|
178
|
+
const names = files.map((f) => (!f.name || f.name === "image.png") ? `${stamp}${n++ ? `-${n}` : ""}.${f.type.split("/")[1] || "png"}` : f.name);
|
|
179
|
+
const { x, y } = pointer;
|
|
180
|
+
store(files, x, y, side.contains(document.elementFromPoint(x, y)), names);
|
|
142
181
|
});
|
|
143
182
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// the page (from the computer: uploaded here, listed, and a "file-dropped" DOM event so note's browser.js opens its note
|
|
4
4
|
// box there; onto the panel itself: only stored; a row from the panel: the same event with that file).
|
|
5
5
|
// The agent always knows the folder (its prompt section); files it saves there show in the panel too (the list is
|
|
6
|
-
// sent again
|
|
6
|
+
// sent again after each of its tool calls, pi's tool_execution_end, if it changed: a file it saves shows at once,
|
|
7
|
+
// while it is still working).
|
|
7
8
|
// POST /file/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
|
|
8
9
|
// GET /file [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
|
|
9
10
|
// POST /file/delete {names: [...]} (names as listed): deleted for good, returns {deleted, errors}
|
|
@@ -37,7 +38,7 @@ function changed(always = false) { // tell the pages, if the list is not what t
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export default function (pi: ExtensionAPI) {
|
|
40
|
-
pi.on("
|
|
41
|
+
pi.on("tool_execution_end", () => changed()); // the agent may have saved files there
|
|
41
42
|
if (!host.shared.has("file-last")) host.shared.set("file-last", JSON.stringify(listing()));
|
|
42
43
|
|
|
43
44
|
host.routes.set("/file", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
|
|
@@ -24,8 +24,8 @@ one design, not separate requests: they share one look, and often each other's d
|
|
|
24
24
|
on the page, with no agent in between. Separate block cards with {"ref"} props are for a frame that only
|
|
25
25
|
shows another's data (refs only read). Don't change the page's own code (page.html, the browser.js files) to
|
|
26
26
|
link frames.
|
|
27
|
-
- Fill a frame with card_show(
|
|
28
|
-
card appears. Keep the place: the user chose where and how big.
|
|
27
|
+
- Fill a frame with card_show(place=<its place>, ...), with any card id (a name for what it is): its
|
|
28
|
+
dashed box goes when a card appears exactly there. Keep the place: the user chose where and how big.
|
|
29
29
|
- Split the work when it pays: several frames that are each real work and don't depend on each other can
|
|
30
30
|
go to helpers in parallel (team_spawn, or idle agents you have): tell each one its frame, and what done
|
|
31
31
|
means: "card <id> shown at <place>, doing <their words>". Say the shared look in each message
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
|
|
4
4
|
// A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where card_show's place puts a card;
|
|
5
5
|
// board: the one it was drawn on, board/; Implement sends the frames of the board shown), with an
|
|
6
|
-
// id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(
|
|
7
|
-
// when
|
|
6
|
+
// id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(place=<its place>) (any card id);
|
|
7
|
+
// when a card appears at its place (or with its id) (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
|
|
8
8
|
// can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
|
|
9
9
|
// GET /frame {id: frame}
|
|
10
10
|
// page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
|
|
11
|
-
// frame_remove {id}, frame_send {to, board}
|
|
11
|
+
// frame_remove {id}, frame_send {to, board, by?} (by: the agent whose code pressed Implement)
|
|
12
12
|
// event "frame" {frames: {id: frame}} to every open page, when they changed
|
|
13
13
|
// Saved in data/frame.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
|
|
14
14
|
import fs from "node:fs";
|
|
@@ -32,10 +32,17 @@ const place = (f: any) => JSON.stringify({ x: f.x, y: f.y, width: f.width, heigh
|
|
|
32
32
|
|
|
33
33
|
export default function (pi: ExtensionAPI) {
|
|
34
34
|
|
|
35
|
-
// a frame is done when a card with its id
|
|
35
|
+
// a frame is done when a card appears at its place on its board, or with its id (after all extensions loaded: the
|
|
36
|
+
// card one may come later). The place is what the user chose; agents often give the card a name of its own.
|
|
36
37
|
pi.on("session_start", () => {
|
|
37
38
|
host.api.card?.watch("frame", (sid: string) => {
|
|
38
|
-
|
|
39
|
+
const card = host.shared.get("card")?.cards[sid];
|
|
40
|
+
if (!card) return;
|
|
41
|
+
const at = (f: any) => card.place && (card.board ?? 1) === (f.board ?? 1)
|
|
42
|
+
&& ["x", "y", "width", "height"].every((k) => Math.round(card.place[k]) === f[k]);
|
|
43
|
+
const done = Object.keys(frames).filter((id) => id === sid || at(frames[id]));
|
|
44
|
+
for (const id of done) delete frames[id];
|
|
45
|
+
if (done.length) changed();
|
|
39
46
|
});
|
|
40
47
|
host.api.board?.onRemove("frame", (n: number) => { // a removed board (board/) takes its frames with it
|
|
41
48
|
const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
|
|
@@ -70,9 +77,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
77
|
if (!ready.length) return;
|
|
71
78
|
for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
|
|
72
79
|
changed();
|
|
73
|
-
|
|
80
|
+
// by: an agent's code pressed Implement (page/browser.js), not the user
|
|
81
|
+
const who = !msg.by ? "the user laid out" : msg.by === to ? "you pressed Implement for" : `${msg.by} pressed Implement for`;
|
|
82
|
+
const lines = [`[frames on the page] ${who} ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
|
|
74
83
|
...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
|
|
75
|
-
` Fill each with card_show(
|
|
84
|
+
` Fill each with card_show(place=<its place>, board=${board}, ...) (any card id): its dashed box goes when a card appears there.`];
|
|
76
85
|
const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
|
|
77
86
|
if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
|
|
78
87
|
team.send("user", to, lines.join("\n"));
|
|
@@ -88,7 +88,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
88
88
|
pi.on("session_start", () => {
|
|
89
89
|
if (!host.shared.has("history")) host.shared.set("history", new History(host.shared.get("card")));
|
|
90
90
|
host.api.history = host.shared.get("history");
|
|
91
|
-
host.api.card?.watch("history", (_id: string, by?: string, what?: string) => {
|
|
91
|
+
host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { // the card's words don't say who
|
|
92
|
+
if (what) host.api.history.add(by, `${by === "user" ? "you" : by} ${what}`);
|
|
93
|
+
});
|
|
92
94
|
});
|
|
93
95
|
const shared = (): History | undefined => host.shared.get("history");
|
|
94
96
|
|
|
@@ -32,4 +32,6 @@ thing has partAt), its text, and the file it comes from. Act on that exact thing
|
|
|
32
32
|
pointer ("this one") use the pen (doodle_mark) instead.
|
|
33
33
|
- When you did what a note asked, note_done(id): its pin turns into a faded ✓, which the user can reopen or delete. If your
|
|
34
34
|
answer leaves anything to them (you suggest something, ask, or offer a choice), leave the note open: their reply
|
|
35
|
-
comes in the same thread, and you close it once that is settled. The user can close one too (✓
|
|
35
|
+
comes in the same thread, and you close it once that is settled. The user can close one too (✓) or delete it (✕);
|
|
36
|
+
you get an fyi. A note you started that is no longer needed (it explained a step, the step is past) can go for
|
|
37
|
+
good: note_done(id, delete: true). Notes the user or another agent started you only close.
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// tells every open page to pin it, with its drawing). Esc or the button turns it off.
|
|
10
10
|
// Pins: a numbered pin per note (the agents' in their colour), on the thing it is on, scrolling with the main space
|
|
11
11
|
// and moving with its card. Click one for its thread: who wrote what, a reply box (Enter sends it to the note's
|
|
12
|
-
// agent), ✓
|
|
12
|
+
// agent), ✓ (close) and ✕ (delete) at its top right; a closed note stays as a faded ✓ (Reopen). ✕ asks nothing: the pin
|
|
13
|
+
// goes, a notice offers Undo for a few seconds, and only then is the delete sent. An agent's new note, or its reply, opens the bubble while
|
|
13
14
|
// the note stays open. Show marks (doodle/)
|
|
14
15
|
// hides the pins with the other marks.
|
|
15
16
|
// The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
|
|
@@ -246,6 +247,9 @@ export default async function (page) {
|
|
|
246
247
|
.note-pin .bubble .busy { color: var(--muted, #888); font-style: italic; }
|
|
247
248
|
.note-pin .bubble textarea { width: 100%; box-sizing: border-box; min-height: 48px; resize: vertical; font: inherit; padding: 6px 8px;
|
|
248
249
|
border-radius: 8px; border: 1px solid rgba(0, 0, 0, .15); outline: none; background: rgba(255, 255, 255, .8); }
|
|
250
|
+
.note-pin .bubble .top { float: right; display: flex; gap: 2px; margin: -4px -6px 0 6px; }
|
|
251
|
+
.note-pin .bubble .top button { width: 24px; height: 24px; border-radius: 6px; font-size: 13px; color: var(--soft, #555); }
|
|
252
|
+
.note-pin .bubble .top button:hover { background: var(--hover, rgba(0, 0, 0, .05)); color: var(--text, #1d1d1f); }
|
|
249
253
|
.note-pin .bubble .acts { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
|
250
254
|
.note-pin .bubble .acts button { padding: 3px 10px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(0, 0, 0, .12); }
|
|
251
255
|
.note-pin .bubble .acts button.primary { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
|
|
@@ -262,7 +266,21 @@ export default async function (page) {
|
|
|
262
266
|
if (card) { const r = card.getBoundingClientRect(), c = inMain(r.left, r.top); return { x: c.x + n.anchor.dx, y: c.y + n.anchor.dy }; }
|
|
263
267
|
return n.at ?? { x: 0, y: 0 };
|
|
264
268
|
}
|
|
269
|
+
// Deleting: the pin goes at once, and a notice offers Undo for UNDO_MS; only then is the delete sent (so an undo
|
|
270
|
+
// needs nothing from the server, and other pages keep the note until it is really gone)
|
|
271
|
+
const UNDO_MS = 6000;
|
|
272
|
+
const leaving = {}; // id -> timer, while its undo is offered
|
|
273
|
+
function remove(id) {
|
|
274
|
+
openId = null;
|
|
275
|
+
pins[id]?.remove(); delete pins[id];
|
|
276
|
+
const undo = Object.assign(document.createElement("button"), { type: "button", className: "go", textContent: "Undo" });
|
|
277
|
+
const notice = page.notice(["Note deleted", undo], () => {});
|
|
278
|
+
const gone = () => { delete leaving[id]; notice.close(); page.send({ type: "note_delete", id }); };
|
|
279
|
+
leaving[id] = setTimeout(gone, UNDO_MS);
|
|
280
|
+
undo.onclick = () => { clearTimeout(leaving[id]); delete leaving[id]; notice.close(); if (notes[id]) draw(id); };
|
|
281
|
+
}
|
|
265
282
|
function draw(id) {
|
|
283
|
+
if (id in leaving) return; // deleted, its undo still offered
|
|
266
284
|
const n = notes[id];
|
|
267
285
|
let el = pins[id];
|
|
268
286
|
if (!el) {
|
|
@@ -283,6 +301,7 @@ export default async function (page) {
|
|
|
283
301
|
}
|
|
284
302
|
const b = Object.assign(document.createElement("button"), { className: "pin", type: "button", textContent: n.state === "open" ? id.slice(1) : "✓",
|
|
285
303
|
title: `${n.thread[0]?.text ?? ""}${n.state === "open" ? "" : " (closed)"}` });
|
|
304
|
+
b.setAttribute("aria-label", `note ${id}${n.state === "open" ? "" : " (closed)"}: ${n.thread[0]?.text ?? ""}`); // what read() and screen readers say
|
|
286
305
|
Object.assign(b.style, { left: `${p.x}px`, top: `${p.y}px` });
|
|
287
306
|
b.onclick = (e) => { e.stopPropagation(); openId = openId === id ? null : id; for (const k of Object.keys(pins)) draw(k); };
|
|
288
307
|
el.append(b);
|
|
@@ -309,6 +328,16 @@ export default async function (page) {
|
|
|
309
328
|
b.onclick = fn;
|
|
310
329
|
acts.append(b);
|
|
311
330
|
};
|
|
331
|
+
// top right: ✓ closes (a faded pin, can be reopened), ✕ deletes (no question: a few seconds to undo instead)
|
|
332
|
+
const top = Object.assign(document.createElement("div"), { className: "top" });
|
|
333
|
+
const corner = (text, title, fn) => {
|
|
334
|
+
const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, title });
|
|
335
|
+
b.onclick = fn;
|
|
336
|
+
top.append(b);
|
|
337
|
+
};
|
|
338
|
+
if (n.state === "open") corner("✓", "Done: close this note", () => { openId = null; page.send({ type: "note_close", id }); });
|
|
339
|
+
corner("✕", "Delete this note", () => remove(id));
|
|
340
|
+
d.prepend(top);
|
|
312
341
|
if (n.state === "open") {
|
|
313
342
|
const ta = Object.assign(document.createElement("textarea"), { placeholder: "Reply… (Enter sends)", value: drafts[id] ?? "" });
|
|
314
343
|
ta.oninput = () => { drafts[id] = ta.value; };
|
|
@@ -319,13 +348,9 @@ export default async function (page) {
|
|
|
319
348
|
if (e.key === "Escape") { openId = null; draw(id); }
|
|
320
349
|
};
|
|
321
350
|
d.append(ta);
|
|
322
|
-
button("✓ Done", () => { openId = null; page.send({ type: "note_close", id }); });
|
|
323
351
|
button("Reply", go, true);
|
|
324
352
|
setTimeout(() => ta.focus(), 0);
|
|
325
|
-
} else {
|
|
326
|
-
button("Delete", () => { if (confirm("Delete this note for good?")) { openId = null; page.send({ type: "note_delete", id }); } });
|
|
327
|
-
button("Reopen", () => page.send({ type: "note_reopen", id }));
|
|
328
|
-
}
|
|
353
|
+
} else button("Reopen", () => page.send({ type: "note_reopen", id }));
|
|
329
354
|
d.append(acts);
|
|
330
355
|
return d;
|
|
331
356
|
}
|
|
@@ -338,8 +363,9 @@ export default async function (page) {
|
|
|
338
363
|
setInterval(layout, 1500);
|
|
339
364
|
page.on("note_pin", ({ id, type, agent, ts, ...n }) => {
|
|
340
365
|
const reply = notes[id] && n.thread.length > notes[id].thread.length && n.thread.at(-1).by !== "user" && n.state === "open"; // an answer still waiting on the user
|
|
366
|
+
const started = !notes[id] && n.by !== "user" && n.state === "open"; // an agent's new note (the notes there at load come from /note/all)
|
|
341
367
|
notes[id] = n;
|
|
342
|
-
if (reply && openId !== id) openId = id; // an agent
|
|
368
|
+
if ((reply || started) && openId !== id) openId = id; // an agent wrote: show it open, so the user sees it
|
|
343
369
|
if (openId === id && n.state !== "open" && n.thread.at(-1)?.by !== "user") openId = null; // and closed it: done, so out of the way
|
|
344
370
|
draw(id);
|
|
345
371
|
});
|
|
@@ -4,5 +4,6 @@ A note is a thread pinned on a thing; you and the user can both start one. The u
|
|
|
4
4
|
- Change that exact thing. If its text is data (a row, a score), change the card's data, not the block.
|
|
5
5
|
- Answer in the note's thread (note_reply), not in chat. Ask or explain on a thing with note_add(on, text).
|
|
6
6
|
- When you did what it asked, call note_done(id): the pin stays as a faded ✓ the user can reopen. If your answer
|
|
7
|
-
leaves anything to them (a suggestion, a question, a choice), leave it open for their reply.
|
|
7
|
+
leaves anything to them (a suggestion, a question, a choice), leave it open for their reply. A note you started
|
|
8
|
+
that has served its purpose can go: note_done(id, delete: true).
|
|
8
9
|
Before you handle your first note, read README.md.
|