@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.
- package/README.md +10 -6
- package/{app → agent}/SYSTEM.md +16 -11
- package/agent/extensions/app/README.md +68 -0
- package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
- package/agent/extensions/app/guide.md +5 -0
- package/agent/extensions/app/index.ts +66 -0
- package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
- package/agent/extensions/board/browser.js +359 -0
- package/agent/extensions/board/guide.md +4 -0
- package/agent/extensions/board/index.ts +100 -0
- package/agent/extensions/card/README.md +88 -0
- package/agent/extensions/card/blocks/Button.js +34 -0
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
- package/agent/extensions/card/blocks/Markdown.js +37 -0
- package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
- package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/agent/extensions/command/browser.js +13 -0
- package/agent/extensions/command/guide.md +2 -0
- package/agent/extensions/command/index.ts +94 -0
- package/agent/extensions/doodle/README.md +57 -0
- package/agent/extensions/doodle/browser.js +1013 -0
- package/agent/extensions/doodle/guide.md +22 -0
- package/agent/extensions/doodle/index.ts +274 -0
- package/agent/extensions/file/browser.js +143 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +81 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
- package/{app → agent}/extensions/mcp/browser.js +1 -1
- package/agent/extensions/mcp/guide.md +3 -0
- package/{app → agent}/extensions/mcp/index.ts +2 -4
- package/agent/extensions/model/browser.js +363 -0
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/agent/extensions/model/login.ts +215 -0
- package/agent/extensions/note/README.md +35 -0
- package/agent/extensions/note/browser.js +356 -0
- package/agent/extensions/note/guide.md +8 -0
- package/agent/extensions/note/index.ts +192 -0
- package/{app → agent}/extensions/page/index.ts +2 -2
- package/{app → agent}/extensions/page/page.html +55 -49
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +4 -4
- package/{app → agent}/extensions/screen/index.ts +11 -11
- package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
- package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
- package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
- package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
- package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
- package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
- package/{app → agent}/extensions/viewer/README.md +4 -4
- package/{app → agent}/extensions/viewer/browser.js +8 -8
- package/agent/extensions/viewer/guide.md +3 -0
- package/{app → agent}/extensions/viewer/index.ts +22 -17
- package/agent/extensions/voice/browser.js +72 -0
- package/agent/extensions/voice/index.ts +39 -0
- package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
- package/agent/settings.json +8 -0
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
- package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +3 -3
- package/package.json +8 -8
- package/server.ts +162 -63
- package/app/extensions/appdata/README.md +0 -10
- package/app/extensions/appdata/guide.md +0 -4
- package/app/extensions/appdata/index.ts +0 -47
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -388
- package/app/extensions/doodle/guide.md +0 -6
- package/app/extensions/doodle/index.ts +0 -54
- package/app/extensions/files/browser.js +0 -96
- package/app/extensions/files/guide.md +0 -7
- package/app/extensions/frames/guide.md +0 -7
- package/app/extensions/frames/index.ts +0 -76
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -245
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -97
- package/app/extensions/prompt/index.ts +0 -19
- package/app/extensions/ui/README.md +0 -69
- package/app/extensions/ui/components/App.js +0 -28
- package/app/extensions/ui/components/Button.js +0 -28
- package/app/extensions/ui/components/Markdown.js +0 -37
- package/app/extensions/ui/guide.md +0 -7
- package/app/extensions/ui/index.ts +0 -271
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The page side of the frame extension: a frame tool in the palette (a dashed box). While it is on, drag on the
|
|
2
2
|
// main space to lay down a frame: a dashed box with a field for what goes there (Enter finishes it). While the
|
|
3
3
|
// tool is on, an Implement button (a hammer with the number of frames in a bubble, in the row under the tool)
|
|
4
|
-
// sends every unsent frame that has words, together (
|
|
5
|
-
// off, the bubble sits on the tool itself, so waiting frames still show. Until then, a frame's corner resizes it, like a
|
|
6
|
-
// Sent frames stay as they are, marked waiting, until an agent renders a
|
|
4
|
+
// sends every unsent frame that has words, together (frame_send, to the agent picked in the chat); while it is
|
|
5
|
+
// off, the bubble sits on the tool itself, so waiting frames still show. Until then, a frame's corner resizes it, like a card's (frame_place).
|
|
6
|
+
// Sent frames stay as they are, marked waiting, until an agent renders a card with their id (index.ts
|
|
7
7
|
// then drops them); ✕ on a frame, or the eraser passing over an unsent one ("erase" event), removes it.
|
|
8
8
|
// Esc or the tool turns drawing off.
|
|
9
|
-
// Frames live in the main space (slot "top"), in main-space px, so they scroll with the
|
|
9
|
+
// Frames live in the main space (slot "top"), in main-space px, so they scroll with the cards.
|
|
10
10
|
export default async function (page) {
|
|
11
11
|
const area = page.slot("top");
|
|
12
12
|
const style = document.createElement("style");
|
|
@@ -31,15 +31,17 @@ export default async function (page) {
|
|
|
31
31
|
border: solid var(--muted); border-width: 0 2px 2px 0; border-radius: 0 0 4px 0; cursor: nwse-resize; opacity: 0; }
|
|
32
32
|
#top .frame:hover .frame-resize { opacity: 1; }
|
|
33
33
|
#top .frame.sent .frame-resize { display: none; }
|
|
34
|
-
#status button.
|
|
34
|
+
#status button.frame-send { color: var(--accent); }`;
|
|
35
35
|
document.head.append(style);
|
|
36
36
|
|
|
37
37
|
const button = document.createElement("button");
|
|
38
38
|
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="14" rx="2" stroke-dasharray="3 2.5"/></svg>`;
|
|
39
|
+
button.dataset.name = "frames"; // names an agent's pen can point at (doodle_mark on: {element: "frames"})
|
|
39
40
|
button.title = "Frames: drag boxes on the page and write what goes in each, then Implement them together (Esc to stop)";
|
|
40
41
|
button.append(Object.assign(document.createElement("span"), { className: "badge" })); // frames waiting, while the tool is off
|
|
41
42
|
const send = document.createElement("button");
|
|
42
|
-
send.className = "
|
|
43
|
+
send.className = "frame-send";
|
|
44
|
+
send.dataset.name = "implement";
|
|
43
45
|
send.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 12-8.4 8.4a2.1 2.1 0 0 1-3-3L12 9"/><path d="m18 15 4-4"/>` +
|
|
44
46
|
`<path d="m21.5 11.5-1.9-1.9a2 2 0 0 1-.6-1.4V7l-2.3-2.3a6 6 0 0 0-4.2-1.7H9l.9.8A6.2 6.2 0 0 1 12 8.4V10l2 2h1.2a2 2 0 0 1 1.4.6l1.9 1.9"/></svg>` +
|
|
45
47
|
`<span class="badge"></span>`; // the hammer: build these
|
|
@@ -57,23 +59,25 @@ export default async function (page) {
|
|
|
57
59
|
button.classList.toggle("on", v);
|
|
58
60
|
area.classList.toggle("framing", v);
|
|
59
61
|
showReady();
|
|
60
|
-
if (v) document.dispatchEvent(new CustomEvent("tool-on", { detail: "
|
|
62
|
+
if (v) document.dispatchEvent(new CustomEvent("tool-on", { detail: "frame" })); // the other tools turn off
|
|
61
63
|
};
|
|
62
64
|
button.onclick = () => setOn(!on);
|
|
63
|
-
document.addEventListener("tool-on", (e) => { if (on && e.detail !== "
|
|
65
|
+
document.addEventListener("tool-on", (e) => { if (on && e.detail !== "frame") setOn(false); }); // one tool at a time
|
|
64
66
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
|
|
65
67
|
send.onclick = () => {
|
|
66
68
|
for (const flush of Object.values(timers)) { clearTimeout(flush.t); flush(); } // text still waiting on the typing pause goes first
|
|
67
|
-
page.send({ type: "
|
|
69
|
+
page.send({ type: "frame_send", to: document.getElementById("to")?.value, board: board() });
|
|
68
70
|
};
|
|
71
|
+
const board = () => Number(document.body.dataset.board) || 1; // the board shown (board/): frames are drawn and sent there
|
|
72
|
+
document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
|
|
69
73
|
|
|
70
|
-
const inMain = (x, y) => { // window px -> main-space px (as
|
|
74
|
+
const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
|
|
71
75
|
const m = area.getBoundingClientRect();
|
|
72
76
|
return { x: Math.round(x - m.left + area.scrollLeft), y: Math.round(y - m.top + area.scrollTop) };
|
|
73
77
|
};
|
|
74
78
|
const put = (box, f) => Object.assign(box.style, { left: `${f.x}px`, top: `${f.y}px`, width: `${f.width}px`, height: `${f.height}px` });
|
|
75
79
|
|
|
76
|
-
// Drawing: on the main space, over empty space or
|
|
80
|
+
// Drawing: on the main space, over empty space or cards, but not on a frame (that is for typing).
|
|
77
81
|
let start = null, ghost = null, focusNew = false;
|
|
78
82
|
area.addEventListener("mousedown", (e) => {
|
|
79
83
|
if (!on || e.button !== 0 || e.target.closest(".frame")) return;
|
|
@@ -83,7 +87,7 @@ export default async function (page) {
|
|
|
83
87
|
ghost = Object.assign(document.createElement("div"), { className: "frame drawing" });
|
|
84
88
|
area.append(ghost);
|
|
85
89
|
}, true);
|
|
86
|
-
area.addEventListener("click", (e) => { if (on && !e.target.closest(".frame")) e.stopPropagation(); }, true); //
|
|
90
|
+
area.addEventListener("click", (e) => { if (on && !e.target.closest(".frame")) e.stopPropagation(); }, true); // cards don't react
|
|
87
91
|
window.addEventListener("mousemove", (e) => {
|
|
88
92
|
if (!start) return;
|
|
89
93
|
const p = inMain(e.clientX, e.clientY);
|
|
@@ -94,7 +98,7 @@ export default async function (page) {
|
|
|
94
98
|
const f = { x: parseInt(ghost.style.left), y: parseInt(ghost.style.top), width: parseInt(ghost.style.width), height: parseInt(ghost.style.height) };
|
|
95
99
|
ghost.remove();
|
|
96
100
|
start = ghost = null;
|
|
97
|
-
if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f }); } // a click or a slip is not a frame
|
|
101
|
+
if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f, board: board() }); } // a click or a slip is not a frame
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
const boxes = {}; // id -> its box on the page
|
|
@@ -103,7 +107,7 @@ export default async function (page) {
|
|
|
103
107
|
document.addEventListener("erase", (e) => { // the doodle eraser at {x, y}, main px: unsent frames under it go
|
|
104
108
|
const p = e.detail;
|
|
105
109
|
for (const [id, f] of Object.entries(shown)) {
|
|
106
|
-
if (f.sent || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
|
|
110
|
+
if (f.sent || (f.board ?? 1) !== board() || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
|
|
107
111
|
delete shown[id]; // once, though the eraser keeps moving over it
|
|
108
112
|
page.send({ type: "frame_remove", id });
|
|
109
113
|
}
|
|
@@ -134,17 +138,18 @@ export default async function (page) {
|
|
|
134
138
|
newest = box;
|
|
135
139
|
}
|
|
136
140
|
put(box, f);
|
|
141
|
+
box.dataset.board = f.board ?? 1; // board/ shows it on its board
|
|
137
142
|
box.classList.toggle("sent", f.sent);
|
|
138
143
|
const text = box.querySelector("textarea");
|
|
139
144
|
text.readOnly = f.sent;
|
|
140
145
|
if (document.activeElement !== text) text.value = f.text;
|
|
141
146
|
}
|
|
142
147
|
if (focusNew && newest) { focusNew = false; newest.querySelector("textarea").focus(); }
|
|
143
|
-
ready = Object.values(frames).filter((f) => !f.sent && f.text.trim()).length;
|
|
148
|
+
ready = Object.values(frames).filter((f) => !f.sent && f.text.trim() && (f.board ?? 1) === board()).length;
|
|
144
149
|
showReady();
|
|
145
150
|
send.title = `Implement: send ${ready} frame${ready > 1 ? "s" : ""} to the agent to build, together`;
|
|
146
151
|
}
|
|
147
|
-
function resize(box, id, e) { // drag the corner (as
|
|
152
|
+
function resize(box, id, e) { // drag the corner (as card/browser.js does for cards); on release the new size is saved
|
|
148
153
|
e.preventDefault();
|
|
149
154
|
const corner = e.currentTarget, x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
|
|
150
155
|
corner.setPointerCapture(e.pointerId);
|
|
@@ -158,6 +163,6 @@ export default async function (page) {
|
|
|
158
163
|
if (place) page.send({ type: "frame_place", id, ...place });
|
|
159
164
|
};
|
|
160
165
|
}
|
|
161
|
-
page.on("
|
|
162
|
-
try { render(await (await fetch("/
|
|
166
|
+
page.on("frame", (ev) => render(ev.frames));
|
|
167
|
+
try { render(await (await fetch("/frame")).json()); } catch {}
|
|
163
168
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Frames: wireframes the user lays out for the agents to fill. browser.js adds a tool to the palette: drag boxes on the main space, type in each what goes there,
|
|
2
|
+
// then press Implement. All the frames go together, as one message, to the agent picked in the chat (else agent1),
|
|
3
|
+
// so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
|
|
4
|
+
// A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where card_show's place puts a card;
|
|
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(card="frame-1", place=<its place>);
|
|
7
|
+
// when that card appears (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
|
|
8
|
+
// can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
|
|
9
|
+
// GET /frame {id: frame}
|
|
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}
|
|
12
|
+
// event "frame" {frames: {id: frame}} to every open page, when they changed
|
|
13
|
+
// Saved in data/frame.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const host = (globalThis as any).chalky;
|
|
19
|
+
const FILE = path.join(host.data, "frame.json");
|
|
20
|
+
if (!host.shared.has("frame"))
|
|
21
|
+
host.shared.set("frame", fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, frames: {} });
|
|
22
|
+
const saved = host.shared.get("frame"); // {last, frames}
|
|
23
|
+
const frames: Record<string, any> = saved.frames;
|
|
24
|
+
|
|
25
|
+
function changed() {
|
|
26
|
+
fs.writeFileSync(FILE + ".tmp", JSON.stringify(saved));
|
|
27
|
+
fs.renameSync(FILE + ".tmp", FILE);
|
|
28
|
+
host.broadcast("frame", { frames });
|
|
29
|
+
}
|
|
30
|
+
const box = (msg: any, f: any = {}) => Object.fromEntries(["x", "y", "width", "height"].map((k) => [k, Math.round(Number(msg[k] ?? f[k] ?? 0))]));
|
|
31
|
+
const place = (f: any) => JSON.stringify({ x: f.x, y: f.y, width: f.width, height: f.height });
|
|
32
|
+
|
|
33
|
+
export default function (pi: ExtensionAPI) {
|
|
34
|
+
|
|
35
|
+
// a frame is done when a card with its id appears (after all extensions loaded: the card one may come later)
|
|
36
|
+
pi.on("session_start", () => {
|
|
37
|
+
host.api.card?.watch("frame", (sid: string) => {
|
|
38
|
+
if (frames[sid] && host.shared.get("card")?.cards[sid]) { delete frames[sid]; changed(); }
|
|
39
|
+
});
|
|
40
|
+
host.api.board?.onRemove("frame", (n: number) => { // a removed board (board/) takes its frames with it
|
|
41
|
+
const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
|
|
42
|
+
const gone = ids.map((id) => ({ owner: frames[id].sent ? frames[id].to : undefined, what: `frame ${id}` }));
|
|
43
|
+
for (const id of ids) delete frames[id];
|
|
44
|
+
if (ids.length) changed();
|
|
45
|
+
return gone;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
host.routes.set("/frame", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(frames)));
|
|
50
|
+
|
|
51
|
+
host.pageHandlers.set("frame", (msg: any) => {
|
|
52
|
+
const f = frames[msg.id], team = host.api.team;
|
|
53
|
+
if (msg.type === "frame_add") {
|
|
54
|
+
frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false, board: Number(msg.board) || 1 };
|
|
55
|
+
changed();
|
|
56
|
+
} else if (msg.type === "frame_place" && f && !f.sent) {
|
|
57
|
+
Object.assign(f, box(msg, f));
|
|
58
|
+
changed();
|
|
59
|
+
} else if (msg.type === "frame_text" && f && !f.sent) {
|
|
60
|
+
f.text = String(msg.text ?? "");
|
|
61
|
+
changed();
|
|
62
|
+
} else if (msg.type === "frame_remove" && f) {
|
|
63
|
+
delete frames[msg.id];
|
|
64
|
+
changed();
|
|
65
|
+
if (f.sent) team?.send("user", f.to, `[frames on the page] the user removed ${msg.id} ("${f.text}"): don't fill it.`, true);
|
|
66
|
+
} else if (msg.type === "frame_send" && team) {
|
|
67
|
+
const to = msg.to in team.status() ? msg.to : "agent1";
|
|
68
|
+
const board = Number(msg.board) || 1; // the board shown: its frames only (the user sees no others)
|
|
69
|
+
const ready = Object.entries(frames).filter(([, f]: any) => !f.sent && f.text.trim() && (f.board ?? 1) === board);
|
|
70
|
+
if (!ready.length) return;
|
|
71
|
+
for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
|
|
72
|
+
changed();
|
|
73
|
+
const lines = [`[frames on the page] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
|
|
74
|
+
...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
|
|
75
|
+
` Fill each with card_show(card=<its id>, place=<its place>, board=${board}, ...); its dashed box goes when it appears.`];
|
|
76
|
+
const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
|
|
77
|
+
if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
|
|
78
|
+
team.send("user", to, lines.join("\n"));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The page side of the history extension: undo, redo and rewind the cards.
|
|
2
2
|
// Ctrl+Z / ⌘Z: undo. Ctrl+Shift+Z / ⌘⇧Z / Ctrl+Y: redo. (Not while typing in a field: that undoes the typing.)
|
|
3
3
|
// Hold Alt (Option) and drag sideways anywhere: scrub along the history; let go to stay there.
|
|
4
4
|
// Doing something new after going back starts a branch; a note offers to bring the other one back.
|
|
5
|
-
// It only sends messages (
|
|
5
|
+
// It only sends messages (history_undo, history_redo, history_goto); the server puts the cards back and the card extension redraws them.
|
|
6
6
|
const CSS = `
|
|
7
|
-
#
|
|
7
|
+
#history-bar { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 9999; display: flex;
|
|
8
8
|
flex-direction: column; align-items: center; gap: 6px; max-width: calc(100vw - 32px); padding: 8px 14px; border-radius: 12px;
|
|
9
9
|
font-size: 12px; color: #fff; background: rgba(29, 29, 31, .92); pointer-events: none; }
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
#history-bar .ticks { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
|
|
11
|
+
#history-bar .ticks i { width: 6px; height: 14px; border-radius: 2px; background: #fff; opacity: .3; }
|
|
12
|
+
#history-bar .ticks i.now { opacity: 1; background: #f4d35e; }
|
|
13
|
+
#history-bar .ticks i.ahead { opacity: .12; }
|
|
14
|
+
#history-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 9999; display: flex; gap: 10px;
|
|
15
15
|
align-items: center; padding: 8px 14px; border-radius: 12px; font-size: 13px; color: #fff; background: rgba(29, 29, 31, .92); }
|
|
16
|
-
#
|
|
16
|
+
#history-toast button { font: inherit; color: inherit; background: none; border: 1px solid currentColor; border-radius: 8px;
|
|
17
17
|
padding: 2px 10px; cursor: pointer; }`;
|
|
18
18
|
|
|
19
19
|
export default function (page) {
|
|
20
|
-
document.head.insertAdjacentHTML("beforeend", `<style id="
|
|
21
|
-
const bar = Object.assign(document.createElement("div"), { id: "
|
|
22
|
-
const toastEl = Object.assign(document.createElement("div"), { id: "
|
|
20
|
+
document.head.insertAdjacentHTML("beforeend", `<style id="history-css">${CSS}</style>`);
|
|
21
|
+
const bar = Object.assign(document.createElement("div"), { id: "history-bar", hidden: true });
|
|
22
|
+
const toastEl = Object.assign(document.createElement("div"), { id: "history-toast", hidden: true });
|
|
23
23
|
document.body.append(bar, toastEl);
|
|
24
24
|
let toastTimer;
|
|
25
25
|
function toast(text, button, onclick) {
|
|
@@ -35,8 +35,8 @@ export default function (page) {
|
|
|
35
35
|
document.addEventListener("keydown", (e) => {
|
|
36
36
|
if (!(e.ctrlKey || e.metaKey) || typing(e)) return;
|
|
37
37
|
const k = e.key.toLowerCase();
|
|
38
|
-
if (k === "z" && !e.shiftKey) { e.preventDefault(); page.send({ type: "
|
|
39
|
-
else if ((k === "z" && e.shiftKey) || k === "y") { e.preventDefault(); page.send({ type: "
|
|
38
|
+
if (k === "z" && !e.shiftKey) { e.preventDefault(); page.send({ type: "history_undo" }); }
|
|
39
|
+
else if ((k === "z" && e.shiftKey) || k === "y") { e.preventDefault(); page.send({ type: "history_redo" }); }
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
// {x0, x, line, start, i} while Alt-dragging. It starts at the press, before the history has come (line: null till
|
|
@@ -57,7 +57,7 @@ export default function (page) {
|
|
|
57
57
|
const i = Math.max(0, Math.min(scrub.line.length - 1, scrub.start + Math.round((scrub.x - scrub.x0) / step)));
|
|
58
58
|
if (i === scrub.i) return;
|
|
59
59
|
scrub.i = i;
|
|
60
|
-
page.send({ type: "
|
|
60
|
+
page.send({ type: "history_goto", step: scrub.line[i].n });
|
|
61
61
|
showBar();
|
|
62
62
|
}
|
|
63
63
|
document.addEventListener("pointerdown", async (e) => {
|
|
@@ -65,7 +65,7 @@ export default function (page) {
|
|
|
65
65
|
e.preventDefault(); e.stopPropagation();
|
|
66
66
|
const me = scrub = { x0: e.clientX, x: e.clientX, line: null };
|
|
67
67
|
let history;
|
|
68
|
-
try { history = await (await fetch("/
|
|
68
|
+
try { history = await (await fetch("/history")).json(); } catch { if (scrub === me) scrub = null; return; }
|
|
69
69
|
if (scrub !== me) return; // let go before the history came: nothing to do
|
|
70
70
|
me.line = history.line;
|
|
71
71
|
me.start = me.i = history.line.findIndex((s) => s.n === history.at);
|
|
@@ -89,9 +89,9 @@ export default function (page) {
|
|
|
89
89
|
if (ahead) toast("Gone back. Do something new to branch from here, or Ctrl+Shift+Z to come forward.");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
page.on("
|
|
92
|
+
page.on("history", (ev) => {
|
|
93
93
|
if (ev.event === "moved" && !scrub) toast(`Back to just after: ${ev.what}`);
|
|
94
94
|
if (ev.event === "branched") toast("You branched from an earlier moment. The other version is kept.", "Bring it back",
|
|
95
|
-
() => page.send({ type: "
|
|
95
|
+
() => page.send({ type: "history_goto", step: ev.tip }));
|
|
96
96
|
});
|
|
97
97
|
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
// Every state of the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// Kept in state/ui-history.jsonl (one step per line), for HISTORY_KEEP_DAYS (default 7). Older steps are dropped at
|
|
1
|
+
// Every state of the cards, kept: undo, redo and rewind for the user (browser.js: the keys and the bar). After each
|
|
2
|
+
// change (the card extension's change() with words for it, from the user or an agent) it saves a step: a full copy
|
|
3
|
+
// of all cards, design and data. Steps form a tree: each has a parent, so undoing and then doing something new
|
|
4
|
+
// starts a branch and loses nothing; redo follows the newest branch.
|
|
5
|
+
// page messages history_undo, history_redo, history_goto {step}
|
|
6
|
+
// page event "history" {event: "moved", at, what} after undo/redo/goto; {event: "branched", tip} a change made after undoing
|
|
7
|
+
// GET /history {"at", "line": [{"n", "ts", "by", "what"}]}, the current line (root to newest tip)
|
|
8
|
+
// Kept in data/history.jsonl (one step per line), for HISTORY_KEEP_DAYS (default 7). Older steps are dropped at
|
|
10
9
|
// start and every PRUNE_EVERY steps; the current step is always kept.
|
|
11
|
-
// Undo puts back the
|
|
10
|
+
// Undo puts back the cards only: agent/ and built/ have their own history (the version extension).
|
|
12
11
|
// pi has no undo for this: /tree and /fork rewind one agent's conversation, not the shared page.
|
|
13
|
-
// It watches the
|
|
12
|
+
// It watches the card extension's changes (host.api.card.watch); others reach it as host.api.history. Not pi.events:
|
|
14
13
|
// each agent has its own bus, and a reload keeps the old listeners on it, so every change would be recorded again.
|
|
15
14
|
import fs from "node:fs";
|
|
16
15
|
import path from "node:path";
|
|
17
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
17
|
|
|
19
18
|
const host = (globalThis as any).chalky;
|
|
20
|
-
const FILE = path.join(host.
|
|
19
|
+
const FILE = path.join(host.data, "history.jsonl");
|
|
21
20
|
const KEEP_DAYS = Number(process.env.HISTORY_KEEP_DAYS ?? 7);
|
|
22
21
|
const PRUNE_EVERY = 200;
|
|
23
22
|
|
|
@@ -25,12 +24,12 @@ const PRUNE_EVERY = 200;
|
|
|
25
24
|
// "data" last, one just drawn has it in the middle)
|
|
26
25
|
const sorted = (v: any) => JSON.stringify(v, (_k, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x);
|
|
27
26
|
|
|
28
|
-
type Step = { n: number, parent: number | null, ts: number, by: string, what: string,
|
|
27
|
+
type Step = { n: number, parent: number | null, ts: number, by: string, what: string, cards: Record<string, any> };
|
|
29
28
|
|
|
30
29
|
class History {
|
|
31
30
|
steps = new Map<number, Step>();
|
|
32
|
-
at: number | null = null; // the step the
|
|
33
|
-
constructor(public
|
|
31
|
+
at: number | null = null; // the step the cards are at now
|
|
32
|
+
constructor(public cards: any) {
|
|
34
33
|
if (fs.existsSync(FILE))
|
|
35
34
|
for (const line of fs.readFileSync(FILE, "utf8").split("\n").filter(Boolean)) {
|
|
36
35
|
const entry = JSON.parse(line);
|
|
@@ -39,19 +38,19 @@ class History {
|
|
|
39
38
|
} // missed this: after a goto, a restart went back there)
|
|
40
39
|
if (this.at === null || !this.steps.has(this.at)) this.at = this.steps.size ? Math.max(...this.steps.keys()) : null;
|
|
41
40
|
this.prune();
|
|
42
|
-
if (this.at === null || sorted(this.steps.get(this.at)!.
|
|
43
|
-
this.add("start", "the
|
|
41
|
+
if (this.at === null || sorted(this.steps.get(this.at)!.cards) !== sorted(cards.cards))
|
|
42
|
+
this.add("start", "the cards as loaded"); // first start, or the files changed while we were away
|
|
44
43
|
}
|
|
45
44
|
write(entry: object) { fs.appendFileSync(FILE, JSON.stringify(entry) + "\n"); }
|
|
46
45
|
add(by: string, what: string) { // a new step after the current one; tells the pages if it left a branch behind
|
|
47
46
|
const left = this.children(this.at).length ? this.tip(this.at!) : null;
|
|
48
47
|
const n = this.steps.size ? Math.max(...this.steps.keys()) + 1 : 0;
|
|
49
|
-
const step = { n, parent: this.at, ts: Date.now() / 1000, by, what,
|
|
48
|
+
const step = { n, parent: this.at, ts: Date.now() / 1000, by, what, cards: this.cards.snapshot() };
|
|
50
49
|
this.steps.set(n, step);
|
|
51
50
|
this.at = n;
|
|
52
51
|
this.write(step);
|
|
53
52
|
if (n % PRUNE_EVERY === 0) this.prune();
|
|
54
|
-
if (left !== null) host.broadcast("
|
|
53
|
+
if (left !== null) host.broadcast("history", { event: "branched", tip: left });
|
|
55
54
|
}
|
|
56
55
|
children(n: number | null) { return [...this.steps.values()].filter((s) => s.parent === n).map((s) => s.n).sort((a, b) => a - b); }
|
|
57
56
|
tip(n: number) { // follow the newest child down to the end of the line
|
|
@@ -69,8 +68,8 @@ class History {
|
|
|
69
68
|
if (!this.steps.has(n) || n === this.at) return;
|
|
70
69
|
this.at = n;
|
|
71
70
|
this.write({ at: n });
|
|
72
|
-
this.
|
|
73
|
-
host.broadcast("
|
|
71
|
+
this.cards.restore(this.steps.get(n)!.cards);
|
|
72
|
+
host.broadcast("history", { event: "moved", at: n, what: this.steps.get(n)!.what });
|
|
74
73
|
}
|
|
75
74
|
prune() { // drop steps older than KEEP_DAYS (never the current one), then rewrite the file
|
|
76
75
|
const cutoff = Date.now() / 1000 - KEEP_DAYS * 86400;
|
|
@@ -84,26 +83,26 @@ class History {
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
export default function (pi: ExtensionAPI) {
|
|
87
|
-
// made once all of an agent's extensions are loaded (pi loads them in folder order, not by name, so
|
|
88
|
-
//
|
|
86
|
+
// made once all of an agent's extensions are loaded (pi loads them in folder order, not by name, so the card
|
|
87
|
+
// extension may come after this file); the first agent's session_start makes it, at startup, before any change
|
|
89
88
|
pi.on("session_start", () => {
|
|
90
|
-
if (!host.shared.has("
|
|
91
|
-
host.api.
|
|
92
|
-
host.api.
|
|
89
|
+
if (!host.shared.has("history")) host.shared.set("history", new History(host.shared.get("card")));
|
|
90
|
+
host.api.history = host.shared.get("history");
|
|
91
|
+
host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { if (what) host.api.history.add(by, what); });
|
|
93
92
|
});
|
|
94
|
-
const shared = (): History | undefined => host.shared.get("
|
|
93
|
+
const shared = (): History | undefined => host.shared.get("history");
|
|
95
94
|
|
|
96
|
-
host.pageHandlers.set("
|
|
95
|
+
host.pageHandlers.set("history", (msg: any) => {
|
|
97
96
|
const history = shared();
|
|
98
97
|
if (!history) return;
|
|
99
98
|
const now = history.at === null ? undefined : history.steps.get(history.at);
|
|
100
|
-
if (msg.type === "
|
|
101
|
-
else if (msg.type === "
|
|
102
|
-
else if (msg.type === "
|
|
99
|
+
if (msg.type === "history_undo" && now && now.parent !== null) history.goto(now.parent);
|
|
100
|
+
else if (msg.type === "history_redo") { const kids = history.children(history.at); if (kids.length) history.goto(kids.at(-1)!); }
|
|
101
|
+
else if (msg.type === "history_goto") history.goto(msg.step);
|
|
103
102
|
});
|
|
104
|
-
host.routes.set("/
|
|
103
|
+
host.routes.set("/history", (_req: any, res: any) => {
|
|
105
104
|
const history = shared();
|
|
106
105
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ at: history?.at ?? null,
|
|
107
|
-
line: (history?.line() ?? []).map((n) => { const {
|
|
106
|
+
line: (history?.line() ?? []).map((n) => { const { cards, parent, ...s } = history!.steps.get(n)!; return s; }) }));
|
|
108
107
|
});
|
|
109
108
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The page side of the mcp extension: "/mcp" in the chat input opens a picker of the servers in agent/mcp.json. A
|
|
2
2
|
// server's box turns it on or off; open it to tick single tools (greyed while the server is off). Each change is
|
|
3
3
|
// saved at once (mcp_set). A server's tools show once the agents have connected to it.
|
|
4
4
|
const STYLE = `
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MCP picker: "/mcp" on the page ticks which servers in
|
|
1
|
+
// MCP picker: "/mcp" on the page ticks which servers in agent/mcp.json, and which of their tools, the agents may use.
|
|
2
2
|
// pi's built-in MCP does the rest (server.ts loads it; pi's docs/mcp.md). A tick writes the fields pi's own /mcp
|
|
3
3
|
// screen writes, in that file: "enabled": false for a server, toolExposure "<tool>": "hidden" (or back to the
|
|
4
4
|
// server's exposure) for a tool. Then every agent reloads, one change at a time (a busy agent after its turn).
|
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
12
|
const host = (globalThis as any).chalky;
|
|
13
|
-
const FILE = path.join(host.
|
|
13
|
+
const FILE = path.join(host.agent, "mcp.json");
|
|
14
14
|
|
|
15
15
|
function state() {
|
|
16
16
|
const servers = JSON.parse(fs.readFileSync(FILE, "utf8")).mcpServers ?? {};
|
|
@@ -46,8 +46,6 @@ async function reloadAll() {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export default function (pi: ExtensionAPI) {
|
|
49
|
-
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
|
|
50
|
-
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.mcp = guide; });
|
|
51
49
|
|
|
52
50
|
host.pageHandlers.set("mcp", async (msg: any) => {
|
|
53
51
|
if (msg.type !== "mcp_list" && msg.type !== "mcp_set") return;
|