@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,109 @@
1
+ // Every state of the UI surfaces, kept: undo, redo and rewind for the user (port of the Python ui_history plugin; the
2
+ // browser half, browser.js, is its ui_history.js). After each change (ui.ts's change() with words for it, from the user or
3
+ // an agent) it saves a step: a full copy of all surfaces, design and data. Steps form a tree: each has a parent, so
4
+ // undoing and then doing something new starts a branch and loses nothing; redo follows the newest branch.
5
+ // Page messages: ui_undo, ui_redo, ui_goto {step}
6
+ // Server -> page: {"type": "ui_history", "event": "moved", "at", "what"} after undo/redo/goto
7
+ // {"type": "ui_history", "event": "branched", "tip"} a change made after undoing
8
+ // GET /ui-api/history: {"at", "line": [{"n", "ts", "by", "what"}]}, the current line (root to newest tip)
9
+ // Kept in state/ui-history.jsonl (one step per line), for HISTORY_KEEP_DAYS (default 7). Older steps are dropped at
10
+ // start and every PRUNE_EVERY steps; the current step is always kept.
11
+ // Undo puts back the surfaces only: code (components, apps) has its own history (the versions extension).
12
+ // pi has no undo for this: /tree and /fork rewind one agent's conversation, not the shared page.
13
+ // It watches the ui extension's changes (host.api.ui.watch); others reach it as host.api.uiHistory. Not pi.events:
14
+ // each agent has its own bus, and a reload keeps the old listeners on it, so every change would be recorded again.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+
19
+ const host = (globalThis as any).chalky;
20
+ const FILE = path.join(host.state, "ui-history.jsonl");
21
+ const KEEP_DAYS = Number(process.env.HISTORY_KEEP_DAYS ?? 7);
22
+ const PRUNE_EVERY = 200;
23
+
24
+ // JSON with every object's keys in order: the same content compares the same (a record loaded from the files has
25
+ // "data" last, one just drawn has it in the middle)
26
+ const sorted = (v: any) => JSON.stringify(v, (_k, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x);
27
+
28
+ type Step = { n: number, parent: number | null, ts: number, by: string, what: string, surfaces: Record<string, any> };
29
+
30
+ class History {
31
+ steps = new Map<number, Step>();
32
+ at: number | null = null; // the step the surfaces are at now
33
+ constructor(public ui: any) {
34
+ if (fs.existsSync(FILE))
35
+ for (const line of fs.readFileSync(FILE, "utf8").split("\n").filter(Boolean)) {
36
+ const entry = JSON.parse(line);
37
+ if ("at" in entry) this.at = entry.at; // a marker line: where undo/redo left us
38
+ else { this.steps.set(entry.n, entry); this.at = entry.n; } // a new step is where we are (the Python version
39
+ } // missed this: after a goto, a restart went back there)
40
+ if (this.at === null || !this.steps.has(this.at)) this.at = this.steps.size ? Math.max(...this.steps.keys()) : null;
41
+ this.prune();
42
+ if (this.at === null || sorted(this.steps.get(this.at)!.surfaces) !== sorted(ui.surfaces))
43
+ this.add("start", "the surfaces as loaded"); // first start, or the files changed while we were away
44
+ }
45
+ write(entry: object) { fs.appendFileSync(FILE, JSON.stringify(entry) + "\n"); }
46
+ add(by: string, what: string) { // a new step after the current one; tells the pages if it left a branch behind
47
+ const left = this.children(this.at).length ? this.tip(this.at!) : null;
48
+ 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, surfaces: this.ui.snapshot() };
50
+ this.steps.set(n, step);
51
+ this.at = n;
52
+ this.write(step);
53
+ if (n % PRUNE_EVERY === 0) this.prune();
54
+ if (left !== null) host.broadcast("ui_history", { event: "branched", tip: left });
55
+ }
56
+ children(n: number | null) { return [...this.steps.values()].filter((s) => s.parent === n).map((s) => s.n).sort((a, b) => a - b); }
57
+ tip(n: number) { // follow the newest child down to the end of the line
58
+ for (let kids = this.children(n); kids.length; kids = this.children(n)) n = kids.at(-1)!;
59
+ return n;
60
+ }
61
+ line() { // the steps from the oldest kept one to the tip of the current branch
62
+ const back = [];
63
+ for (let n = this.at; n !== null && this.steps.has(n); n = this.steps.get(n)!.parent) back.push(n);
64
+ const ahead = [];
65
+ for (let n = this.at, kids = this.children(n); kids.length; kids = this.children(n)) { n = kids.at(-1)!; ahead.push(n); }
66
+ return [...back.reverse(), ...ahead];
67
+ }
68
+ goto(n: number) {
69
+ if (!this.steps.has(n) || n === this.at) return;
70
+ this.at = n;
71
+ this.write({ at: n });
72
+ this.ui.restore(this.steps.get(n)!.surfaces);
73
+ host.broadcast("ui_history", { event: "moved", at: n, what: this.steps.get(n)!.what });
74
+ }
75
+ prune() { // drop steps older than KEEP_DAYS (never the current one), then rewrite the file
76
+ const cutoff = Date.now() / 1000 - KEEP_DAYS * 86400;
77
+ for (const [n, s] of this.steps) if (s.ts < cutoff && n !== this.at) this.steps.delete(n);
78
+ for (const s of this.steps.values()) if (s.parent !== null && !this.steps.has(s.parent)) s.parent = null;
79
+ const lines = [...this.steps.values()].sort((a, b) => a.n - b.n).map((s) => JSON.stringify(s));
80
+ if (this.at !== null) lines.push(JSON.stringify({ at: this.at }));
81
+ fs.writeFileSync(FILE + ".tmp", lines.map((l) => l + "\n").join(""));
82
+ fs.renameSync(FILE + ".tmp", FILE);
83
+ }
84
+ }
85
+
86
+ 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 ui and its
88
+ // surfaces may come after this file); the first agent's session_start makes it, at startup, before any change
89
+ pi.on("session_start", () => {
90
+ if (!host.shared.has("ui-history")) host.shared.set("ui-history", new History(host.shared.get("ui")));
91
+ host.api.uiHistory = host.shared.get("ui-history");
92
+ host.api.ui?.watch("ui_history", (_sid: string, by?: string, what?: string) => { if (what) host.api.uiHistory.add(by, what); });
93
+ });
94
+ const shared = (): History | undefined => host.shared.get("ui-history");
95
+
96
+ host.pageHandlers.set("ui_history", (msg: any) => {
97
+ const history = shared();
98
+ if (!history) return;
99
+ const now = history.at === null ? undefined : history.steps.get(history.at);
100
+ if (msg.type === "ui_undo" && now && now.parent !== null) history.goto(now.parent);
101
+ else if (msg.type === "ui_redo") { const kids = history.children(history.at); if (kids.length) history.goto(kids.at(-1)!); }
102
+ else if (msg.type === "ui_goto") history.goto(msg.step);
103
+ });
104
+ host.routes.set("/ui-api/history", (_req: any, res: any) => {
105
+ const history = shared();
106
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ at: history?.at ?? null,
107
+ line: (history?.line() ?? []).map((n) => { const { surfaces, parent, ...s } = history!.steps.get(n)!; return s; }) }));
108
+ });
109
+ }
@@ -0,0 +1,58 @@
1
+ // Pictures for the Versions panel, taken by the open page itself (no headless browser): after a new version (the
2
+ // "version" event), and when the page opens and the current version has none yet. It draws the main space (#top)
3
+ // with dom-to-image-more, then each same-site app frame's own page pasted in place (the library leaves frames
4
+ // blank), crops to the surfaces plus a margin, and uploads it: PUT /versions/<sha>/shot (the first page wins).
5
+ // Can't be drawn: video, and pictures or frames from other sites (those parts stay blank).
6
+ const MARGIN = 32;
7
+ let lib;
8
+ const library = () => lib ??= new Promise((ok, no) => { // loaded once, on the first picture
9
+ const s = Object.assign(document.createElement("script"), { src: "/versions/dom-to-image-more.js", onload: () => ok(window.domtoimage), onerror: no });
10
+ document.head.append(s);
11
+ });
12
+ const image = (src) => new Promise((ok, no) => Object.assign(new Image(), { onload() { ok(this); }, onerror: no, src }));
13
+
14
+ async function picture() { // a PNG blob of the surfaces, or null if there are none
15
+ const top = document.getElementById("top"), surfaces = [...top.querySelectorAll("[data-surface]")];
16
+ if (!surfaces.length) return null;
17
+ const domtoimage = await library(), at = top.getBoundingClientRect();
18
+ const box = (el) => { const r = el.getBoundingClientRect(); return { x: r.left - at.left + top.scrollLeft, y: r.top - at.top + top.scrollTop, w: r.width, h: r.height }; };
19
+ const all = surfaces.map(box);
20
+ const x0 = Math.max(0, Math.min(...all.map((b) => b.x)) - MARGIN), y0 = Math.max(0, Math.min(...all.map((b) => b.y)) - MARGIN);
21
+ const x1 = Math.max(...all.map((b) => b.x + b.w)) + MARGIN, y1 = Math.max(...all.map((b) => b.y + b.h)) + MARGIN;
22
+ const width = Math.ceil(x1), height = Math.ceil(y1); // the whole main space up to the last surface; cropped below
23
+ const bgcolor = getComputedStyle(document.body).backgroundColor;
24
+ const page = await image(await domtoimage.toPng(top, { width, height, bgcolor,
25
+ style: { position: "static", inset: "auto", overflow: "visible", width: `${width}px`, height: `${height}px` } }));
26
+ const canvas = Object.assign(document.createElement("canvas"), { width: Math.ceil(x1 - x0), height: Math.ceil(y1 - y0) });
27
+ const g = canvas.getContext("2d");
28
+ g.drawImage(page, -x0, -y0);
29
+ for (const frame of top.querySelectorAll("iframe")) { // apps: their own pages, drawn and pasted where they are
30
+ let doc;
31
+ try { doc = frame.contentDocument; } catch { continue; } // another site's frame: stays blank
32
+ if (!doc?.documentElement) continue;
33
+ const b = box(frame);
34
+ if (!b.w || !b.h) continue;
35
+ try {
36
+ const app = await image(await domtoimage.toPng(doc.documentElement, { width: b.w, height: b.h, bgcolor: "#fff" }));
37
+ g.drawImage(app, b.x - x0, b.y - y0, b.w, b.h);
38
+ } catch {} // this app can't be drawn: the rest of the picture still is
39
+ }
40
+ return new Promise((ok) => canvas.toBlob(ok, "image/png"));
41
+ }
42
+
43
+ let busy = Promise.resolve();
44
+ function shoot(sha) { // one picture at a time
45
+ busy = busy.then(async () => {
46
+ const png = await picture();
47
+ if (png) await fetch(`/versions/${sha}/shot`, { method: "PUT", body: png });
48
+ }).catch((e) => console.warn("versions: no picture", e));
49
+ }
50
+
51
+ export default async function (page) {
52
+ if (new URLSearchParams(location.search).has("shot")) return; // the old headless view
53
+ page.on("version", (ev) => setTimeout(() => shoot(ev.sha), 2000)); // after the page has redrawn what changed
54
+ try { // the current version has no picture yet (made while no page was open): take it now
55
+ const current = (await (await fetch("/versions")).json()).find((v) => v.current);
56
+ if (current && !current.shot) setTimeout(() => shoot(current.short), 3000); // once the surfaces are drawn
57
+ } catch {}
58
+ }
@@ -0,0 +1,168 @@
1
+ // Version history of the agents' own code, app/ (port of the Python versions plugin; the page's Versions panel is
2
+ // unchanged). The history is a git repo kept OUTSIDE app/, in state/versions.git (the idea of pi-workspace-history):
3
+ // app/ has no .git, so an agent's own git commands can't rewrite it. Its first commit is app/ as it was the first
4
+ // time, tagged "original". Git through isomorphic-git (pure JS; no git program needed), one operation at a time.
5
+ // - When an agent's turn is over (pi's agent_settled) and app/ changed: commit, author = that agent, message =
6
+ // its reload reason, else what it was asked (Python: what it first said, often just "done"), else what it said.
7
+ // Event "version" to the pages ({agent, sha, message, files, restart: false}: they refresh what changed).
8
+ // - Pictures for the panel: taken by the open page itself (browser.js here, with dom-to-image-more; no
9
+ // headless browser), after each new version and, when a page opens, for the current version if it has none.
10
+ // In state/shots/<sha>.png. A version made while no page was open and changed again before one opened: none.
11
+ // - GET /versions commits, newest first ({sha, short, author, date, message, current, shot})
12
+ // GET /versions/<sha>/shot that commit's picture (404 if none; HEAD too)
13
+ // PUT /versions/<sha>/shot the page's picture (PNG, at most 10 MB; {saved: false} if it has one: the first page wins)
14
+ // POST /versions/<sha>/rollback that commit's files as a NEW commit (history is never rewritten, so a rollback
15
+ // can be rolled back). The Python agent restarted its process; here: stop every
16
+ // agent, put the files back, reload every agent's extensions (pi's reload), read
17
+ // the surfaces again, and tell each agent (an fyi: no turn).
18
+ // - Not ported: pip freeze (Python's library list per version).
19
+ // Ignored (info/exclude in the history repo): pi's auth.json, models-store.json and mcp-auth.json (logins), node_modules/.
20
+ // Guard: app/ is the agents' code. If a commit includes other files, its agent is told to move them to the
21
+ // workspace: a rollback would undo them.
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import git from "isomorphic-git";
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+
27
+ const host = (globalThis as any).chalky;
28
+ const GIT_DIR = path.join(host.state, "versions.git");
29
+ const SHOTS = path.join(host.state, "shots");
30
+ const MAX_SHOT = 10 * 1024 * 1024;
31
+ const CODE = ["extensions/", "skills/", "ui/", "SYSTEM.md", "mcp.json"];
32
+ const IGNORE = ["auth.json", "models-store.json", "mcp-auth.json", "node_modules/"];
33
+ const repo = { fs, dir: host.app, gitdir: GIT_DIR };
34
+ const who = (name: string) => ({ name, email: `${name}@chalky` });
35
+
36
+ const shared = host.shared.get("versions") ?? { queue: Promise.resolve(), turnFrom: {} as Record<string, number> };
37
+ host.shared.set("versions", shared);
38
+ function queued<T>(fn: () => Promise<T>): Promise<T> { // history operations one at a time (agents can finish together)
39
+ const run = shared.queue.then(fn);
40
+ shared.queue = run.catch(() => {});
41
+ return run;
42
+ }
43
+
44
+ async function stage() { // git add -A: every change in app/ into the index; the changed paths
45
+ const changed = [];
46
+ for (const [filepath, head, workdir, index] of await git.statusMatrix(repo)) {
47
+ if (head === 1 && workdir === 1 && index === 1) continue; // unchanged
48
+ // new files: statusMatrix (1.44.2) checks ignores without our gitdir, so it misses info/exclude; isIgnored doesn't
49
+ if (head === 0 && index === 0 && await git.isIgnored({ ...repo, filepath })) continue;
50
+ if (workdir === 0) { if (index !== 0) await git.remove({ ...repo, filepath }); }
51
+ else await git.add({ ...repo, filepath });
52
+ changed.push(filepath);
53
+ }
54
+ return changed;
55
+ }
56
+
57
+ if (!fs.existsSync(GIT_DIR) && !shared.creating) shared.creating = queued(async () => { // the first time (once, though every agent loads this): the history starts with app/ as it is
58
+ await git.init({ ...repo, defaultBranch: "main" });
59
+ fs.writeFileSync(path.join(GIT_DIR, "info/exclude"), IGNORE.join("\n") + "\n");
60
+ await stage();
61
+ await git.commit({ ...repo, message: "original", author: who("chalky") });
62
+ await git.tag({ ...repo, ref: "original" });
63
+ });
64
+ else if (fs.existsSync(GIT_DIR)) fs.writeFileSync(path.join(GIT_DIR, "info/exclude"), IGNORE.join("\n") + "\n"); // a history from before an IGNORE change
65
+
66
+ const head = async () => { await shared.queue; return git.resolveRef({ ...repo, ref: "HEAD" }); }; // after queued work (e.g. the first commit)
67
+
68
+ async function commit(author: string, message: string) { // the changed files, or [] if nothing changed
69
+ const changed = await stage();
70
+ if (!changed.length) return [];
71
+ let sha: string;
72
+ try { sha = await git.commit({ ...repo, message, author: who(author), disallowEmpty: true }); }
73
+ catch (e: any) { if (e.code === "EmptyCommitError") return []; throw e; } // nothing really changed
74
+ host.broadcast("version", { agent: author, sha: sha.slice(0, 7), message, files: changed, restart: false }); // pages take its picture
75
+ return changed;
76
+ }
77
+
78
+ const textOf = (m: any) => typeof m.content === "string" ? m.content
79
+ : (m.content ?? []).filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
80
+
81
+ export default function (pi: ExtensionAPI) {
82
+ pi.on("agent_start", (_e, ctx) => { shared.turnFrom[pi.getSessionName()!] = ctx.sessionManager.getEntries().length; });
83
+ pi.on("agent_settled", (_e, ctx) => {
84
+ const agent = pi.getSessionName()!;
85
+ const turn = ctx.sessionManager.getEntries().slice(shared.turnFrom[agent] ?? 0).filter((e: any) => e.type === "message").map((e: any) => e.message);
86
+ const reason = host.shared.get(`reload-reason ${agent}`);
87
+ host.shared.delete(`reload-reason ${agent}`);
88
+ let message = "";
89
+ for (const text of [reason ?? "", ...turn.filter((m: any) => m.role === "user").map(textOf), ...turn.filter((m: any) => m.role === "assistant").map(textOf)]) {
90
+ const line = text.split("\n").map((l: string) => l.trim()).find((l: string) => l && !/^\[\w+ (from|to) /.test(l));
91
+ if (line) { message = line; break; }
92
+ }
93
+ return queued(async () => {
94
+ const changed = await commit(agent, message.slice(0, 100) || `changes by ${agent}`);
95
+ // files outside the code folders that are there now (a deleted one is the agent moving it: no new warning)
96
+ const stray = changed.filter((p) => !CODE.some((c) => p.startsWith(c)) && fs.existsSync(path.join(host.app, p)));
97
+ if (stray.length && host.agents.has(agent))
98
+ host.api.swarm?.send("system", agent, `Your last turn changed files in ${host.app} that aren't your code: ${stray.slice(0, 5).join(", ")}. `
99
+ + `${host.app} is your own code (extensions, skills, ui, SYSTEM.md, mcp.json): it is versioned, and a rollback would undo these files. `
100
+ + `Move project work to ${host.work} and use it from there.`, true);
101
+ });
102
+ });
103
+
104
+ host.routes.set("/versions", async (_req: any, res: any) => {
105
+ const now = await head(); // waits for queued work, so the log is complete
106
+ const out = (await git.log(repo)).map(({ oid, commit: c }) => ({ sha: oid, short: oid.slice(0, 7), author: c.author.name,
107
+ date: new Date(c.author.timestamp * 1000).toISOString(), message: c.message.split("\n")[0], current: oid === now,
108
+ shot: fs.existsSync(path.join(SHOTS, `${oid}.png`)) })); // the page takes the current one's picture if it has none
109
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out));
110
+ });
111
+
112
+ host.routes.set("/versions/", async (req: any, res: any) => { // /versions/<sha>/shot, /versions/<sha>/rollback
113
+ const [, , given, what] = new URL(req.url, "http://x").pathname.split("/");
114
+ if (!/^[0-9a-f]{7,40}$/.test(given ?? "")) return res.writeHead(400).end();
115
+ let sha: string;
116
+ try { sha = await git.expandOid({ ...repo, oid: given }); } catch { return res.writeHead(404).end(); }
117
+ const shot = path.join(SHOTS, `${sha}.png`);
118
+ if (what === "shot" && (req.method === "GET" || req.method === "HEAD")) {
119
+ if (!fs.existsSync(shot)) return res.writeHead(404).end();
120
+ if (req.method === "HEAD") return res.writeHead(200, { "content-type": "image/png" }).end();
121
+ return fs.createReadStream(shot).pipe(res.writeHead(200, { "content-type": "image/png" }));
122
+ }
123
+ if (what === "shot" && req.method === "PUT") { // a page's picture of this version
124
+ if (fs.existsSync(shot)) // another page was first: not an error (a 409 would be logged by the browser)
125
+ return res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ saved: false }));
126
+ const chunks: Buffer[] = [];
127
+ let size = 0;
128
+ req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_SHOT) chunks.push(c); });
129
+ req.on("end", () => {
130
+ const png = Buffer.concat(chunks);
131
+ if (size > MAX_SHOT || !png.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return res.writeHead(400).end();
132
+ fs.mkdirSync(SHOTS, { recursive: true });
133
+ fs.writeFileSync(shot + ".tmp", png);
134
+ fs.renameSync(shot + ".tmp", shot);
135
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ saved: true }));
136
+ });
137
+ return;
138
+ }
139
+ if (what !== "rollback" || req.method !== "POST") return res.writeHead(404).end();
140
+ const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...repo, oid: sha })).commit.message.split("\n")[0]}`;
141
+ await host.api.swarm?.stop();
142
+ const files = await queued(async () => {
143
+ await commit("user", "uncommitted changes before rollback"); // nothing is ever lost
144
+ // that commit's files into app/ and the index, removing files it doesn't have; the branch stays where it is
145
+ await git.checkout({ ...repo, ref: sha, noUpdateHead: true, force: true });
146
+ const files = (await git.statusMatrix(repo)).filter(([, h, w, i]) => !(h === 1 && w === 1 && i === 1)).map(([f]) => f);
147
+ // git keeps files, not folders: a folder whose files the rollback removed stays, empty (an extension folder
148
+ // without index.ts would show in the map). Remove those, up to the first one that isn't empty.
149
+ for (const f of files) for (let d = path.dirname(f); d !== "."; d = path.dirname(d)) {
150
+ try { fs.rmdirSync(path.join(host.app, d)); } catch { break; }
151
+ }
152
+ await git.commit({ ...repo, message: `rollback to ${subject}`, author: who("user") });
153
+ return files;
154
+ });
155
+ for (const name of host.agents.keys()) await host.reloadAgent(name);
156
+ if (files.includes("ui/surfaces.json")) { host.shared.get("ui")?.load(); host.api.uiHistory?.add("user", `rolled the code back to ${subject}`); }
157
+ for (const name of host.agents.keys())
158
+ host.api.swarm?.fyi("system", name, `The user rolled ${host.app} back to version ${subject}. Later changes are undone `
159
+ + `(still in the history: GET http://127.0.0.1:${host.port}/versions). Check before relying on them.`);
160
+ const now = await head();
161
+ host.broadcast("version", { agent: "user", sha: now.slice(0, 7), message: `rollback to ${subject}`, files, restart: false });
162
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
163
+ });
164
+
165
+ host.routes.set("/versions/dom-to-image-more.js", (_req: any, res: any) =>
166
+ res.writeHead(200, { "content-type": "text/javascript" })
167
+ .end(fs.readFileSync(path.join(host.modules, "dom-to-image-more/dist/dom-to-image-more.min.js"))));
168
+ }
@@ -0,0 +1,12 @@
1
+ # Previews of the user's files (the viewer extension)
2
+ The user can open any file in {work}/drops/ in a preview window (a click on its name in the file
3
+ panel). A note pointed at that window has a part like {"viewing": "{work}/drops/q3.xlsx", "sheet":
4
+ "Costs", "cell": "B4"} (or "slide", "paragraph", "line"): the note is about that file and that spot, not
5
+ about the page (ignore the note's source file then).
6
+ - If the user drew around something there, the part says exactly what: {"sheet", "range": "B2:D5", "values"},
7
+ {"paragraphs": [{"paragraph", "text"}]}, {"lines": [{"slide", "line", "text"}]} or {"slides": [...]},
8
+ {"lines": "12-18", "text"}, or for an image {"region": {"x", "y", "width", "height"}} in the image's own
9
+ pixels. On a pdf it is {"image": <png>}: a picture of the circled area as the user saw it; look at it with
10
+ read (read_document gives the pdf's text, to find the same words there).
11
+ - read_document(path): a document's content as text (docx, pptx, xlsx, xls, csv, pdf, doc, txt), to see
12
+ what the user sees. For pictures use read.