@chalky/chalky 0.1.4 → 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 (104) hide show
  1. package/README.md +10 -5
  2. package/{app → agent}/SYSTEM.md +15 -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/{app/extensions/boards → agent/extensions/board}/browser.js +25 -23
  9. package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
  10. package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
  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 -129
  19. package/agent/extensions/card/guide.md +13 -0
  20. package/agent/extensions/card/index.ts +370 -0
  21. package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
  22. package/agent/extensions/command/guide.md +2 -0
  23. package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
  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 +20 -18
  33. package/agent/extensions/frame/guide.md +3 -0
  34. package/{app/extensions/frames → agent/extensions/frame}/index.ts +27 -31
  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/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
  41. package/{app → agent}/extensions/model/index.ts +8 -4
  42. package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
  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/{app → agent}/extensions/voice/browser.js +4 -3
  64. package/{app → agent}/extensions/voice/index.ts +8 -6
  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 +2 -2
  73. package/package.json +8 -8
  74. package/server.ts +151 -58
  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/commands/guide.md +0 -3
  79. package/app/extensions/doodle/README.md +0 -15
  80. package/app/extensions/doodle/browser.js +0 -398
  81. package/app/extensions/doodle/guide.md +0 -6
  82. package/app/extensions/doodle/index.ts +0 -54
  83. package/app/extensions/files/browser.js +0 -96
  84. package/app/extensions/files/guide.md +0 -7
  85. package/app/extensions/frames/guide.md +0 -7
  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 -247
  89. package/app/extensions/point/guide.md +0 -6
  90. package/app/extensions/point/index.ts +0 -112
  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 -291
  98. package/app/extensions/viewer/guide.md +0 -5
  99. package/app/mcp.json +0 -3
  100. package/app/settings.json +0 -3
  101. package/app/skills/plugin-map/SKILL.md +0 -49
  102. package/app/ui/apps/.keep +0 -0
  103. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  104. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
package/server.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Host: the fixed core under the agents. Everything else, the page included, is a pi extension in
2
- // app/extensions/<name>/ (agents can change those; this file they can't). This file only:
3
- // - runs the web server: its WebSocket /ws, and every route extensions add (the page: extensions/page);
4
- // - keeps one pi AgentSession per agent (session name = agent name; saved under state/sessions/<agent>);
5
- // - serves /recover: going back to a working version when an agent broke the page (the safety net);
2
+ // agent/extensions/<name>/ (agents can change those; this file they can't). This file only:
3
+ // - lays out the four places (agent/, built/, data/, workspace/) and moves an older layout into them once;
4
+ // - runs the web server: its WebSocket /ws, /built/ (what the agents made), and every route extensions add;
5
+ // - keeps one pi AgentSession per agent (session name = agent name; saved under data/sessions/<agent>);
6
+ // - keeps the rule for the version history (host.history) and serves /recover, going back to a working version
7
+ // when an agent broke the page (the safety net);
6
8
  // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
7
9
  // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
8
10
  // Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
