@chalky/chalky 0.0.0-stage → 0.1.3

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/app/SYSTEM.md +30 -0
  4. package/app/extensions/appdata/README.md +10 -0
  5. package/app/extensions/appdata/guide.md +4 -0
  6. package/app/extensions/appdata/index.ts +47 -0
  7. package/app/extensions/doodle/README.md +15 -0
  8. package/app/extensions/doodle/browser.js +388 -0
  9. package/app/extensions/doodle/guide.md +6 -0
  10. package/app/extensions/doodle/index.ts +54 -0
  11. package/app/extensions/files/browser.js +96 -0
  12. package/app/extensions/files/guide.md +7 -0
  13. package/app/extensions/files/index.ts +73 -0
  14. package/app/extensions/frames/README.md +37 -0
  15. package/app/extensions/frames/browser.js +163 -0
  16. package/app/extensions/frames/guide.md +7 -0
  17. package/app/extensions/frames/index.ts +76 -0
  18. package/app/extensions/mcp/browser.js +64 -0
  19. package/app/extensions/mcp/guide.md +5 -0
  20. package/app/extensions/mcp/index.ts +64 -0
  21. package/app/extensions/model/index.ts +20 -0
  22. package/app/extensions/page/index.ts +96 -0
  23. package/app/extensions/page/page.html +615 -0
  24. package/app/extensions/point/README.md +22 -0
  25. package/app/extensions/point/browser.js +245 -0
  26. package/app/extensions/point/guide.md +6 -0
  27. package/app/extensions/point/index.ts +97 -0
  28. package/app/extensions/prompt/index.ts +19 -0
  29. package/app/extensions/reload/index.ts +21 -0
  30. package/app/extensions/screen/browser.js +76 -0
  31. package/app/extensions/screen/index.ts +80 -0
  32. package/app/extensions/steps/index.ts +17 -0
  33. package/app/extensions/swarm/README.md +47 -0
  34. package/app/extensions/swarm/guide.md +11 -0
  35. package/app/extensions/swarm/index.ts +304 -0
  36. package/app/extensions/ui/README.md +69 -0
  37. package/app/extensions/ui/agents.js +45 -0
  38. package/app/extensions/ui/browser.js +363 -0
  39. package/app/extensions/ui/components/App.js +28 -0
  40. package/app/extensions/ui/components/Button.js +28 -0
  41. package/app/extensions/ui/components/Card.js +23 -0
  42. package/app/extensions/ui/components/Column.js +17 -0
  43. package/app/extensions/ui/components/Markdown.js +37 -0
  44. package/app/extensions/ui/components/Row.js +17 -0
  45. package/app/extensions/ui/components/Table.js +31 -0
  46. package/app/extensions/ui/components/Text.js +19 -0
  47. package/app/extensions/ui/components/TextField.js +30 -0
  48. package/app/extensions/ui/guide.md +7 -0
  49. package/app/extensions/ui/index.ts +271 -0
  50. package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
  51. package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
  52. package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
  53. package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
  54. package/app/extensions/ui/voice.js +114 -0
  55. package/app/extensions/ui_history/browser.js +97 -0
  56. package/app/extensions/ui_history/index.ts +109 -0
  57. package/app/extensions/versions/browser.js +58 -0
  58. package/app/extensions/versions/index.ts +168 -0
  59. package/app/extensions/viewer/README.md +12 -0
  60. package/app/extensions/viewer/browser.js +322 -0
  61. package/app/extensions/viewer/guide.md +5 -0
  62. package/app/extensions/viewer/index.ts +209 -0
  63. package/app/mcp.json +3 -0
  64. package/app/skills/plugin-map/SKILL.md +49 -0
  65. package/app/skills/write-skill/SKILL.md +57 -0
  66. package/app/ui/apps/.keep +0 -0
  67. package/bin/chalky.mjs +11 -0
  68. package/node_modules/xlsx/CHANGELOG.md +361 -0
  69. package/node_modules/xlsx/LICENSE +201 -0
  70. package/node_modules/xlsx/README.md +41 -0
  71. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  72. package/node_modules/xlsx/bower.json +22 -0
  73. package/node_modules/xlsx/dist/LICENSE +201 -0
  74. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  75. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  76. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  77. package/node_modules/xlsx/dist/shim.min.js +2 -0
  78. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  79. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  80. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  81. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  82. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  83. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  84. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  85. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  86. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  87. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  88. package/node_modules/xlsx/package.json +183 -0
  89. package/node_modules/xlsx/types/index.d.ts +1065 -0
  90. package/node_modules/xlsx/types/tsconfig.json +15 -0
  91. package/node_modules/xlsx/xlsx.js +28103 -0
  92. package/node_modules/xlsx/xlsx.mjs +28228 -0
  93. package/node_modules/xlsx/xlsxworker.js +14 -0
  94. package/package.json +42 -4
  95. package/server.ts +275 -0
