@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.
Files changed (103) hide show
  1. package/README.md +10 -6
  2. package/{app → agent}/SYSTEM.md +16 -11
  3. package/agent/extensions/app/README.md +68 -0
  4. package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
  5. package/agent/extensions/app/guide.md +5 -0
  6. package/agent/extensions/app/index.ts +66 -0
  7. package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
  8. package/agent/extensions/board/browser.js +359 -0
  9. package/agent/extensions/board/guide.md +4 -0
  10. package/agent/extensions/board/index.ts +100 -0
  11. package/agent/extensions/card/README.md +88 -0
  12. package/agent/extensions/card/blocks/Button.js +34 -0
  13. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  14. package/agent/extensions/card/blocks/Markdown.js +37 -0
  15. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  16. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  18. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
  19. package/agent/extensions/card/guide.md +13 -0
  20. package/agent/extensions/card/index.ts +370 -0
  21. package/agent/extensions/command/browser.js +13 -0
  22. package/agent/extensions/command/guide.md +2 -0
  23. package/agent/extensions/command/index.ts +94 -0
  24. package/agent/extensions/doodle/README.md +57 -0
  25. package/agent/extensions/doodle/browser.js +1013 -0
  26. package/agent/extensions/doodle/guide.md +22 -0
  27. package/agent/extensions/doodle/index.ts +274 -0
  28. package/agent/extensions/file/browser.js +143 -0
  29. package/agent/extensions/file/guide.md +5 -0
  30. package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
  31. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  32. package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
  33. package/agent/extensions/frame/guide.md +3 -0
  34. package/agent/extensions/frame/index.ts +81 -0
  35. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  36. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
  37. package/{app → agent}/extensions/mcp/browser.js +1 -1
  38. package/agent/extensions/mcp/guide.md +3 -0
  39. package/{app → agent}/extensions/mcp/index.ts +2 -4
  40. package/agent/extensions/model/browser.js +363 -0
  41. package/{app → agent}/extensions/model/index.ts +8 -4
  42. package/agent/extensions/model/login.ts +215 -0
  43. package/agent/extensions/note/README.md +35 -0
  44. package/agent/extensions/note/browser.js +356 -0
  45. package/agent/extensions/note/guide.md +8 -0
  46. package/agent/extensions/note/index.ts +192 -0
  47. package/{app → agent}/extensions/page/index.ts +2 -2
  48. package/{app → agent}/extensions/page/page.html +55 -49
  49. package/agent/extensions/prompt/index.ts +38 -0
  50. package/{app → agent}/extensions/reload/index.ts +3 -3
  51. package/{app → agent}/extensions/screen/browser.js +4 -4
  52. package/{app → agent}/extensions/screen/index.ts +11 -11
  53. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  54. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  55. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  56. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  57. package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
  58. package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
  59. package/{app → agent}/extensions/viewer/README.md +4 -4
  60. package/{app → agent}/extensions/viewer/browser.js +8 -8
  61. package/agent/extensions/viewer/guide.md +3 -0
  62. package/{app → agent}/extensions/viewer/index.ts +22 -17
  63. package/agent/extensions/voice/browser.js +72 -0
  64. package/agent/extensions/voice/index.ts +39 -0
  65. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  66. package/agent/settings.json +8 -0
  67. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  68. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  69. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
  70. package/agent/skills/extension-map/SKILL.md +56 -0
  71. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  72. package/bin/chalky.mjs +3 -3
  73. package/package.json +8 -8
  74. package/server.ts +162 -63
  75. package/app/extensions/appdata/README.md +0 -10
  76. package/app/extensions/appdata/guide.md +0 -4
  77. package/app/extensions/appdata/index.ts +0 -47
  78. package/app/extensions/doodle/README.md +0 -15
  79. package/app/extensions/doodle/browser.js +0 -388
  80. package/app/extensions/doodle/guide.md +0 -6
  81. package/app/extensions/doodle/index.ts +0 -54
  82. package/app/extensions/files/browser.js +0 -96
  83. package/app/extensions/files/guide.md +0 -7
  84. package/app/extensions/frames/guide.md +0 -7
  85. package/app/extensions/frames/index.ts +0 -76
  86. package/app/extensions/mcp/guide.md +0 -5
  87. package/app/extensions/point/README.md +0 -22
  88. package/app/extensions/point/browser.js +0 -245
  89. package/app/extensions/point/guide.md +0 -6
  90. package/app/extensions/point/index.ts +0 -97
  91. package/app/extensions/prompt/index.ts +0 -19
  92. package/app/extensions/ui/README.md +0 -69
  93. package/app/extensions/ui/components/App.js +0 -28
  94. package/app/extensions/ui/components/Button.js +0 -28
  95. package/app/extensions/ui/components/Markdown.js +0 -37
  96. package/app/extensions/ui/guide.md +0 -7
  97. package/app/extensions/ui/index.ts +0 -271
  98. package/app/extensions/viewer/guide.md +0 -5
  99. package/app/mcp.json +0 -3
  100. package/app/skills/plugin-map/SKILL.md +0 -49
  101. package/app/ui/apps/.keep +0 -0
  102. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  103. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