@@ -17,42 +19,115 @@ import { createAgentSession, createCodemodeExtension, createMcpExtension, create
17
19
  import git from "isomorphic-git";
18
20
  import { WebSocketServer } from "ws";
19
21
 
20
- const ROOT = import.meta.dirname; // this package: server.ts and the starting app/
21
- // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder
22
+ const ROOT = import.meta.dirname; // this package: server.ts and the starting agent/
23
+ // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder.
24
+ // Four places, one job each (the prompt gives them as {agent}, {built}, {data}, {workspace}):
22
25
  const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
23
- const APP = path.join(HOME, "app"); // agent-editable, pi's agentDir: extensions/, skills/, ui/ (what they built)
24
- const STATE = path.join(HOME, "state"); // conversations and data, not code
26
+ const AGENT = path.join(HOME, "agent"); // what the agents ARE, pi's agentDir: SYSTEM.md, settings, extensions/, skills/
27
+ const BUILT = path.join(HOME, "built"); // what they MADE for the user: card.json, board.json, apps/; served at /built/
28
+ const DATA = path.join(HOME, "data"); // what is kept while running: conversations, typed data, notes (not versioned)
29
+ const WORKSPACE = path.join(HOME, "workspace"); // the agents' working folder (bash runs here); workspace/drops: the user's files
30
+ // The version history covers agent/ and built/ together (a rollback puts both back), but not the logins and endpoints
31
+ // (the model extension writes them, not the agents) or installed packages. Here, so /recover follows it too.
32
+ const NOT_IN_HISTORY = ["agent/auth.json", "agent/models.json", "agent/models-store.json", "agent/mcp-auth.json"];
33
+ const inHistory = (f: string) => /^(agent|built)\//.test(f) && !NOT_IN_HISTORY.includes(f) && !f.split("/").includes("node_modules");
34
+ // What an agent is made of (paths in agent/). A new home starts with these from this package (no logins, nothing
35
+ // built); anything else an agent puts in agent/ doesn't belong there (the version extension tells it so).
36
+ const AGENT_PARTS = ["extensions/", "skills/", "SYSTEM.md", "mcp.json", "settings.json"];
37
+ const isAgentPart = (rel: string) => !rel.split(path.sep).includes("node_modules")
38
+ && AGENT_PARTS.some((p) => (rel.replaceAll(path.sep, "/") + "/").startsWith(p) || rel === p);
39
+ const startAgent = () => fs.cpSync(path.join(ROOT, "agent"), AGENT, { recursive: true,
40
+ filter: (src) => { const rel = path.relative(path.join(ROOT, "agent"), src); return !rel || isAgentPart(rel); } });
25
41
  // our dependencies' folder (npm may put it above this package)
26
42
  const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
27
- if (!fs.existsSync(APP)) fs.cpSync(path.join(ROOT, "app"), APP, { recursive: true }); // first run: the starting code
28
- if (HOME !== ROOT) { // HOME/app's extensions import our dependencies: Node looks for a node_modules above them
43
+
44
+ // The layout before 2026-11 (app/: the agent and what it built; state/: the data), moved once into the four places.
45
+ // An old app/ (an installed home) gives up only the user's things: its code stays as app-before-reorg/, and agent/ is
46
+ // this package's (the old code expects the old layout). The old version history stays too: its paths were app/'s.
47
+ function migrate() {
48
+ const move = (from: string, to: string) => {
49
+ if (!fs.existsSync(from) || fs.existsSync(to)) return;
50
+ fs.mkdirSync(path.dirname(to), { recursive: true });
51
+ fs.renameSync(from, to);
52
+ console.log(`moved ${path.relative(HOME, from)} -> ${path.relative(HOME, to)}`);
53
+ };
54
+ const old = path.join(HOME, "app");
55
+ if (fs.existsSync(old)) {
56
+ if (!fs.existsSync(AGENT)) startAgent();
57
+ for (const f of [...NOT_IN_HISTORY.map((f) => path.basename(f)), "mcp.json", "settings.json"]) // the user's, not code
58
+ if (fs.existsSync(path.join(old, f))) { fs.rmSync(path.join(AGENT, f), { force: true }); move(path.join(old, f), path.join(AGENT, f)); }
59
+ }
60
+ for (const ui of [path.join(old, "ui"), path.join(AGENT, "ui")]) { // what they built: app/ui (installed), agent/ui (this repo)
61
+ const apps = path.join(ui, "apps");
62
+ if (fs.existsSync(apps)) for (const name of fs.readdirSync(apps)) if (name !== ".keep") move(path.join(apps, name), path.join(BUILT, "apps", name));
63
+ move(path.join(ui, "surfaces.json"), path.join(BUILT, "card.json"));
64
+ move(path.join(ui, "boards.json"), path.join(BUILT, "board.json"));
65
+ fs.rmSync(path.join(apps, ".keep"), { force: true });
66
+ for (const d of [apps, ui]) try { fs.rmdirSync(d); } catch {}
67
+ }
68
+ if (fs.existsSync(old)) move(old, path.join(HOME, "app-before-reorg"));
69
+ move(path.join(HOME, "state"), DATA);
70
+ for (const [from, to] of [["ui-data.json", "card.json"], ["ui-history.jsonl", "history.jsonl"], ["notes.json", "note.json"],
71
+ ["frames.json", "frame.json"], ["login.json", "model.json"], ["app-data", "app"], ["screens", "screen"], ["pointed", "viewer"],
72
+ ["shots", "version/shots"], ["versions.git", "version-before-reorg.git"]]) move(path.join(DATA, from), path.join(DATA, to));
73
+ // what was in them, in the new words: a card's "components" are its "blocks", each {"block": kind}; the Card block
74
+ // is Panel; a card that was one App block shows that app; the undo steps' "surfaces" are "cards"; apps use the new addresses
75
+ const blocks = (list: any[]) => list.map(({ component, ...b }: any) => ({ ...b, block: component === "Card" ? "Panel" : component }));
76
+ const content = ({ components, ...rest }: any) => components.length === 1 && components[0].component === "App"
77
+ ? { ...rest, app: `apps/${components[0].src}`, ...(components[0].height ? { height: components[0].height } : {}) }
78
+ : { ...rest, blocks: blocks(components) };
79
+ const card = (r: any) => {
80
+ if (!r.components) return r;
81
+ const out: any = content(r);
82
+ if (out.options) out.options = out.options.map((o: any) => o.components ? content(o) : o);
83
+ if (out.kinds) out.kinds = Object.fromEntries(Object.entries(out.kinds).filter(([k]) => k !== "App").map(([k, v]) => [k === "Card" ? "Panel" : k, v]));
84
+ return out;
85
+ };
86
+ const cards = (all: Record<string, any>) => Object.fromEntries(Object.entries(all).map(([id, r]) => [id, card(r)]));
87
+ const rewrite = (file: string, change: (text: string) => string) => {
88
+ const text = fs.readFileSync(file, "utf8"), now = change(text);
89
+ if (now !== text) { fs.writeFileSync(file, now); console.log(`updated ${path.relative(HOME, file)}`); }
90
+ };
91
+ if (fs.existsSync(path.join(BUILT, "card.json"))) rewrite(path.join(BUILT, "card.json"), (t) => t.includes('"components"') ? JSON.stringify(cards(JSON.parse(t)), null, 1) : t);
92
+ if (fs.existsSync(path.join(DATA, "history.jsonl"))) rewrite(path.join(DATA, "history.jsonl"), (t) => !t.includes('"surfaces"') ? t
93
+ : t.split("\n").map((l) => { if (!l) return l; const { surfaces, ...e } = JSON.parse(l); return JSON.stringify(surfaces ? { ...e, cards: cards(surfaces) } : e); }).join("\n"));
94
+ const apps = path.join(BUILT, "apps");
95
+ if (fs.existsSync(apps)) for (const f of fs.readdirSync(apps, { recursive: true }) as string[])
96
+ if (/\.(html|js|mjs)$/.test(f) && !f.split(path.sep).includes("node_modules")) rewrite(path.join(apps, f), (t) => t
97
+ .replaceAll("/ui/agents.js", "/app/agents.js").replaceAll("/ui/voice.js", "/voice/voice.js")
98
+ .replaceAll("/ui-api/app-data/", "/app/data/").replaceAll("/ui/apps/", "/built/apps/"));
99
+ }
100
+ migrate();
101
+ if (!fs.existsSync(AGENT)) startAgent(); // first run: the starting agent
102
+ if (HOME !== ROOT) { // HOME/agent's extensions import our dependencies: Node looks for a node_modules above them
29
103
  const link = path.join(HOME, "node_modules");
30
104
  if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
31
105
  if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
32
106
  fs.symlinkSync(MODULES, link, "junction");
33
107
  }
34
108
  // pi's agent folder for the code that doesn't take our agentDir but calls getAgentDir() (its MCP extension: mcp.json
35
- // and mcp-auth.json): the same app/
36
- process.env.PI_CODING_AGENT_DIR = APP;
37
- const WORK = path.join(HOME, "workspace"); // the agents' working folder (bash runs here)
109
+ // and mcp-auth.json): the same agent/
110
+ process.env.PI_CODING_AGENT_DIR = AGENT;
38
111
  const PORT = Number(process.env.PORT || 3000); // the page's port
39
- for (const d of [STATE, WORK]) fs.mkdirSync(d, { recursive: true });
112
+ for (const d of [path.join(BUILT, "apps"), DATA, WORKSPACE]) fs.mkdirSync(d, { recursive: true });
40
113
 
41
114
  type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
42
115
  stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
43
116
 
44
117
  const sockets = new Set<any>();
45
118
  const host = {
46
- root: ROOT, app: APP, state: STATE, work: WORK, modules: MODULES, port: PORT,
119
+ root: ROOT, home: HOME, agent: AGENT, built: BUILT, data: DATA, workspace: WORKSPACE, modules: MODULES, port: PORT,
120
+ inHistory, // inHistory(path in HOME): whether the version history keeps that file
121
+ isAgentPart, // isAgentPart(path in agent/): whether it is part of an agent
47
122
  agents: new Map<string, Agent>(),
48
123
  routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
49
124
  pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
50
125
  eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
51
126
  // also what pi.on misses, e.g. a message filed while idle); key -> handler
52
127
  connectHandlers: new Map<string, (send: (type: string, data: object) => void) => void>(), // a page opened: what it gets first
53
- shared: new Map<string, any>(), // state extensions share across agents (e.g. the surfaces)
128
+ shared: new Map<string, any>(), // state extensions share across agents (e.g. the cards)
54
129
  reloadAfterTurn: new Map<string, boolean>(), // agents whose extensions load again when their turn ends -> quiet
55
- api: {} as Record<string, any>, // services extensions offer by name (e.g. swarm); the newest load wins
130
+ api: {} as Record<string, any>, // services extensions offer by name (e.g. team); the newest load wins
56
131
  file, // file(res, path): send a file (type from its extension; 404 if none)
57
132
 
58
133
  broadcast(type: string, data: object) {
@@ -60,14 +135,14 @@ const host = {
60
135
  for (const s of sockets) s.send(text);
61
136
  },
62
137
 
63
- // Without the swarm extension: each agent idle or working, and messages go straight to a run
138
+ // Without the team extension: each agent idle or working, and messages go straight to a run
64
139
  status() {
65
- if (host.api.swarm) return host.api.swarm.status();
140
+ if (host.api.team) return host.api.team.status();
66
141
  return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
67
142
  state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
68
143
  },
69
144
  send(from: string, to: string, text: string) {
70
- if (host.api.swarm) return host.api.swarm.send(from, to, text);
145
+ if (host.api.team) return host.api.team.send(from, to, text);
71
146
  void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
72
147
  },
73
148
 
@@ -118,23 +193,23 @@ const host = {
118
193
  async createAgent(name: string, role: string, app?: string, fresh = false) {
119
194
  const old = host.agents.get(name);
120
195
  if (old) { old.unsubscribe(); old.session.dispose(); }
121
- const dir = path.join(STATE, "sessions", name);
196
+ const dir = path.join(DATA, "sessions", name);
122
197
  fs.mkdirSync(dir, { recursive: true });
123
198
  // pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
124
199
  // SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
125
- const settingsManager = SettingsManager.create(WORK, APP, { projectTrusted: false });
200
+ const settingsManager = SettingsManager.create(WORKSPACE, AGENT, { projectTrusted: false });
126
201
  // agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
127
- // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in app/mcp.json, with
128
- // codemode and tool_search for servers whose tools aren't direct. Sign-ins: app/mcp-auth.json (not versioned).
129
- const resourceLoader = new DefaultResourceLoader({ cwd: WORK, agentDir: APP, settingsManager, noContextFiles: true,
202
+ // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in agent/mcp.json, with
203
+ // codemode and tool_search for servers whose tools aren't direct. Sign-ins: agent/mcp-auth.json (not versioned).
204
+ const resourceLoader = new DefaultResourceLoader({ cwd: WORKSPACE, agentDir: AGENT, settingsManager, noContextFiles: true,
130
205
  extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
131
- createMcpExtension({ logPath: path.join(STATE, "mcp.log") })] });
206
+ createMcpExtension({ logPath: path.join(DATA, "mcp.log") })] });
132
207
  await resourceLoader.reload();
133
208
  for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
134
209
  const { session } = await createAgentSession({
135
- cwd: WORK, agentDir: APP, resourceLoader, settingsManager,
210
+ cwd: WORKSPACE, agentDir: AGENT, resourceLoader, settingsManager,
136
211
  // the saved conversation, or a new one
137
- sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
212
+ sessionManager: fresh ? SessionManager.create(WORKSPACE, dir) : SessionManager.continueRecent(WORKSPACE, dir),
138
213
  });
139
214
  if (session.sessionName !== name) session.setSessionName(name);
140
215
  const empty = session.sessionManager.buildSessionContext().messages.length === 0;
@@ -144,9 +219,9 @@ const host = {
144
219
  console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
145
220
  emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
146
221
  } });
147
- // the model: the one picked with /login or /model (extensions/login), else the MODEL_BASE_URL endpoint (extensions/model).
222
+ // the model: the one picked with /login or /model, else the MODEL_BASE_URL endpoint (both: extensions/model).
148
223
  // CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
149
- const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.login?.modelFor(name, session);
224
+ const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.model?.modelFor(name, session);
150
225
  const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
151
226
  if (model) await session.setModel(model);
152
227
  // thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
@@ -186,58 +261,71 @@ function file(res: http.ServerResponse, p: string) {
186
261
  fs.createReadStream(p).pipe(res);
187
262
  }
188
263
 
189
- // ---- recovery: the safety net for when an agent breaks the page (or the versions extension, so its panel) ----
190
- // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever app/ holds.
191
- // GET /recover the saved versions (state/versions.git, written by the versions extension), newest first;
192
- // "the page worked" = the page took its picture of that version (state/shots/<sha>.png);
193
- // the newest too (a broken versions extension doesn't save the edit that broke it)
194
- // POST /recover/<sha> that version's files back into app/, as a new commit (as the versions panel's rollback,
264
+ // ---- the version history: one git repo for agent/ and built/ (inHistory), kept in data/version/git ----
265
+ // Outside the folders it keeps, so an agent's own git commands can't rewrite it. Git through isomorphic-git (pure JS).
266
+ // The version extension commits and rolls back with this; /recover too, so it works when that extension is broken.
267
+ // repo isomorphic-git's {fs, dir: HOME, gitdir}
268
+ // stage() git add -A for what inHistory keeps (.gitignore files play no part); returns the changed paths
269
+ const REPO = { fs, dir: HOME, gitdir: path.join(DATA, "version", "git") };
270
+ const KEPT = { filepaths: ["agent", "built"], ignored: true, filter: inHistory }; // statusMatrix's walk: only these
271
+ async function stage() {
272
+ const changed = [];
273
+ for (const [filepath, head, workdir, index] of await git.statusMatrix({ ...REPO, ...KEPT })) {
274
+ if (head === 1 && workdir === 1 && index === 1) continue; // unchanged
275
+ if (workdir === 0) { if (index !== 0) await git.remove({ ...REPO, filepath }); }
276
+ else await git.add({ ...REPO, filepath, force: true });
277
+ changed.push(filepath);
278
+ }
279
+ return changed;
280
+ }
281
+ Object.assign(host, { history: { repo: REPO, kept: KEPT, stage } });
282
+
283
+ // ---- recovery: the safety net for when an agent breaks the page (or the version extension, so its panel) ----
284
+ // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever agent/ holds.
285
+ // GET /recover the saved versions, newest first; "the page worked" = the page took its picture of that
286
+ // version (data/version/shots/<sha>.png); the newest too (a broken version extension doesn't
287
+ // save the edit that broke it)
288
+ // POST /recover/<sha> that version's agent/ and built/ back, as a new commit (as the version panel's rollback,
195
289
  // without its extras), then every agent's extensions load again; back to the page
196
290
  // Also shown at / when no extension serves the page (the page extension failed to load).
197
- const REPO = { fs, dir: APP, gitdir: path.join(STATE, "versions.git") };
198
291
  const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
199
292
 
200
293
  async function recoverPage(res: http.ServerResponse) {
201
- const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO) : [];
294
+ // the history's folder comes before its first commit: right after a start there may be no versions yet
295
+ const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO).catch((e) => e.code === "NotFoundError" ? [] : Promise.reject(e)) : [];
202
296
  const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
203
297
  <td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
204
- <td>${fs.existsSync(path.join(STATE, "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
298
+ <td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
205
299
  <td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
206
300
  const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
207
301
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
208
302
  <style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
209
303
  <h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
210
304
  files are saved first, and going back is itself a new version. <a href="/">Back to the page</a></p>
211
- ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(APP, e.path))}: ${esc(String(e.error))}</p>`).join("")}
305
+ ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(AGENT, e.path))}: ${esc(String(e.error))}</p>`).join("")}
212
306
  <table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