@@ -0,0 +1,96 @@
1
+ // Browser half of the files plugin: the file panel on the right edge (slot "side"), one name per row,
2
+ // collapsed and opened by its folder button (remembered in this browser). Hidden while there are no files.
3
+ // Files dragged from the computer onto the page: uploaded (POST /files/upload), then a "files-dropped"
4
+ // DOM event {x, y, files: [{name, path, size}]} at the drop spot (the point plugin opens its note box
5
+ // there). Dropped onto the panel itself: only stored.
6
+ // A row dragged out of the panel onto the page: the same event, with that file.
7
+ // A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
8
+ // {file, row} (the viewer plugin opens a preview window attached to the row; rows carry data-path).
9
+ // The list comes from GET /files, then from "files" events (after an upload, and after each agent turn).
10
+ export default async function (page) {
11
+ const side = page.slot("side");
12
+ const style = document.createElement("style");
13
+ style.textContent = `
14
+ #side .files-toggle { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; color: var(--muted);
15
+ align-self: flex-end; margin: 16px 9px 0 0; transition: background 0.15s, color 0.15s; }
16
+ #side .files-toggle:hover { background: var(--hover); color: var(--text); }
17
+ #side[data-open] .files-toggle { background: var(--glass-strong); color: var(--text);
18
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06), inset 0 0 0 1px var(--edge); }
19
+ #side .files { display: none; width: 220px; max-height: calc(100vh - 140px); overflow-y: auto; margin: 8px 9px 0 0;
20
+ padding: 8px; box-sizing: border-box; border-radius: var(--radius); font-size: 13px;
21
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
22
+ border: 1px solid var(--edge); box-shadow: var(--shadow); }
23
+ #side[data-open] .files { display: block; }
24
+ #side[data-over] .files { outline: 2px solid var(--accent); outline-offset: -2px; }
25
+ #side .files-row { padding: 5px 8px; border-radius: 8px; color: var(--soft); cursor: grab;
26
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
27
+ #side .files-row:hover, #side .files-row.viewing { background: var(--hover); color: var(--text); }`;
28
+ document.head.append(style);
29
+
30
+ const toggle = document.createElement("button");
31
+ toggle.className = "files-toggle";
32
+ toggle.title = "Files: drop files anywhere on the page; drag a name onto the page to use it there";
33
+ toggle.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`;
34
+ const list = document.createElement("div");
35
+ list.className = "files";
36
+ side.append(toggle, list);
37
+
38
+ const setOpen = (open) => {
39
+ side.toggleAttribute("data-open", open);
40
+ try { localStorage.setItem("files-open", open ? "1" : ""); } catch {}
41
+ };
42
+ let open = true;
43
+ try { open = localStorage.getItem("files-open") !== ""; } catch {}
44
+ setOpen(open);
45
+ toggle.onclick = () => setOpen(!side.hasAttribute("data-open"));
46
+
47
+ const ROW = "application/x-chalky-file"; // a row being dragged: its file as JSON
48
+ function render(files) {
49
+ list.replaceChildren();
50
+ side.hidden = !files.length; // no files yet: no panel (a drop from the computer still works anywhere)
51
+ for (const f of files) {
52
+ const row = Object.assign(document.createElement("div"), { className: "files-row", textContent: f.name, draggable: true });
53
+ row.title = `${f.path} · ${Math.ceil(f.size / 1024)} KB · click to preview`;
54
+ row.dataset.path = f.path;
55
+ row.onclick = () => opened(f); // a drag never ends in a click, so dragging a row still works
56
+ row.ondragstart = (e) => { e.dataTransfer.setData(ROW, JSON.stringify(f)); e.dataTransfer.effectAllowed = "copy"; };
57
+ list.append(row);
58
+ }
59
+ }
60
+ page.on("files", ({ files }) => render(files));
61
+ try { render(await (await fetch("/files")).json()); } catch { render([]); }
62
+
63
+ const kind = (e) => {
64
+ const types = [...(e.dataTransfer?.types || [])];
65
+ return types.includes(ROW) ? "row" : types.includes("Files") ? "files" : null;
66
+ };
67
+ const onPanel = (e) => side.contains(e.target);
68
+ const opened = (file) => document.dispatchEvent(new CustomEvent("file-open",
69
+ { detail: { file, row: list.querySelector(`[data-path="${CSS.escape(file.path)}"]`) } }));
70
+ const dropped = (x, y, files) => document.dispatchEvent(new CustomEvent("files-dropped", { detail: { x, y, files } }));
71
+
72
+ document.addEventListener("dragover", (e) => {
73
+ if (!kind(e)) return;
74
+ e.preventDefault();
75
+ side.toggleAttribute("data-over", kind(e) === "files" && onPanel(e));
76
+ });
77
+ document.addEventListener("dragleave", (e) => { if (!e.relatedTarget) side.removeAttribute("data-over"); });
78
+ document.addEventListener("drop", async (e) => {
79
+ const k = kind(e);
80
+ if (!k) return;
81
+ e.preventDefault();
82
+ side.removeAttribute("data-over");
83
+ const { clientX: x, clientY: y } = e;
84
+ if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
85
+ const stored = [];
86
+ for (const f of [...e.dataTransfer.files].slice(0, 10)) {
87
+ try {
88
+ const out = await (await fetch(`/files/upload?name=${encodeURIComponent(f.name)}`, { method: "POST", body: f })).json();
89
+ if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
90
+ else page.note(`Could not upload ${f.name}: ${out.error}`);
91
+ } catch (err) { page.note(`Could not upload ${f.name}: ${err}`); }
92
+ }
93
+ if (stored.length) opened(stored[0]); // the list has its row by now (the "files" event comes before the reply)
94
+ if (stored.length && !onPanel(e)) dropped(x, y, stored);
95
+ });
96
+ }
@@ -0,0 +1,7 @@
1
+ # The user's files
2
+ {work}/drops/ holds the user's files: the ones they dropped on the page, and any you save there. The
3
+ user sees all of them by name in the file panel on the page's right edge.
4
+ - A file matters only when the user points at it (a note with "files": dropped from their computer or
5
+ dragged from their panel) or names it. Then read it from there (read shows you pictures).
6
+ - To hand the user a file (an export, a generated image, a dataset), save it in {work}/drops/: it shows
7
+ in their panel when your turn ends. Other work files stay in {work}.
@@ -0,0 +1,73 @@
1
+ // Files: the user's files, in one folder, <workspace>/drops (port of the Python files plugin; the browser half,
2
+ // browser.js, is its files.js). browser.js shows them in a panel on the page's right edge and handles files dragged onto
3
+ // the page (from the computer: uploaded here, listed, and a "files-dropped" DOM event so point's browser.js opens its note
4
+ // box there; onto the panel itself: only stored; a row from the panel: the same event with that file).
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 when an agent's turn is over: pi's agent_settled).
7
+ // POST /files/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
8
+ // GET /files [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
9
+ // event "files" {files: [...]} to every open page, when the list changed
10
+ // guide.md (copied from files.py, short enough to be whole) names pi's read tool for pictures (Python: view_image).
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+
15
+ const host = (globalThis as any).chalky;
16
+ const DIR = path.join(host.work, "drops");
17
+ const MAX_FILE = 25 * 1024 * 1024; // bytes per uploaded file
18
+ const MAX_LISTED = 500; // rows in the panel (the newest)
19
+ fs.mkdirSync(DIR, { recursive: true }); // there from the first turn, so the agent can save into it
20
+
21
+ function listing() { // the files in DIR and its folders (not hidden ones), newest first; name: the path inside DIR
22
+ return fs.readdirSync(DIR, { withFileTypes: true, recursive: true })
23
+ .map((e) => ({ e, name: path.relative(DIR, path.join(e.parentPath, e.name)) }))
24
+ .filter(({ e, name }) => e.isFile() && !name.split(path.sep).some((s) => s.startsWith(".")))
25
+ .map(({ name }) => { const p = path.join(DIR, name), st = fs.statSync(p); return { name, path: p, size: st.size, mtime: st.mtimeMs }; })
26
+ .sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name))
27
+ .slice(0, MAX_LISTED)
28
+ .map(({ name, path, size }) => ({ name, path, size }));
29
+ }
30
+
31
+ function changed() { // tell the pages, if the list is not what they were last sent
32
+ const now = JSON.stringify(listing());
33
+ if (now === host.shared.get("files-last")) return;
34
+ host.shared.set("files-last", now);
35
+ host.broadcast("files", { files: JSON.parse(now) });
36
+ }
37
+
38
+ export default function (pi: ExtensionAPI) {
39
+ const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
40
+ pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.files = guide; });
41
+ pi.on("agent_settled", () => changed()); // the agent may have saved files there
42
+ if (!host.shared.has("files-last")) host.shared.set("files-last", JSON.stringify(listing()));
43
+
44
+ host.routes.set("/files", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
45
+ host.routes.set("/files/upload", (req: any, res: any) => { // save one file in DIR (a new name if it exists)
46
+ if (req.method !== "POST") return res.writeHead(405).end();
47
+ const given = path.basename(new URL(req.url, "http://x").searchParams.get("name") ?? "file");
48
+ const name = given.replace(/[^\w.\-]+/g, "_").replace(/^[._]+|[._]+$/g, "") || "file";
49
+ const ext = path.extname(name), stem = name.slice(0, name.length - ext.length);
50
+ let p = path.join(DIR, name), k = 1;
51
+ while (fs.existsSync(p)) p = path.join(DIR, `${stem}-${++k}${ext}`);
52
+ const out = fs.createWriteStream(p);
53
+ out.on("error", () => {}); // writes still queued when the size limit cuts it off
54
+ let size = 0, tooBig = false;
55
+ req.on("data", (chunk: Buffer) => {
56
+ size += chunk.length;
57
+ if (size > MAX_FILE && !tooBig) {
58
+ tooBig = true;
59
+ out.on("close", () => fs.rmSync(p, { force: true }));
60
+ out.destroy();
61
+ res.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: `${name} is over ${MAX_FILE / 1024 / 1024} MB` }));
62
+ req.destroy();
63
+ } else if (!tooBig) out.write(chunk);
64
+ });
65
+ req.on("end", () => {
66
+ if (tooBig) return;
67
+ out.end(() => {
68
+ changed();
69
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ path: p, size }));
70
+ });
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,37 @@
1
+ # Wireframes (the frames extension)
2
+ The user can lay out placeholder boxes (frames) on the page and write in each what goes there, then send
3
+ them all at once: "[frames on the UI]" lists each frame's id, place and words. Frames sent together are
4
+ one design, not separate requests: they share one look, and often each other's data.
5
+ - The words in a frame are a title, not a spec: "my notes" means a real notes app. Build what a good
6
+ version of that thing does, the way the user would expect it from any app they know (notes are added
7
+ and kept, a list can be added to and edited, things saved somewhere can be found there again), working
8
+ on the page from the first version. Not the least that matches the words, and not a static mock with
9
+ sample data where the thing should hold the user's own. Say your assumptions in one line at the end.
10
+ - Before building, look for the links: between the frames, and between them and the surfaces already on
11
+ the page. Does one show, file, pick from, summarise or act on another's items ("book of the week" from
12
+ "a reading list"; "my notes on what i'm reading": notes per book)? Do two hold the same things? Should a
13
+ frame use something already there (an existing notepad) instead of starting its own? The words are short,
14
+ so a link is often only hinted: work out what a person who drew these side by side would mean.
15
+ - If the links are not obvious, don't pick a default silently and don't ask an open question: propose
16
+ the design and ask once, in one short reply, e.g. "Reading list: add books, mark read. Book of the week:
17
+ Pick on a row sets it. Notes: one box for the week's book, Save files the note in that book's folder.
18
+ Build it like that?" That reply is your answer for this turn; build when they answer. When the links are
19
+ obvious, build straight away. Never ask about what you can decide well yourself (look, layout, details).
20
+ - Linked frames that change each other's data (pick a book, file a note into it, mark it read) are one
21
+ app: one App folder ({code}/ui/apps/<name>/) with a page per frame (reading/list.html, week.html,
22
+ notes.html), each frame its own surface showing its page, all keeping their data in the app's shared
23
+ store (agents.load / agents.save, see "App data"). A click in one updates the others at once, on the
24
+ page, with no agent in between. Separate component surfaces with {"ref"} props are for a frame that only
25
+ shows another's data (refs only read). Don't change the page's own code (page.html, the browser.js files) to
26
+ link frames.
27
+ - Fill a frame with ui_render(surface=<its id>, place=<its place>, ...): its dashed box goes when that
28
+ surface appears. Keep the place: the user chose where and how big.
29
+ - Split the work when it pays: several frames that are each real work and don't depend on each other can
30
+ go to helpers in parallel (spawn_agent, or idle agents you have): tell each one its frame, and what done
31
+ means: "surface <id> rendered at <place>, doing <their words>". Say the shared look in each message
32
+ (colours, type, spacing, the components to reuse), so the pieces match. A frame that needs another's data
33
+ waits for it: hand it out once that one is done. Small or tightly linked frames: do them yourself.
34
+ - When all are filled, look at the whole page once (the pieces side by side) and fix what doesn't match,
35
+ and try each link end to end (add a note: does it show in the filer?) by clicking through it with the
36
+ ui-check skill.
37
+ Put back any data your clicks changed, so the user finds what you built, not your test.
@@ -0,0 +1,163 @@
1
+ // Browser half of the frames extension (Python's frames.js, unchanged but for this header): a frame tool in the palette (a dashed box). While it is on, drag on the
2
+ // main space to lay down a frame: a dashed box with a field for what goes there (Enter finishes it). While the
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 (frames_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 surface's (frame_place).
6
+ // Sent frames stay as they are, marked waiting, until an agent renders a surface with their id (index.ts
7
+ // then drops them); ✕ on a frame, or the eraser passing over an unsent one ("erase" event), removes it.
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 surfaces.
10
+ export default async function (page) {
11
+ const area = page.slot("top");
12
+ const style = document.createElement("style");
13
+ style.textContent = `
14
+ #top.framing { cursor: crosshair; z-index: 11; } /* above the messages panel (10), so frames can be drawn there too */
15
+ #top .frame { position: absolute; z-index: 100000; box-sizing: border-box; display: grid; place-items: center; padding: 12px;
16
+ border: 1.5px dashed var(--muted); border-radius: 12px; background: var(--glass); cursor: default;
17
+ backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); }
18
+ #top .frame.drawing { pointer-events: none; }
19
+ #top .frame textarea { width: 100%; max-width: 360px; border: 0; outline: 0; resize: none; background: none; font: inherit;
20
+ font-size: 14px; color: var(--text); text-align: center; field-sizing: content; max-height: 100%; }
21
+ #top .frame textarea::placeholder { color: var(--muted); }
22
+ #top .frame.sent { border-color: var(--accent); }
23
+ #top .frame.sent textarea { color: var(--soft); }
24
+ #top .frame .frame-id { position: absolute; left: 10px; top: 6px; font-size: 11px; color: var(--muted); }
25
+ #top .frame.sent .frame-id::after { content: " · waiting for an agent"; }
26
+ #top .frame .frame-x { position: absolute; right: 6px; top: 4px; width: 22px; height: 22px; border-radius: 6px;
27
+ color: var(--muted); opacity: 0; }
28
+ #top .frame:hover .frame-x { opacity: 1; }
29
+ #top .frame .frame-x:hover { background: var(--hover); color: var(--text); }
30
+ #top .frame .frame-resize { position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; box-sizing: border-box;
31
+ border: solid var(--muted); border-width: 0 2px 2px 0; border-radius: 0 0 4px 0; cursor: nwse-resize; opacity: 0; }
32
+ #top .frame:hover .frame-resize { opacity: 1; }
33
+ #top .frame.sent .frame-resize { display: none; }
34
+ #status button.frames-send { color: var(--accent); }`;
35
+ document.head.append(style);
36
+
37
+ const button = document.createElement("button");
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.title = "Frames: drag boxes on the page and write what goes in each, then Implement them together (Esc to stop)";
40
+ button.append(Object.assign(document.createElement("span"), { className: "badge" })); // frames waiting, while the tool is off
41
+ const send = document.createElement("button");
42
+ send.className = "frames-send";
43
+ 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
+ `<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
+ `<span class="badge"></span>`; // the hammer: build these
46
+ send.hidden = true;
47
+ page.tool(button).append(send);
48
+
49
+ let on = false, ready = 0; // ready: unsent frames with words
50
+ const showReady = () => { // only one row under the palette shows at a time: the tool that is on
51
+ send.hidden = !(on && ready);
52
+ send.querySelector(".badge").textContent = ready || "";
53
+ button.querySelector(".badge").textContent = !on && ready ? ready : "";
54
+ };
55
+ const setOn = (v) => {
56
+ on = v;
57
+ button.classList.toggle("on", v);
58
+ area.classList.toggle("framing", v);
59
+ showReady();
60
+ if (v) document.dispatchEvent(new CustomEvent("tool-on", { detail: "frames" })); // the other tools turn off
61
+ };
62
+ button.onclick = () => setOn(!on);
63
+ document.addEventListener("tool-on", (e) => { if (on && e.detail !== "frames") setOn(false); }); // one tool at a time
64
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
65
+ send.onclick = () => {
66
+ for (const flush of Object.values(timers)) { clearTimeout(flush.t); flush(); } // text still waiting on the typing pause goes first
67
+ page.send({ type: "frames_send", to: document.getElementById("to")?.value });
68
+ };
69
+
70
+ const inMain = (x, y) => { // window px -> main-space px (as point.js)
71
+ const m = area.getBoundingClientRect();
72
+ return { x: Math.round(x - m.left + area.scrollLeft), y: Math.round(y - m.top + area.scrollTop) };
73
+ };
74
+ 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
+
76
+ // Drawing: on the main space, over empty space or surfaces, but not on a frame (that is for typing).
77
+ let start = null, ghost = null, focusNew = false;
78
+ area.addEventListener("mousedown", (e) => {
79
+ if (!on || e.button !== 0 || e.target.closest(".frame")) return;
80
+ e.preventDefault();
81
+ e.stopPropagation();
82
+ start = inMain(e.clientX, e.clientY);
83
+ ghost = Object.assign(document.createElement("div"), { className: "frame drawing" });
84
+ area.append(ghost);
85
+ }, true);
86
+ area.addEventListener("click", (e) => { if (on && !e.target.closest(".frame")) e.stopPropagation(); }, true); // surfaces don't react
87
+ window.addEventListener("mousemove", (e) => {
88
+ if (!start) return;
89
+ const p = inMain(e.clientX, e.clientY);
90
+ put(ghost, { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), width: Math.abs(p.x - start.x), height: Math.abs(p.y - start.y) });
91
+ });
92
+ window.addEventListener("mouseup", () => {
93
+ if (!start) return;
94
+ const f = { x: parseInt(ghost.style.left), y: parseInt(ghost.style.top), width: parseInt(ghost.style.width), height: parseInt(ghost.style.height) };
95
+ ghost.remove();
96
+ 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
98
+ });
99
+
100
+ const boxes = {}; // id -> its box on the page
101
+ const timers = {};
102
+ let shown = {}; // the frames as last rendered
103
+ document.addEventListener("erase", (e) => { // the doodle eraser at {x, y}, main px: unsent frames under it go
104
+ const p = e.detail;
105
+ 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;
107
+ delete shown[id]; // once, though the eraser keeps moving over it
108
+ page.send({ type: "frame_remove", id });
109
+ }
110
+ });
111
+ function render(frames) {
112
+ shown = { ...frames };
113
+ for (const id of Object.keys(boxes)) if (!frames[id]) { boxes[id].remove(); delete boxes[id]; }
114
+ let newest = null;
115
+ for (const [id, f] of Object.entries(frames)) {
116
+ let box = boxes[id];
117
+ if (!box) {
118
+ box = boxes[id] = Object.assign(document.createElement("div"), { className: "frame" });
119
+ box.dataset.frame = id;
120
+ const label = Object.assign(document.createElement("span"), { className: "frame-id", textContent: id });
121
+ const close = Object.assign(document.createElement("button"), { className: "frame-x", textContent: "✕", title: "Remove this frame" });
122
+ close.onclick = () => page.send({ type: "frame_remove", id });
123
+ const text = Object.assign(document.createElement("textarea"), { rows: 1, placeholder: "What goes here?" });
124
+ text.oninput = () => {
125
+ clearTimeout(timers[id]?.t);
126
+ const flush = timers[id] = () => { delete timers[id]; page.send({ type: "frame_text", id, text: text.value }); };
127
+ flush.t = setTimeout(flush, 300);
128
+ };
129
+ text.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); text.blur(); } };
130
+ const corner = Object.assign(document.createElement("div"), { className: "frame-resize", title: "Drag to resize" });
131
+ corner.onpointerdown = (e) => resize(box, id, e);
132
+ box.append(label, close, text, corner);
133
+ area.append(box);
134
+ newest = box;
135
+ }
136
+ put(box, f);
137
+ box.classList.toggle("sent", f.sent);
138
+ const text = box.querySelector("textarea");
139
+ text.readOnly = f.sent;
140
+ if (document.activeElement !== text) text.value = f.text;
141
+ }
142
+ if (focusNew && newest) { focusNew = false; newest.querySelector("textarea").focus(); }
143
+ ready = Object.values(frames).filter((f) => !f.sent && f.text.trim()).length;
144
+ showReady();
145
+ send.title = `Implement: send ${ready} frame${ready > 1 ? "s" : ""} to the agent to build, together`;
146
+ }
147
+ function resize(box, id, e) { // drag the corner (as ui.js does for surfaces); on release the new size is saved
148
+ e.preventDefault();
149
+ const corner = e.currentTarget, x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
150
+ corner.setPointerCapture(e.pointerId);
151
+ let place = null;
152
+ corner.onpointermove = (ev) => {
153
+ place = { x, y, width: Math.max(40, Math.round(w0 + ev.clientX - e.clientX)), height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
154
+ put(box, place);
155
+ };
156
+ corner.onpointerup = () => {
157
+ corner.onpointermove = corner.onpointerup = null;
158
+ if (place) page.send({ type: "frame_place", id, ...place });
159
+ };
160
+ }
161
+ page.on("frames", (ev) => render(ev.frames));
162
+ try { render(await (await fetch("/frames")).json()); } catch {}
163
+ }
@@ -0,0 +1,7 @@
1
+ # Wireframes
2
+ The user lays out boxes (frames) on the page, writes in each what goes there, and sends them together:
3
+ "[frames on the UI]".
4
+ - Fill each frame with ui_render(surface=<the frame's id>, place=<its place>): its dashed box goes when it appears.
5
+ - Build a working version of what the words name, as a good one would work (an App when it keeps or calculates
6
+ things), never a mock with sample data. Frames sent together are one design.
7
+ Before you handle a frames message, read README.md.
@@ -0,0 +1,76 @@
1
+ // Frames: wireframes the user lays out for the agents to fill (port of the Python frames plugin; browser.js is its
2
+ // frames.js). browser.js adds a tool to the palette: drag boxes on the main space, type in each what goes there,
3
+ // then press Implement. All the frames go together, as one message, to the agent picked in the chat (else agent1),
4
+ // so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
5
+ // A frame is {x, y, width, height, text, sent, to?} (main-space px, where ui_render's place puts a surface), with an
6
+ // id (frame-1, frame-2, ...: never reused). An agent fills it with ui_render(surface="frame-1", place=<its place>);
7
+ // when that surface appears (a ui watcher, host.api.ui.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 /frames {id: frame}
10
+ // page messages frame_add {x, y, width, height}, frame_place {id, x, y, width, height}, frame_text {id, text},
11
+ // frame_remove {id}, frames_send {to}
12
+ // event "frames" {frames: {id: frame}} to every open page, when they changed
13
+ // Saved in state/frames.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.state, "frames.json");
20
+ if (!host.shared.has("frames"))
21
+ host.shared.set("frames", fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, frames: {} });
22
+ const saved = host.shared.get("frames"); // {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("frames", { 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
+ const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
35
+ .replace("README.md", path.join(import.meta.dirname, "README.md"));
36
+ pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.frames = guide; });
37
+
38
+ // a frame is done when a surface with its id appears (after all extensions loaded: the ui one may come later)
39
+ pi.on("session_start", () => {
40
+ host.api.ui?.watch("frames", (sid: string) => {
41
+ if (frames[sid] && host.shared.get("ui")?.surfaces[sid]) { delete frames[sid]; changed(); }
42
+ });
43
+ });
44
+
45
+ host.routes.set("/frames", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(frames)));
46
+
47
+ host.pageHandlers.set("frames", (msg: any) => {
48
+ const f = frames[msg.id], swarm = host.api.swarm;
49
+ if (msg.type === "frame_add") {
50
+ frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false };
51
+ changed();
52
+ } else if (msg.type === "frame_place" && f && !f.sent) {
53
+ Object.assign(f, box(msg, f));
54
+ changed();
55
+ } else if (msg.type === "frame_text" && f && !f.sent) {
56
+ f.text = String(msg.text ?? "");
57
+ changed();
58
+ } else if (msg.type === "frame_remove" && f) {
59
+ delete frames[msg.id];
60
+ changed();
61
+ if (f.sent) swarm?.send("user", f.to, `[frames on the UI] the user removed ${msg.id} ("${f.text}"): don't fill it.`, true);
62
+ } else if (msg.type === "frames_send" && swarm) {
63
+ const to = msg.to in swarm.status() ? msg.to : "agent1";
64
+ const ready = Object.entries(frames).filter(([, f]: any) => !f.sent && f.text.trim());
65
+ if (!ready.length) return;
66
+ for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
67
+ changed();
68
+ const lines = [`[frames on the UI] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} to fill:`,
69
+ ...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
70
+ " Fill each with ui_render(surface=<its id>, place=<its place>, ...); its dashed box goes when it appears."];
71
+ const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
72
+ if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
73
+ swarm.send("user", to, lines.join("\n"));
74
+ }
75
+ });
76
+ }
@@ -0,0 +1,64 @@
1
+ // Browser half of the mcp extension: "/mcp" in the chat input opens a picker of the servers in app/mcp.json. A
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
+ // saved at once (mcp_set). A server's tools show once the agents have connected to it.
4
+ const STYLE = `
5
+ .mcp { width: min(560px, calc(100vw - 32px)); max-height: 75vh; padding: 16px 18px; box-sizing: border-box;
6
+ border-radius: var(--radius, 16px); border: 1px solid var(--edge, #eee); color: var(--text, #1d1d1f);
7
+ background: var(--glass-strong, #fff); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
8
+ box-shadow: var(--shadow, 0 8px 30px rgba(0,0,0,.1)); font-size: 13px; }
9
+ .mcp::backdrop { background: rgba(0, 0, 0, .08); }
10
+ .mcp header { display: flex; justify-content: space-between; margin-bottom: 10px; font-weight: 600; }
11
+ .mcp header button { font: inherit; color: var(--muted, #888); background: none; border: 0; cursor: pointer; }
12
+ .mcp p { color: var(--muted, #888); margin: 0 0 10px; } .mcp p.error { color: #c62828; }
13
+ .mcp details { border-top: 1px solid rgba(128, 128, 128, .2); padding: 8px 0; }
14
+ .mcp summary { display: flex; gap: 8px; align-items: center; cursor: pointer; list-style: none; }
15
+ .mcp summary span { margin-left: auto; color: var(--muted, #888); }
16
+ .mcp label { display: flex; gap: 8px; align-items: baseline; padding: 4px 0 4px 24px; cursor: pointer; }
17
+ .mcp label span { color: var(--muted, #888); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
18
+ `;
19
+
20
+ export default function (page) {
21
+ const style = document.createElement("style");
22
+ style.textContent = STYLE;
23
+ document.head.append(style);
24
+ const box = document.createElement("dialog");
25
+ box.className = "mcp";
26
+ document.body.append(box);
27
+ box.onclick = (e) => { if (e.target === box) box.close(); }; // a click on the backdrop closes it
28
+ let state = null; // the "mcp" event: {file, servers, error}
29
+ const open = new Set(); // servers whose tools are shown, kept across redraws
30
+ const el = (tag, props, ...kids) => { const e = Object.assign(document.createElement(tag), props); e.append(...kids); return e; };
31
+ const tick = (checked, onchange, more = {}) => el("input", { type: "checkbox", checked, onchange, ...more });
32
+
33
+ function render() {
34
+ const close = el("button", { title: "Close", onclick: () => box.close() }, "✕");
35
+ box.replaceChildren(el("header", {}, "MCP tools", close));
36
+ if (!state) return box.append("Asking…");
37
+ box.append(el("p", {}, `Tick what the agents may use. The servers are in ${state.file}.`));
38
+ if (state.error) box.append(el("p", { className: "error" }, state.error));
39
+ const servers = Object.entries(state.servers);
40
+ if (!servers.length && !state.error) box.append(el("p", {}, "No servers yet: add them to that file."));
41
+ for (const [server, s] of servers) {
42
+ const on = s.tools.filter((t) => t.on).length;
43
+ const all = tick(s.enabled, () => page.send({ type: "mcp_set", server, on: all.checked }),
44
+ { onclick: (e) => e.stopPropagation() }); // ticking the server doesn't open or close it
45
+ const count = s.tools.length ? `${on}/${s.tools.length}` : s.enabled ? "no tools yet" : "off";
46
+ const d = el("details", { open: open.has(server) },
47
+ el("summary", {}, all, el("b", { title: s.description }, server), el("span", {}, count)));
48
+ d.ontoggle = () => d.open ? open.add(server) : open.delete(server);
49
+ for (const t of s.tools) {
50
+ const one = tick(t.on, () => page.send({ type: "mcp_set", server, tool: t.name, on: one.checked }), { disabled: !s.enabled });
51
+ d.append(el("label", { title: t.description }, one, t.name, el("span", {}, t.description)));
52
+ }
53
+ box.append(d);
54
+ }
55
+ }
56
+
57
+ page.command("mcp", () => {
58
+ state = null; // ask again each time: mcp.json or the connections may have changed
59
+ render();
60
+ box.showModal();
61
+ page.send({ type: "mcp_list" });
62
+ }, "Choose the MCP tools the agents may use");
63
+ page.on("mcp", (ev) => { state = ev; if (box.open) render(); });
64
+ }
@@ -0,0 +1,5 @@
1
+ # MCP servers
2
+ Servers are in {code}/mcp.json (pi's built-in MCP: pi's docs/mcp.md); their tools are named mcp__<server>__<tool>.
3
+ The user ticks which servers and tools are on with /mcp on the page.
4
+ - To add a server when asked: add its entry to that file (secrets as ${ENV_VAR} in headers or env, never written
5
+ out), then call reload.
@@ -0,0 +1,64 @@
1
+ // MCP picker: "/mcp" on the page ticks which servers in app/mcp.json, and which of their tools, the agents may use.
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
+ // screen writes, in that file: "enabled": false for a server, toolExposure "<tool>": "hidden" (or back to the
4
+ // server's exposure) for a tool. Then every agent reloads, one change at a time (a busy agent after its turn).
5
+ // Tools: agent1's, from pi (getAllTools), once its connection to the server is up.
6
+ // page message mcp_list -> event "mcp" {file, servers: {name: {enabled, description,
7
+ // mcp_set {server, on} | {server, tool, on} tools: [{name, description, on}]}}, error}
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+
12
+ const host = (globalThis as any).chalky;
13
+ const FILE = path.join(host.app, "mcp.json");
14
+
15
+ function state() {
16
+ const servers = JSON.parse(fs.readFileSync(FILE, "utf8")).mcpServers ?? {};
17
+ const tools = host.agents.get("agent1")?.session.getAllTools() ?? [];
18
+ const out: Record<string, any> = {};
19
+ for (const [name, c] of Object.entries<any>(servers)) {
20
+ const enabled = c.enabled !== false;
21
+ out[name] = { enabled, description: c.description ?? c.url ?? c.command ?? "",
22
+ tools: tools.filter((t: any) => t.namespace?.aliases?.[0] === name).map((t: any) => {
23
+ const tool = t.name.slice(t.namespace.name.length + 2); // mcp__<server>__<tool>
24
+ return { name: tool, description: t.description, on: enabled && (c.toolExposure?.[tool] ?? t.exposure) !== "hidden" };
25
+ }) };
26
+ }
27
+ return { file: FILE, servers: out, error: "" };
28
+ }
29
+
30
+ function set(msg: any) {
31
+ const json = JSON.parse(fs.readFileSync(FILE, "utf8")), c = json.mcpServers?.[msg.server];
32
+ if (!c) return;
33
+ if (msg.tool === undefined) { if (msg.on) delete c.enabled; else c.enabled = false; }
34
+ else {
35
+ const own = c.exposure ?? "codemode"; // pi's default
36
+ (c.toolExposure ??= {})[msg.tool] = !msg.on ? "hidden" : own === "hidden" ? "direct" : own;
37
+ }
38
+ fs.writeFileSync(FILE, JSON.stringify(json, null, 2) + "\n");
39
+ }
40
+
41
+ async function reloadAll() {
42
+ for (const a of host.agents.values()) {
43
+ if (a.session.isStreaming) { if (!host.reloadAfterTurn.has(a.name)) host.reloadAfterTurn.set(a.name, true); }
44
+ else await host.reloadAgent(a.name, true); // quiet: ticking isn't news for the conversation
45
+ }
46
+ }
47
+
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
+
52
+ host.pageHandlers.set("mcp", async (msg: any) => {
53
+ if (msg.type !== "mcp_list" && msg.type !== "mcp_set") return;
54
+ try {
55
+ if (msg.type === "mcp_set") set(msg);
56
+ host.broadcast("mcp", state()); // before the reloads: agent1's tools are still listed, the file has the ticks
57
+ } catch (e: any) {
58
+ return host.broadcast("mcp", { file: FILE, servers: {}, error: `${FILE}: ${e.message}` });
59
+ }
60
+ // reloads one after another, also across this extension's own reloads (host.shared outlives them)
61
+ if (msg.type === "mcp_set") host.shared.set("mcp-reloads", (host.shared.get("mcp-reloads") ?? Promise.resolve())
62
+ .then(reloadAll).catch((e: any) => console.error("mcp: reload failed:", e)));
63
+ });
64
+ }
@@ -0,0 +1,20 @@
1
+ // The model: one OpenAI-compatible endpoint (in Chalky: keyproxy -> LiteLLM), as the Python agent's llm plugin.
2
+ // MODEL_BASE_URL (up to /v1), MODEL_API_KEY, MODEL_NAME (a LiteLLM route, "default"). pi does the calls,
3
+ // streaming, retries, thinking and compaction; this only tells it where. The host selects provider "chalky".
4
+ // MODEL_CONTEXT_WINDOW: the model's context size; pi compacts when a request would leave less than 16384 tokens of it.
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ if (!process.env.MODEL_BASE_URL) return; // pi then says no model is configured on each message
9
+ pi.registerProvider("chalky", {
10
+ baseUrl: process.env.MODEL_BASE_URL,
11
+ apiKey: "$MODEL_API_KEY", // read from the environment by pi, not stored in the session
12
+ api: "openai-completions",
13
+ compat: { supportsStore: false, supportsDeveloperRole: false }, // LiteLLM may not take "store" (pi-ai docs)
14
+ models: [{
15
+ id: process.env.MODEL_NAME || "default", name: "Chalky default route", reasoning: true,
16
+ input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
17
+ contextWindow: Number(process.env.MODEL_CONTEXT_WINDOW || 200000), maxTokens: 16000,
18
+ }],
19
+ });
20
+ }