@@ -1,46 +1,42 @@
1
- // Peer-to-peer swarm (port of the Python swarm plugin, on pi's pieces): no manager; anyone (the user or an agent)
1
+ // The team: peer-to-peer agents, no manager; anyone (the user or an agent)
2
2
  // can talk to any agent. Every agent is its own pi session (host.createAgent). A message starts a turn, and the
3
3
  // turn's final answer goes back to whoever is owed it. Nothing is said "to no one".
4
4
  // - Kinds of item: message from the user or the harness; the answer is shown in the chat
5
- // question ask_agent: the asker waits; the answer is its tool result
6
- // note tell: the sender goes on; the answer comes back to it as a reply
7
- // reply the answer to a note (or to a question that timed out)
8
- // fyi a tell to the agent you owe your answer to: an update, not a request (no answer back)
9
- // - Each item has a chain: who gets the answer, nearest first. A note from agent1 (working for the user) has
5
+ // question team_ask: the asker waits; the answer is its tool result
6
+ // request team_tell: the sender goes on; the answer comes back to it as a reply
7
+ // reply the answer to a request (or to a question that timed out)
8
+ // fyi a team_tell to the agent you owe your answer to: an update, not a request (no answer back)
9
+ // - Each item has a chain: who gets the answer, nearest first. A request from agent1 (working for the user) has
10
10
  // chain [agent1, user]; agent2's answer goes to agent1 as a reply with chain [user], and agent1's answer to that
11
11
  // reply goes to the user. So replies never bounce back and forth.
12
- // - Notes, replies and fyis for an agent in the middle of a turn: pi's steer() (it sees them between its tool calls;
12
+ // - Requests, replies and fyis for an agent in the middle of a turn: pi's steer() (it sees them between its tool calls;
13
13
  // no turn of their own). An fyi for an idle agent: pi's sendCustomMessage() (filed; the model sees it next turn).
14
14
  // Messages and questions wait in the inbox for a turn of their own, one at a time.
15
- // - Limits: asks + tells per user message, agents, inbox size, notes per minute, identical notes dropped, ask
16
- // timeout. Asking an agent that is waiting on you is refused (use tell).
17
- // Shared state is plain data on the host (host.shared "swarm"); the code that acts on it is this file's newest load
18
- // (host.api.swarm), so an edited swarm/index.ts takes effect after a reload. Agents: host.agents (role, app, session).
15
+ // - Limits: asks + tells per user message, agents, inbox size, requests per minute, identical requests dropped, ask
16
+ // timeout. Asking an agent that is waiting on you is refused (use team_tell).
17
+ // Shared state is plain data on the host (host.shared "team"); the code that acts on it is this file's newest load
18
+ // (host.api.team), so an edited team/index.ts takes effect after a reload. Agents: host.agents (role, app, session).
19
19
  // Pictures with a message (doodle's steps): send(..., images) takes png paths; they go to pi with the text (prompt
20
20
  // or steer), read when the agent gets the message.
21
- // Not ported: the tasks plugin's "blocked" state.
22
21
  import fs from "node:fs";
23
22
  import path from "node:path";
24
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
24
  import { Type } from "typebox";
26
25
 
27
26
  const host = (globalThis as any).chalky;
28
- const MAX_MESSAGES = Number(process.env.SWARM_MAX_MESSAGES || 50); // ask_agent + tell calls per user message
29
- const MAX_AGENTS = Number(process.env.SWARM_MAX_AGENTS || 8);
30
- const ASK_TIMEOUT = Number(process.env.SWARM_ASK_TIMEOUT || 600); // seconds; then the answer comes as a reply
27
+ const MAX_MESSAGES = Number(process.env.TEAM_MAX_MESSAGES || 50); // team_ask + team_tell calls per user message
28
+ const MAX_AGENTS = Number(process.env.TEAM_MAX_AGENTS || 8);
29
+ const ASK_TIMEOUT = Number(process.env.TEAM_ASK_TIMEOUT || 600); // seconds; then the answer comes as a reply
31
30
  const INBOX_CAP = 50; // items waiting for one agent
32
- const NOTES_PER_MINUTE = 20; // per sender
33
- const APP_MESSAGES_PER_MINUTE = 30; // from apps (the page) to agents
34
- const TEAM = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it
35
- .replace("README.md", path.join(import.meta.dirname, "README.md"));
31
+ const REQUESTS_PER_MINUTE = 20; // per sender
36
32
 
37
33
  type Waiter = { resolve: (answer: string) => void } | null;