213
307
  }
214
308
 
215
309
  async function recover(sha: string, res: http.ServerResponse) {
216
310
  try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
217
- await host.shared.get("versions")?.queue; // after the versions extension's own work
218
- try { await host.api.swarm?.stop(); } catch {} // the swarm extension may be the broken one
311
+ await host.shared.get("version")?.queue; // after the versions extension's own work
312
+ try { await host.api.team?.stop(); } catch {} // the team extension may be the broken one
219
313
  for (const name of host.agents.keys()) await host.abort(name);
220
- const stage = async () => { // git add -A (new ignored files skipped: statusMatrix misses info/exclude, see versions)
221
- for (const [f, h, w, i] of await git.statusMatrix(REPO)) {
222
- if ((h === 1 && w === 1 && i === 1) || (h === 0 && i === 0 && await git.isIgnored({ ...REPO, filepath: f }))) continue;
223
- if (w === 0) { if (i !== 0) await git.remove({ ...REPO, filepath: f }); } else await git.add({ ...REPO, filepath: f });
224
- }
225
- };
226
314
  const author = { name: "user", email: "user@chalky" };
227
315
  const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
228
316
  await stage();
229
317
  try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
230
- await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true });
231
- const files = (await git.statusMatrix(REPO)).filter(([, h, w, i]) => !(h === 1 && w === 1 && i === 1)).map(([f]) => f);
232
- for (const f of files) for (let d = path.dirname(f); d !== "."; d = path.dirname(d)) { // folders it emptied
233
- try { fs.rmdirSync(path.join(APP, d)); } catch { break; }
318
+ await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true, filepaths: KEPT.filepaths });
319
+ const files = await stage();
320
+ for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) { // folders it emptied
321
+ try { fs.rmdirSync(path.join(HOME, d)); } catch { break; }
234
322
  }