38
34
  type Item = { kind: string; sender: string; text: string; chain: string[]; ts: number; waiter: Waiter; images?: string[] };
39
35
 
40
- if (!host.shared.has("swarm"))
41
- host.shared.set("swarm", { inbox: {}, running: new Set(), chain: {}, waiting: {}, doing: {}, sent: 0, recent: {},
42
- lastNote: {}, appSent: [] });
43
- const S = host.shared.get("swarm");
36
+ if (!host.shared.has("team"))
37
+ host.shared.set("team", { inbox: {}, running: new Set(), chain: {}, waiting: {}, doing: {}, sent: 0, recent: {},
38
+ lastRequest: {} });
39
+ const S = host.shared.get("team");
44
40
  const inbox = (name: string): Item[] => (S.inbox[name] ??= []);
45
41
  const isAgent = (name: string) => host.agents.has(name);
46
42
  const target = (chain: string[]) => (chain[0] && isAgent(chain[0]) ? chain[0] : "user");
@@ -64,7 +60,7 @@ const pictures = (paths: string[] = []) => paths.filter((p) => fs.existsSync(p))
64
60
  function put(to: string, kind: string, sender: string, text: string, chain: string[], waiter: Waiter = null, images?: string[]) {
65
61
  const item: Item = { kind, sender, text, chain, ts: Date.now(), waiter, images };
66
62
  const session = host.agents.get(to).session;
67
- if (["note", "reply", "fyi"].includes(kind) && session.isStreaming) void session.steer(`${header(item)}\n${text}`, pictures(images));
63
+ if (["request", "reply", "fyi"].includes(kind) && session.isStreaming) void session.steer(`${header(item)}\n${text}`, pictures(images));
68
64
  else if (kind === "fyi") // its turn ended before it was handed over: file it, don't start a turn
69
65
  void session.sendCustomMessage({ customType: "inbox", content: `${header(item)}\n${text}`, display: true });
70
66
  else {
@@ -141,11 +137,11 @@ function ask(asker: string, to: string, text: string, signal?: AbortSignal) {
141
137
 
142
138
  const api = {
143
139
  status,
144
- // From the user or the harness (the page's chat, apps, ui actions). The answer is shown in the chat.
140
+ // From the user or the harness (the page's chat, apps, card actions). The answer is shown in the chat.
145
141
  // Returns the item (for withdraw). images: png paths sent with it.
146
- send(sender: string, to: string, text: string, note = false, images?: string[]) {
142
+ send(sender: string, to: string, text: string, request = false, images?: string[]) {
147
143
  if (sender === "user") S.sent = 0; // a new user message gives the team a fresh budget
148
- return put(to, note ? "note" : "message", sender, text, [sender], null, images);
144
+ return put(to, request ? "request" : "message", sender, text, [sender], null, images);
149
145
  },
150
146
  // An update for an agent that doesn't start a turn (filed if idle, between its tool calls if busy)
151
147
  fyi(sender: string, to: string, text: string) { return put(to, "fyi", sender, text, [sender]); },
@@ -174,21 +170,16 @@ const api = {
174
170
  return null;
175
171
  },
176
172
  };
177
- host.api.swarm = api;
173
+ host.api.team = api;
178
174
 
179
175
  const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
180
176
 
181
177
  export default function (pi: ExtensionAPI) {
182
178
  const me = () => pi.getSessionName()!;
183
- pi.on("before_agent_start", (event) => {
184
- const a = host.agents.get(me());
185
- if (a) event.systemPromptOptions.sections.team = TEAM.replace("{name}", me()).replace("{role}", a.role)
186
- .replace("{max_agents}", String(MAX_AGENTS));
187
- });
188
179
  // What each agent is doing right now, for status
189
180
  pi.on("tool_execution_start", (ev) => {
190
181
  const args = ev.args ?? {};
191
- changed(me(), `${ev.toolName} ${String(args.command ?? args.to ?? args.surface ?? args.path ?? "").slice(0, 60)}`.trim());
182
+ changed(me(), `${ev.toolName} ${String(args.command ?? args.to ?? args.card ?? args.path ?? "").slice(0, 60)}`.trim());
192
183
  });
193
184
  pi.on("turn_start", () => changed(me(), "thinking"));
194
185
 
@@ -200,18 +191,18 @@ export default function (pi: ExtensionAPI) {
200
191
  };
201
192
 
202
193
  pi.registerTool({
203
- name: "spawn_agent", label: "Spawn agent",
194
+ name: "team_spawn", label: "Team spawn",
204
195
  description: "Create a new peer agent with a role. It starts idle. Agents are numbered in creation order (agent1, agent2, ...); "
205
196
  + "the result tells you the new name.",
206
197
  parameters: Type.Object({
207
198
  role: Type.String(),
208
199
  name: Type.Optional(Type.String({ description: "Only if the user asked for a specific name. Otherwise leave out." })),
209
- app: Type.Optional(Type.String({ description: "The surface of the app this agent is part of (a simulation's member, a "
200
+ app: Type.Optional(Type.String({ description: "The card of the app this agent is part of (a simulation's member, a "
210
201
  + "character). Leave out for agents that work with you." })),
211
202
  }),
212
203
  async execute(_id, a) {
213
204
  if (a.name && (isAgent(a.name) || a.name === "user")) return result(`error: name '${a.name}' is taken`);
214
- if (host.agents.size >= MAX_AGENTS) return result(`error: the team is full (${MAX_AGENTS} agents). Give work to an existing agent (list_agents).`);
205
+ if (host.agents.size >= MAX_AGENTS) return result(`error: the team is full (${MAX_AGENTS} agents). Give work to an existing agent (team_list).`);
215
206
  let name = a.name, n = host.agents.size + 1;
216
207
  while (!name || isAgent(name)) name = `agent${n++}`; // next free number
217
208
  await host.createAgent(name, a.role, a.app);
@@ -221,7 +212,7 @@ export default function (pi: ExtensionAPI) {
221
212
  });
222
213
 
223
214
  pi.registerTool({
224
- name: "reset_agent", label: "Reset agent",
215
+ name: "team_reset", label: "Team reset", exposure: "deferred",
225
216
  description: "Empty an agent's memory, keeping its role (for a new round that starts fresh). Give a name, or app to reset "
226
217
  + "all agents of that app. Only when they are idle.",
227
218
  parameters: Type.Object({ name: Type.Optional(Type.String()), app: Type.Optional(Type.String()) }),
@@ -235,48 +226,48 @@ export default function (pi: ExtensionAPI) {
235
226
  });
236
227
 
237
228
  pi.registerTool({
238
- name: "tell", label: "Tell",
239
- description: "Send a note to another agent and keep working (don't wait). Its answer comes back to you later as a reply.",
229
+ name: "team_tell", label: "Team tell",
230
+ description: "Send a request to another agent and keep working (don't wait). Its answer comes back to you later as a reply.",
240
231
  parameters: Type.Object({ to: Type.String(), text: Type.String() }),
241
232
  async execute(_id, a) {
242
233
  const agent = me(), now = Date.now();
243
234
  const error = checkTo(agent, a.to);
244
235
  if (error) return result(error);
245
236
  const recent = (S.recent[agent] ?? []).filter((t: number) => now - t < 60000);
246
- if (S.lastNote[`${agent}>${a.to}`] === a.text) return result(`error: dropped, same as your last note to ${a.to}. It already has it.`);
247
- if (recent.length >= NOTES_PER_MINUTE) return result(`error: slow down: at most ${NOTES_PER_MINUTE} notes a minute. Wait for replies instead.`);
237
+ if (S.lastRequest[`${agent}>${a.to}`] === a.text) return result(`error: dropped, same as your last request to ${a.to}. It already has it.`);
238
+ if (recent.length >= REQUESTS_PER_MINUTE) return result(`error: slow down: at most ${REQUESTS_PER_MINUTE} requests a minute. Wait for replies instead.`);
248
239
  if (inbox(a.to).length >= INBOX_CAP) return result(`error: ${a.to}'s inbox is full (${INBOX_CAP}). Wait for it to catch up.`);
249
240
  S.recent[agent] = [...recent, now];
250
- S.lastNote[`${agent}>${a.to}`] = a.text;
241
+ S.lastRequest[`${agent}>${a.to}`] = a.text;
251
242
  S.sent++;
252
243
  if (owed(agent) === a.to) { // it's owed your final answer anyway: an update, no answer back
253
244
  put(a.to, "fyi", agent, a.text, []);
254
245
  return result(`sent as information only: ${a.to} gets your final answer anyway, so don't repeat this there. `
255
- + `It won't answer this; if you need an answer from ${a.to}, use ask_agent.`);
246
+ + `It won't answer this; if you need an answer from ${a.to}, use team_ask.`);
256
247
  }
257
- put(a.to, "note", agent, a.text, [agent, ...(S.chain[agent] ?? [])]);
248
+ put(a.to, "request", agent, a.text, [agent, ...(S.chain[agent] ?? [])]);
258
249
  return result(`sent. ${a.to} is ${status()[a.to].state}; its answer will come to you as a reply. `
259
250
  + "If all you have left is waiting, end your turn: the reply wakes you.");
260
251
  },
261
252
  });
262
253
 
263
254
  pi.registerTool({
264
- name: "ask_agent", label: "Ask agent",
265
- description: "Ask another agent and wait for its answer (the result). For quick questions; use tell for longer work. "
266
- + "Several ask_agent calls in one step run in parallel.",
255
+ name: "team_ask", label: "Team ask",
256
+ description: "Ask another agent and wait for its answer (the result). For quick questions; use team_tell for longer work. "
257
+ + "Several team_ask calls in one step run in parallel.",
267
258
  parameters: Type.Object({ to: Type.String(), text: Type.String() }),
268
259
  async execute(_id, a, signal) {
269
260
  const agent = me();
270
261
  const error = checkTo(agent, a.to);
271
262
  if (error) return result(error);
272
- if (waitsOn(a.to, agent)) return result(`error: '${a.to}' is waiting on you, so it can't answer now. Use tell, or answer from what you know.`);
263
+ if (waitsOn(a.to, agent)) return result(`error: '${a.to}' is waiting on you, so it can't answer now. Use team_tell, or answer from what you know.`);
273
264
  S.sent++;
274
265
  return result(await ask(agent, a.to, a.text, signal));
275
266
  },
276
267
  });
277
268
 
278
269
  pi.registerTool({
279
- name: "list_agents", label: "List agents", description: "List all agents: role, and what each is doing right now.",
270
+ name: "team_list", label: "Team list", description: "List all agents: role, and what each is doing right now.",
280
271
  parameters: Type.Object({}),
281
272
  async execute() {
282
273
  const now = Date.now() / 1000;
@@ -285,20 +276,4 @@ export default function (pi: ExtensionAPI) {
285
276
  + (s.queued ? `, ${s.queued} waiting in its inbox` : "")).join("\n"));
286
277
  },
287
278
  });
288
-
289
- // From apps on the page (/ui/agents.js): a message to an app agent, or a fresh round for an app's agents
290
- host.pageHandlers.set("swarm", async (msg: any) => {
291
- if (msg.type === "app_reset") {
292
- const names = [...host.agents.values()].filter((x: any) => x.app === msg.surface).map((x: any) => x.name);
293
- const error = await api.reset(names);
294
- if (error) host.broadcast("error", { agent: "app", text: `reset of ${msg.surface}: ${error}` });
295
- } else if (msg.type === "app_message") {
296
- const now = Date.now();
297
- S.appSent = S.appSent.filter((t: number) => now - t < 60000);
298
- if (!isAgent(msg.to)) host.broadcast("error", { agent: "app", text: `app message to unknown agent '${msg.to}'` });
299
- else if (S.appSent.length >= APP_MESSAGES_PER_MINUTE)
300
- host.broadcast("error", { agent: "app", text: `app message to ${msg.to} dropped: more than ${APP_MESSAGES_PER_MINUTE} a minute` });
301
- else { S.appSent.push(now); api.send("app", msg.to, msg.text ?? ""); }
302
- }
303
- });
304
279
  }
@@ -1,25 +1,25 @@
1
1
  // Pictures for the Versions panel, taken by the open page itself (no headless browser): after a new version (the
2
2
  // "version" event), and when the page opens and the current version has none yet. It draws the main space (#top)
3
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).
4
+ // blank), crops to the cards plus a margin, and uploads it: PUT /version/<sha>/shot (the first page wins).
5
5
  // Can't be drawn: video, and pictures or frames from other sites (those parts stay blank).
6
6
  const MARGIN = 32;
7
7
  let lib;
8
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 });
9
+ const s = Object.assign(document.createElement("script"), { src: "/version/dom-to-image-more.js", onload: () => ok(window.domtoimage), onerror: no });
10
10
  document.head.append(s);
11
11
  });
12
12
  const image = (src) => new Promise((ok, no) => Object.assign(new Image(), { onload() { ok(this); }, onerror: no, src }));
13
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;
14
+ async function picture() { // a PNG blob of the cards, or null if there are none
15
+ const top = document.getElementById("top"), cards = [...top.querySelectorAll("[data-card]")];
16
+ if (!cards.length) return null;
17
17
  const domtoimage = await library(), at = top.getBoundingClientRect();
18
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);
19
+ const all = cards.map(box);
20
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
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
22
+ const width = Math.ceil(x1), height = Math.ceil(y1); // the whole main space up to the last card; cropped below
23
23
  const bgcolor = getComputedStyle(document.body).backgroundColor;
24
24
  const page = await image(await domtoimage.toPng(top, { width, height, bgcolor,
25
25
  style: { position: "static", inset: "auto", overflow: "visible", width: `${width}px`, height: `${height}px` } }));
@@ -44,7 +44,7 @@ let busy = Promise.resolve();
44
44
  function shoot(sha) { // one picture at a time
45
45
  busy = busy.then(async () => {
46
46
  const png = await picture();
47
- if (png) await fetch(`/versions/${sha}/shot`, { method: "PUT", body: png });
47
+ if (png) await fetch(`/version/${sha}/shot`, { method: "PUT", body: png });
48
48
  }).catch((e) => console.warn("versions: no picture", e));
49
49
  }
50
50
 
@@ -52,7 +52,7 @@ export default async function (page) {
52
52
  if (new URLSearchParams(location.search).has("shot")) return; // the old headless view
53
53
  page.on("version", (ev) => setTimeout(() => shoot(ev.sha), 2000)); // after the page has redrawn what changed
54
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
55
+ const current = (await (await fetch("/version")).json()).find((v) => v.current);
56
+ if (current && !current.shot) setTimeout(() => shoot(current.short), 3000); // once the cards are drawn
57
57
  } catch {}
58
58
  }
@@ -1,67 +1,51 @@
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 =
1
+ // Version history of agent/ and built/ together: what the agents are and what they made, so one rollback puts both
2
+ // back. The repo and the rule for what it keeps (not logins, endpoints or installed packages) are the host's
3
+ // (host.history, server.ts: /recover uses them too); this extension commits, lists and rolls back, one operation at
4
+ // a time. Its first commit is both folders as they were the first time, tagged "original".
5
+ // - When an agent's turn is over (pi's agent_settled) and either folder changed: commit, author = that agent, message =
6
6
  // its reload reason, else what it was asked (Python: what it first said, often just "done"), else what it said.
7
7
  // Event "version" to the pages ({agent, sha, message, files, restart: false}: they refresh what changed).
8
8
  // - Pictures for the panel: taken by the open page itself (browser.js here, with dom-to-image-more; no
9
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
10
+ // In data/version/shots/<sha>.png. A version made while no page was open and changed again before one opened: none.
11
+ // - GET /version commits, newest first ({sha, short, author, date, message, current, shot})
12
+ // GET /version/<sha>/shot that commit's picture (404 if none; HEAD too)
13
+ // PUT /version/<sha>/shot the page's picture (PNG, at most 10 MB; {saved: false} if it has one: the first page wins)
14
+ // POST /version/<sha>/rollback that commit's files as a NEW commit (history is never rewritten, so a rollback
15
15
  // can be rolled back). The Python agent restarted its process; here: stop every
16
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.
17
+ // the cards again, and tell each agent (an fyi: no turn).
18
+ // Guard: agent/ is the agents themselves. If a commit adds other files there, its agent is told where they belong
19
+ // (built/ for what it makes for the user, workspace/ for project work).
22
20
  import fs from "node:fs";
23
21
  import path from "node:path";
24
22
  import git from "isomorphic-git";
25
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
24
 
27
25
  const host = (globalThis as any).chalky;
28
- const GIT_DIR = path.join(host.state, "versions.git");
29
- const SHOTS = path.join(host.state, "shots");
26
+ const { repo, kept, stage } = host.history;
27
+ const SHOTS = path.join(host.data, "version", "shots");
30
28
  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
29
  const who = (name: string) => ({ name, email: `${name}@chalky` });
35
30
 
36
- const shared = host.shared.get("versions") ?? { queue: Promise.resolve(), turnFrom: {} as Record<string, number> };
37
- host.shared.set("versions", shared);
31
+ const shared = host.shared.get("version") ?? { queue: Promise.resolve(), turnFrom: {} as Record<string, number> };
32
+ host.shared.set("version", shared);
38
33
  function queued<T>(fn: () => Promise<T>): Promise<T> { // history operations one at a time (agents can finish together)
39
34
  const run = shared.queue.then(fn);
40
35
  shared.queue = run.catch(() => {});
41
36
  return run;
42
37
  }
43
38
 
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
39
 
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
40
+ // The first time (once per start, though every agent loads this): the history starts with both folders as they are.
41
+ // "No commit yet", not "no repo yet": a start stopped between init and the first commit leaves a repo without HEAD.
42
+ if (!shared.creating) shared.creating = queued(async () => {
43
+ try { await git.resolveRef({ ...repo, ref: "HEAD" }); return; } catch {}
58
44
  await git.init({ ...repo, defaultBranch: "main" });
59
- fs.writeFileSync(path.join(GIT_DIR, "info/exclude"), IGNORE.join("\n") + "\n");
60
45
  await stage();
61
46
  await git.commit({ ...repo, message: "original", author: who("chalky") });
62
47
  await git.tag({ ...repo, ref: "original" });
63
48
  });
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
49
 
66
50
  const head = async () => { await shared.queue; return git.resolveRef({ ...repo, ref: "HEAD" }); }; // after queued work (e.g. the first commit)
67
51
 
@@ -92,16 +76,16 @@ export default function (pi: ExtensionAPI) {
92
76
  }
93
77
  return queued(async () => {
94
78
  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)));
79
+ // files in agent/ that aren't part of an agent, there now (a deleted one is the agent moving it: no new warning)
80
+ const stray = changed.filter((p) => p.startsWith("agent/") && !host.isAgentPart(p.slice("agent/".length)) && fs.existsSync(path.join(host.home, p)));
97
81
  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);
82
+ host.api.team?.send("system", agent, `Your last turn added files to ${host.agent} that aren't part of you: ${stray.slice(0, 5).join(", ")}. `
83
+ + `${host.agent} is you (extensions, skills, SYSTEM.md, mcp.json, settings.json). Move what you make for the user to `
84
+ + `${host.built}, and project work to ${host.workspace}.`, true);
101
85
  });
102
86
  });
103
87
 
104
- host.routes.set("/versions", async (_req: any, res: any) => {
88
+ host.routes.set("/version", async (_req: any, res: any) => {
105
89
  const now = await head(); // waits for queued work, so the log is complete
106
90
  const out = (await git.log(repo)).map(({ oid, commit: c }) => ({ sha: oid, short: oid.slice(0, 7), author: c.author.name,
107
91
  date: new Date(c.author.timestamp * 1000).toISOString(), message: c.message.split("\n")[0], current: oid === now,
@@ -109,7 +93,7 @@ export default function (pi: ExtensionAPI) {
109
93
  res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out));
110
94
  });
111
95
 
112
- host.routes.set("/versions/", async (req: any, res: any) => { // /versions/<sha>/shot, /versions/<sha>/rollback
96
+ host.routes.set("/version/", async (req: any, res: any) => { // /version/<sha>/shot, /version/<sha>/rollback
113
97
  const [, , given, what] = new URL(req.url, "http://x").pathname.split("/");
114
98
  if (!/^[0-9a-f]{7,40}$/.test(given ?? "")) return res.writeHead(400).end();
115
99
  let sha: string;
@@ -138,31 +122,32 @@ export default function (pi: ExtensionAPI) {
138
122
  }
139
123
  if (what !== "rollback" || req.method !== "POST") return res.writeHead(404).end();
140
124
  const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...repo, oid: sha })).commit.message.split("\n")[0]}`;
141
- await host.api.swarm?.stop();
125
+ await host.api.team?.stop();
142
126
  const files = await queued(async () => {
143
127
  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);
128
+ // that commit's agent/ and built/ into the folders and the index, removing files it doesn't have; the branch
129
+ // stays where it is. What the history doesn't keep (logins, endpoints) isn't touched.
130
+ await git.checkout({ ...repo, ref: sha, noUpdateHead: true, force: true, filepaths: kept.filepaths });
131
+ const files = await stage();
147
132
  // git keeps files, not folders: a folder whose files the rollback removed stays, empty (an extension folder
148
133
  // 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; }
134
+ for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) {
135
+ try { fs.rmdirSync(path.join(host.home, d)); } catch { break; }
151
136
  }
152
137
  await git.commit({ ...repo, message: `rollback to ${subject}`, author: who("user") });
153
138
  return files;
154
139
  });
155
140
  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}`); }
141
+ if (files.includes("built/card.json")) { host.shared.get("card")?.load(); host.api.history?.add("user", `rolled back to ${subject}`); }
157
142
  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.`);
143
+ host.api.team?.fyi("system", name, `The user rolled ${host.agent} and ${host.built} back to version ${subject}. Later changes are undone `
144
+ + `(still in the history: GET http://127.0.0.1:${host.port}/version). Check before relying on them.`);
160
145
  const now = await head();
161
146
  host.broadcast("version", { agent: "user", sha: now.slice(0, 7), message: `rollback to ${subject}`, files, restart: false });
162
147
  res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
163
148
  });
164
149
 
165
- host.routes.set("/versions/dom-to-image-more.js", (_req: any, res: any) =>
150
+ host.routes.set("/version/dom-to-image-more.js", (_req: any, res: any) =>
166
151
  res.writeHead(200, { "content-type": "text/javascript" })
167
152
  .end(fs.readFileSync(path.join(host.modules, "dom-to-image-more/dist/dom-to-image-more.min.js"))));
168
153
  }
@@ -1,12 +1,12 @@
1
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":
2
+ The user can open any file in {workspace}/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": "{workspace}/drops/q3.xlsx", "sheet":
4
4
  "Costs", "cell": "B4"} (or "slide", "paragraph", "line"): the note is about that file and that spot, not