235
323
  await git.commit({ ...REPO, message: `recover to ${subject}`, author });
236
324
  for (const name of host.agents.keys()) await host.reloadAgent(name);
237
- if (files.includes("ui/surfaces.json")) host.shared.get("ui")?.load?.();
325
+ if (files.includes("built/card.json")) { host.shared.get("card")?.load?.(); host.api.history?.add("user", `recovered to ${subject}`); }
238
326
  for (const name of host.agents.keys())
239
- host.api.swarm?.fyi("system", name, `The user recovered ${APP} to version ${subject} (the page was broken). `
240
- + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/versions). Check before relying on them.`);
327
+ host.api.team?.fyi("system", name, `The user recovered ${AGENT} and ${BUILT} to version ${subject} (the page was broken). `
328
+ + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/version). Check before relying on them.`);
241
329
  console.log(`recovered to ${subject}`);
242
330
  res.writeHead(303, { location: "/" }).end();
243
331
  }
@@ -246,6 +334,11 @@ const server = http.createServer((req, res) => {
246
334
  const url = new URL(req.url!, "http://x").pathname;
247
335
  if (url === "/recover") return void recoverPage(res);
248
336
  if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
337
+ if (url.startsWith("/built/")) { // what the agents made, at the same path as on disk (built/ holds no secrets)
338
+ let p = "";
339
+ try { p = path.join(HOME, path.posix.normalize(decodeURIComponent(url))); } catch {} // a bad %-escape: 404
340
+ return p.startsWith(BUILT + path.sep) ? file(res, p) : void res.writeHead(404).end(); // also a ".." out of built/
341
+ }
249
342
  let route = host.routes.get(url);
250
343
  for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
251
344
  if (route) return route(req, res);
@@ -263,7 +356,7 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
263
356
  const data = JSON.parse(String(raw));
264
357
  const kind = data.type ?? "chat";
265
358
  if (kind === "chat") host.send("user", data.to === "main" || !host.agents.has(data.to) ? "agent1" : data.to, data.text);
266
- else if (kind === "stop" && host.api.swarm) await host.api.swarm.stop();
359
+ else if (kind === "stop" && host.api.team) await host.api.team.stop();
267
360
  else if (kind === "stop") {
268
361
  for (const a of host.agents.values()) await host.abort(a.name);
269
362
  emit("stopped", { agent: "all" });
@@ -273,8 +366,8 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
273
366
 
274
367
  // agent1, then every agent saved by an earlier run (their conversations come back from their session files)
275
368
  await host.createAgent("agent1", "the user's main agent");
276
- for (const name of fs.readdirSync(path.join(STATE, "sessions")).filter((n) => n !== "agent1")) {
277
- const { role, app } = JSON.parse(fs.readFileSync(path.join(STATE, "sessions", name, "role.json"), "utf8"));
369
+ for (const name of fs.readdirSync(path.join(DATA, "sessions")).filter((n) => n !== "agent1")) {
370
+ const { role, app } = JSON.parse(fs.readFileSync(path.join(DATA, "sessions", name, "role.json"), "utf8"));
278
371
  await host.createAgent(name, role, app);
279
372
  }
280
373
  const hostName = process.env.UI_HOST || "127.0.0.1";
@@ -1,10 +0,0 @@
1
- # App data (the appdata extension)
2
- An App ({code}/ui/apps/<name>/) keeps what the user makes in it (notes, lists, picks) with
3
- `import agents from "/ui/agents.js"`: `await agents.load()` gives the app's saved data ({} at first),
4
- `agents.save(data)` replaces it, and `agents.on("app_data", ({app, data}) => ...)` hears every save of any
5
- app (check app === your app's folder name). Saved on the server in {data}/app-data/<name>.json: it
6
- survives reloads, is the same in every browser, and you can read it (cat the file) when the user points at
7
- something in the app. The same app shown in several surfaces shares this data, so one surface's clicks
8
- update the others at once, with no agent in between. To change it yourself, PUT the whole value:
9
- curl -X PUT --data @new.json {page}/ui-api/app-data/<name> (the open pages update).
10
- Not localStorage: that stays in one browser, and you can't see it.
@@ -1,4 +0,0 @@
1
- # App data
2
- An App keeps what the user makes in it (notes, lists, picks) with /ui/agents.js (agents.load, agents.save), on the
3
- server and shared by every surface showing it; never localStorage (one browser only, and you can't see it).
4
- Before you build an App that keeps data, read README.md.
@@ -1,47 +0,0 @@
1
- // App data: a small shared store for apps (app/ui/apps/<name>/, shown by the App component), saved here, so an
2
- // app's data outlives the page, is the same in every browser, and the agents can read it (port of the Python
3
- // appdata plugin). One JSON value per app, in state/app-data/<name>.json. The same app shown in several surfaces
4
- // shares it: when one saves, every open page and app frame gets an "app_data" event.
5
- // The app side is app/ui/agents.js: agents.load(), agents.save(data), agents.on("app_data", fn).
6
- // GET /ui-api/app-data/<name> the app's data ({} if none yet)
7
- // PUT /ui-api/app-data/<name> replace it (JSON, at most MAX_BYTES); event "app_data" {app, data}
8
- // Whole value replaced each time: the last save wins (keep one app's data in one value). A version check (refuse a
9
- // save made from an older value) is the fix if lost saves show up; not added yet.
10
- import fs from "node:fs";
11
- import path from "node:path";
12
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
-
14
- const host = (globalThis as any).chalky;
15
- const DIR = path.join(host.state, "app-data");
16
- const MAX_BYTES = 1024 * 1024;
17
- const json = (res: any, v: unknown, status = 200) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(v));
18
-
19
- export default function (pi: ExtensionAPI) {
20
- const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
21
- .replace("README.md", path.join(import.meta.dirname, "README.md"));
22
- pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.appdata = guide; });
23
-
24
- host.routes.set("/ui-api/app-data/", (req: any, res: any) => {
25
- const name = decodeURIComponent(new URL(req.url, "http://x").pathname.slice("/ui-api/app-data/".length));
26
- if (!/^[\w-]+$/.test(name)) return json(res, { error: "app names are letters, digits, _ and -" }, 400);
27
- const p = path.join(DIR, name + ".json");
28
- if (req.method === "GET") {
29
- if (!fs.existsSync(p)) return json(res, {});
30
- return res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(fs.readFileSync(p));
31
- }
32
- if (req.method !== "PUT") return res.writeHead(405).end();
33
- const chunks: Buffer[] = [];
34
- let size = 0;
35
- req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
36
- req.on("end", () => {
37
- if (size > MAX_BYTES) return json(res, { error: `bad app name, or over ${MAX_BYTES / 1024} KB` }, 400);
38
- let data;
39
- try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch (e: any) { return json(res, { error: `not JSON: ${e.message}` }, 400); }
40
- fs.mkdirSync(DIR, { recursive: true });
41
- fs.writeFileSync(p + ".tmp", JSON.stringify(data));
42
- fs.renameSync(p + ".tmp", p);
43
- host.broadcast("app_data", { app: name, data });
44
- json(res, { saved: true });
45
- });
46
- });
47
- }
@@ -1,3 +0,0 @@
1
- # Commands
2
- The user's "/" commands for an agent (e.g. /compact) are pi commands in {code}/extensions/commands/index.ts; the
3
- page lists them by itself. To add one when asked: pi.registerCommand there, calling what pi's built-in calls.
@@ -1,15 +0,0 @@
1
- # Doodles (the doodle extension)
2
- The user can draw on the page (lines, arrows, circles, words in handwriting), click through it while drawing,
3
- then send it all with a few words: "[doodles on the UI] <their words>", then the steps, in order. Each step
4
- is a picture of the screen as they saw it, with their drawing on it (cropped to the drawing; the pictures
5
- are attached to the message, in step order), which part of the page it shows (main-space px), the words they
6
- wrote on it (exactly), then what they clicked to get to the next step. The drawing is only in the picture.
7
- - Read it as a story: step 1 is how it is and what they marked; their clicks lead to step 2; and so on. A
8
- step with nothing drawn after clicks is how the screen looked after them: often the point ("after Save,
9
- this should show the date").
10
- - Look at every picture closely: the drawing is the message. A line from A to B links them ("this goes
11
- there", "this feeds that"); a circle or a box points at the thing to change; a cross removes; handwriting
12
- says what. Find what a mark is on from the picture (its text, its look) and the page you know.
13
- - To place something where they drew (on empty space): the picture's area maps to the page, so a mark's
14
- spot in the picture is a spot on the page; ui_render(..., place=<that box>).
15
- - If the drawing could mean two quite different things, propose one in a line and ask; otherwise do it.