5
5
  about the page (ignore the note's source file then).
6
6
  - If the user drew around something there, the part says exactly what: {"sheet", "range": "B2:D5", "values"},
7
7
  {"paragraphs": [{"paragraph", "text"}]}, {"lines": [{"slide", "line", "text"}]} or {"slides": [...]},
8
8
  {"lines": "12-18", "text"}, or for an image {"region": {"x", "y", "width", "height"}} in the image's own
9
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
10
+ read (viewer_read gives the pdf's text, to find the same words there).
11
+ - viewer_read(path) (load it with tool_search): a document's content as text (docx, pptx, xlsx, xls, csv, pdf, doc, txt), to see
12
12
  what the user sees. For pictures use read.
@@ -1,10 +1,10 @@
1
- // Browser half of the viewer plugin: a preview window per file, opened by the files plugin's "file-open" DOM
1
+ // The page side of the viewer extension: a preview window per file, opened by the file extension's "file-open" DOM
2
2
  // event {file: {name, path, size}} (a click on a row of the file panel, or files dropped on the page).
3
3
  // A window starts attached to its row: left of the panel, at the row's height, a notch pointing at it, the row
4
4
  // lit. Only one window is attached at a time (opening another file replaces it); dragging its label (shown
5
- // on hover, like a surface's: name, ✕) or resizing it from the corner detaches it, and then it stays where
5
+ // on hover, like a card's: name, ✕) or resizing it from the corner detaches it, and then it stays where
6
6
  // it was put. ✕ closes it. Windows live only in this page (not saved, not seen by the agent).
7
- // Pointing at a window with the pen: point.js asks its partAt(x, y), which answers which file and where:
7
+ // Pointing at a window with the pen: note/browser.js asks its partAt(x, y), which answers which file and where:
8
8
  // {viewing, sheet, cell} | {viewing, slide} | {viewing, paragraph} | {viewing, line} | {viewing}.
9
9
  // Drawing around something: partIn(points) answers what is inside the drawn shape (a thing whose middle is
10
10
  // inside it; if none, the thing under the drawing's middle), with its content, at most MAX_PICK characters:
@@ -83,19 +83,19 @@ export default async function (page) {
83
83
  const layer = el("div", { class: "viewers" });
84
84
  document.body.append(layer);
85
85
 
86
- const rowOf = (path) => document.querySelector(`#side .files-row[data-path="${CSS.escape(path)}"]`);
86
+ const rowOf = (path) => document.querySelector(`#side .file-row[data-path="${CSS.escape(path)}"]`);
87
87
 
88
88
  function place(w) { // attached: left of the panel, at the row's height (the folder button if the panel is closed)
89
89
  if (!w.attached) return;
90
90
  const row = rowOf(w.file.path);
91
91
  const visible = row && row.offsetParent;
92
- const anchor = visible ? row : document.querySelector("#side .files-toggle");
92
+ const anchor = visible ? row : document.querySelector("#side .file-toggle");
93
93
  if (!anchor) return;
94
94
  const a = anchor.getBoundingClientRect(), panel = (visible ? row.parentElement : anchor).getBoundingClientRect();
95
95
  const h = w.box.offsetHeight, y = Math.max(72, Math.min(a.top - 14, innerHeight - h - 16)); // 72: below the palette
96
96
  Object.assign(w.box.style, { right: `${innerWidth - panel.left + 14}px`, left: "", top: `${y}px` });
97
97
  w.box.style.setProperty("--notch", `${Math.max(10, Math.min(a.top + a.height / 2 - y - 6, h - 22))}px`);
98
- for (const r of document.querySelectorAll("#side .files-row.viewing")) r.classList.remove("viewing");
98
+ for (const r of document.querySelectorAll("#side .file-row.viewing")) r.classList.remove("viewing");
99
99
  if (visible) row.classList.add("viewing");
100
100
  }
101
101
  const placeAll = () => Object.values(windows).forEach(place);
@@ -103,7 +103,7 @@ export default async function (page) {
103
103
  document.addEventListener("scroll", placeAll, true); // the panel's list scrolls
104
104
  const side = document.getElementById("side");
105
105
  if (side) new MutationObserver(() => { // the list redrawn or the panel opened or closed
106
- for (const w of Object.values(windows)) if (!rowOf(w.file.path) && w.attached && side.querySelector(".files-row")) close(w); // file gone
106
+ for (const w of Object.values(windows)) if (!rowOf(w.file.path) && w.attached) close(w); // file gone (deleted too)
107
107
  placeAll();
108
108
  }).observe(side, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-open", "hidden"] });
109
109
 
@@ -148,7 +148,7 @@ export default async function (page) {
148
148
  left: `${Math.max(0, Math.min(r.left + dx, innerWidth - 60))}px`, top: `${Math.max(24, Math.min(r.top + dy, innerHeight - 40))}px` }));
149
149
  corner.onpointerdown = (e) => dragging(w, e, (r, dx, dy) => Object.assign(box.style, {
150
150
  width: `${Math.max(240, r.width + dx)}px`, height: `${Math.max(160, r.height + dy)}px` }));
151
- box.partAt = (x, y) => { // for point.js: which file, and where in it
151
+ box.partAt = (x, y) => { // for note/browser.js: which file, and where in it
152
152
  const r = box.getBoundingClientRect();
153
153
  const hit = document.elementsFromPoint(r.left + x, r.top + y).find((e) => box.contains(e));
154
154
  const spot = hit?.closest("[data-part]");
@@ -0,0 +1,3 @@
1
+ # Previews of the user's files
2
+ A note whose part has "viewing": <a file> is about that spot in that file, not the page: read README.md before you
3
+ handle one.