@chalky/chalky 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/{app → agent}/SYSTEM.md +16 -11
- package/agent/extensions/app/README.md +68 -0
- package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
- package/agent/extensions/app/guide.md +5 -0
- package/agent/extensions/app/index.ts +66 -0
- package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
- package/agent/extensions/board/browser.js +359 -0
- package/agent/extensions/board/guide.md +4 -0
- package/agent/extensions/board/index.ts +100 -0
- package/agent/extensions/card/README.md +88 -0
- package/agent/extensions/card/blocks/Button.js +34 -0
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
- package/agent/extensions/card/blocks/Markdown.js +37 -0
- package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
- package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/agent/extensions/command/browser.js +13 -0
- package/agent/extensions/command/guide.md +2 -0
- package/agent/extensions/command/index.ts +94 -0
- package/agent/extensions/doodle/README.md +57 -0
- package/agent/extensions/doodle/browser.js +1013 -0
- package/agent/extensions/doodle/guide.md +22 -0
- package/agent/extensions/doodle/index.ts +274 -0
- package/agent/extensions/file/browser.js +143 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +81 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
- package/{app → agent}/extensions/mcp/browser.js +1 -1
- package/agent/extensions/mcp/guide.md +3 -0
- package/{app → agent}/extensions/mcp/index.ts +2 -4
- package/agent/extensions/model/browser.js +363 -0
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/agent/extensions/model/login.ts +215 -0
- package/agent/extensions/note/README.md +35 -0
- package/agent/extensions/note/browser.js +356 -0
- package/agent/extensions/note/guide.md +8 -0
- package/agent/extensions/note/index.ts +192 -0
- package/{app → agent}/extensions/page/index.ts +2 -2
- package/{app → agent}/extensions/page/page.html +55 -49
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +4 -4
- package/{app → agent}/extensions/screen/index.ts +11 -11
- package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
- package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
- package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
- package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
- package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
- package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
- package/{app → agent}/extensions/viewer/README.md +4 -4
- package/{app → agent}/extensions/viewer/browser.js +8 -8
- package/agent/extensions/viewer/guide.md +3 -0
- package/{app → agent}/extensions/viewer/index.ts +22 -17
- package/agent/extensions/voice/browser.js +72 -0
- package/agent/extensions/voice/index.ts +39 -0
- package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
- package/agent/settings.json +8 -0
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
- package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +3 -3
- package/package.json +8 -8
- package/server.ts +162 -63
- package/app/extensions/appdata/README.md +0 -10
- package/app/extensions/appdata/guide.md +0 -4
- package/app/extensions/appdata/index.ts +0 -47
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -388
- package/app/extensions/doodle/guide.md +0 -6
- package/app/extensions/doodle/index.ts +0 -54
- package/app/extensions/files/browser.js +0 -96
- package/app/extensions/files/guide.md +0 -7
- package/app/extensions/frames/guide.md +0 -7
- package/app/extensions/frames/index.ts +0 -76
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -245
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -97
- package/app/extensions/prompt/index.ts +0 -19
- package/app/extensions/ui/README.md +0 -69
- package/app/extensions/ui/components/App.js +0 -28
- package/app/extensions/ui/components/Button.js +0 -28
- package/app/extensions/ui/components/Markdown.js +0 -37
- package/app/extensions/ui/guide.md +0 -7
- package/app/extensions/ui/index.ts +0 -271
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
package/server.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Host: the fixed core under the agents. Everything else, the page included, is a pi extension in
|
|
2
|
-
//
|
|
3
|
-
// -
|
|
4
|
-
// -
|
|
5
|
-
// -
|
|
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).
|
|
9
|
-
// Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...]
|
|
11
|
+
// Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
|
|
12
|
+
// CHALKY_LOGIN=off: no /login, /logout, /model on the page; agents always use MODEL_BASE_URL (a cloud deployment)
|
|
10
13
|
import fs from "node:fs";
|
|
11
14
|
import { createRequire } from "node:module";
|
|
12
15
|
import http from "node:http";
|
|
@@ -16,42 +19,115 @@ import { createAgentSession, createCodemodeExtension, createMcpExtension, create
|
|
|
16
19
|
import git from "isomorphic-git";
|
|
17
20
|
import { WebSocketServer } from "ws";
|
|
18
21
|
|
|
19
|
-
const ROOT = import.meta.dirname; // this package: server.ts and the starting
|
|
20
|
-
// 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}):
|
|
21
25
|
const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
|
|
22
|
-
const
|
|
23
|
-
const
|
|
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); } });
|
|
24
41
|
// our dependencies' folder (npm may put it above this package)
|
|
25
42
|
const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
103
|
const link = path.join(HOME, "node_modules");
|
|
29
104
|
if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
|
|
30
105
|
if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
|
|
31
106
|
fs.symlinkSync(MODULES, link, "junction");
|
|
32
107
|
}
|
|
33
108
|
// pi's agent folder for the code that doesn't take our agentDir but calls getAgentDir() (its MCP extension: mcp.json
|
|
34
|
-
// and mcp-auth.json): the same
|
|
35
|
-
process.env.PI_CODING_AGENT_DIR =
|
|
36
|
-
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;
|
|
37
111
|
const PORT = Number(process.env.PORT || 3000); // the page's port
|
|
38
|
-
for (const d of [
|
|
112
|
+
for (const d of [path.join(BUILT, "apps"), DATA, WORKSPACE]) fs.mkdirSync(d, { recursive: true });
|
|
39
113
|
|
|
40
114
|
type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
|
|
41
115
|
stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
|
|
42
116
|
|
|
43
117
|
const sockets = new Set<any>();
|
|
44
118
|
const host = {
|
|
45
|
-
root: ROOT,
|
|
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
|
|
46
122
|
agents: new Map<string, Agent>(),
|
|
47
123
|
routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
|
|
48
124
|
pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
|
|
49
125
|
eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
|
|
50
126
|
// also what pi.on misses, e.g. a message filed while idle); key -> handler
|
|
51
127
|
connectHandlers: new Map<string, (send: (type: string, data: object) => void) => void>(), // a page opened: what it gets first
|
|
52
|
-
shared: new Map<string, any>(), // state extensions share across agents (e.g. the
|
|
128
|
+
shared: new Map<string, any>(), // state extensions share across agents (e.g. the cards)
|
|
53
129
|
reloadAfterTurn: new Map<string, boolean>(), // agents whose extensions load again when their turn ends -> quiet
|
|
54
|
-
api: {} as Record<string, any>, // services extensions offer by name (e.g.
|
|
130
|
+
api: {} as Record<string, any>, // services extensions offer by name (e.g. team); the newest load wins
|
|
55
131
|
file, // file(res, path): send a file (type from its extension; 404 if none)
|
|
56
132
|
|
|
57
133
|
broadcast(type: string, data: object) {
|
|
@@ -59,14 +135,14 @@ const host = {
|
|
|
59
135
|
for (const s of sockets) s.send(text);
|
|
60
136
|
},
|
|
61
137
|
|
|
62
|
-
// Without the
|
|
138
|
+
// Without the team extension: each agent idle or working, and messages go straight to a run
|
|
63
139
|
status() {
|
|
64
|
-
if (host.api.
|
|
140
|
+
if (host.api.team) return host.api.team.status();
|
|
65
141
|
return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
|
|
66
142
|
state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
|
|
67
143
|
},
|
|
68
144
|
send(from: string, to: string, text: string) {
|
|
69
|
-
if (host.api.
|
|
145
|
+
if (host.api.team) return host.api.team.send(from, to, text);
|
|
70
146
|
void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
|
|
71
147
|
},
|
|
72
148
|
|
|
@@ -117,36 +193,41 @@ const host = {
|
|
|
117
193
|
async createAgent(name: string, role: string, app?: string, fresh = false) {
|
|
118
194
|
const old = host.agents.get(name);
|
|
119
195
|
if (old) { old.unsubscribe(); old.session.dispose(); }
|
|
120
|
-
const dir = path.join(
|
|
196
|
+
const dir = path.join(DATA, "sessions", name);
|
|
121
197
|
fs.mkdirSync(dir, { recursive: true });
|
|
122
|
-
// projectTrusted false: pi ignores workspace/.pi/ (its
|
|
123
|
-
// project files can't change it; app/ (versioned) is the only place
|
|
124
|
-
const settingsManager = SettingsManager.
|
|
198
|
+
// pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
|
|
199
|
+
// SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
|
|
200
|
+
const settingsManager = SettingsManager.create(WORKSPACE, AGENT, { projectTrusted: false });
|
|
125
201
|
// agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
|
|
126
|
-
// pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in
|
|
127
|
-
// codemode and tool_search for servers whose tools aren't direct. Sign-ins:
|
|
128
|
-
const resourceLoader = new DefaultResourceLoader({ cwd:
|
|
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,
|
|
129
205
|
extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
|
|
130
|
-
createMcpExtension({ logPath: path.join(
|
|
206
|
+
createMcpExtension({ logPath: path.join(DATA, "mcp.log") })] });
|
|
131
207
|
await resourceLoader.reload();
|
|
132
208
|
for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
|
|
133
209
|
const { session } = await createAgentSession({
|
|
134
|
-
cwd:
|
|
210
|
+
cwd: WORKSPACE, agentDir: AGENT, resourceLoader, settingsManager,
|
|
135
211
|
// the saved conversation, or a new one
|
|
136
|
-
sessionManager: fresh ? SessionManager.create(
|
|
212
|
+
sessionManager: fresh ? SessionManager.create(WORKSPACE, dir) : SessionManager.continueRecent(WORKSPACE, dir),
|
|
137
213
|
});
|
|
138
214
|
if (session.sessionName !== name) session.setSessionName(name);
|
|
215
|
+
const empty = session.sessionManager.buildSessionContext().messages.length === 0;
|
|
139
216
|
// starts the extensions' session (session_start). onError, besides showing extensions' errors: pi 1.4.0's reload()
|
|
140
217
|
// sends session_start again only to a session with a binding, and without it pi's MCP doesn't reconnect after a reload
|
|
141
218
|
await session.bindExtensions({ onError: (e) => {
|
|
142
219
|
console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
|
|
143
220
|
emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
|
|
144
221
|
} });
|
|
145
|
-
|
|
222
|
+
// the model: the one picked with /login or /model, else the MODEL_BASE_URL endpoint (both: extensions/model).
|
|
223
|
+
// CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
|
|
224
|
+
const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.model?.modelFor(name, session);
|
|
225
|
+
const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
|
|
146
226
|
if (model) await session.setModel(model);
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
227
|
+
// thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
|
|
228
|
+
// conversation starts at "medium". Set here: pi restores it only when it knows the model then, and ours (the
|
|
229
|
+
// model extension) comes later, so pi would say "off"
|
|
230
|
+
session.setThinkingLevel(empty ? "medium" : session.sessionManager.buildSessionContext().thinkingLevel);
|
|
150
231
|
const a: Agent = { name, role, app, session, unsubscribe: () => {} };
|
|
151
232
|
host.agents.set(name, a);
|
|
152
233
|
a.unsubscribe = session.subscribe((ev: any) => {
|
|
@@ -180,58 +261,71 @@ function file(res: http.ServerResponse, p: string) {
|
|
|
180
261
|
fs.createReadStream(p).pipe(res);
|
|
181
262
|
}
|
|
182
263
|
|
|
183
|
-
// ----
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
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,
|
|
189
289
|
// without its extras), then every agent's extensions load again; back to the page
|
|
190
290
|
// Also shown at / when no extension serves the page (the page extension failed to load).
|
|
191
|
-
const REPO = { fs, dir: APP, gitdir: path.join(STATE, "versions.git") };
|
|
192
291
|
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
193
292
|
|
|
194
293
|
async function recoverPage(res: http.ServerResponse) {
|
|
195
|
-
|
|
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)) : [];
|
|
196
296
|
const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
|
|
197
297
|
<td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
|
|
198
|
-
<td>${fs.existsSync(path.join(
|
|
298
|
+
<td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
|
|
199
299
|
<td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
|
|
200
300
|
const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
|
|
201
301
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
|
|
202
302
|
<style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
|
|
203
303
|
<h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
|
|
204
304
|
files are saved first, and going back is itself a new version. <a href="/">Back to the page</a></p>
|
|
205
|
-
${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(
|
|
305
|
+
${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(AGENT, e.path))}: ${esc(String(e.error))}</p>`).join("")}
|
|
206
306
|
<table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
|
|
207
307
|
}
|
|
208
308
|
|
|
209
309
|
async function recover(sha: string, res: http.ServerResponse) {
|
|
210
310
|
try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
|
|
211
|
-
await host.shared.get("
|
|
212
|
-
try { await host.api.
|
|
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
|
|
213
313
|
for (const name of host.agents.keys()) await host.abort(name);
|
|
214
|
-
const stage = async () => { // git add -A (new ignored files skipped: statusMatrix misses info/exclude, see versions)
|
|
215
|
-
for (const [f, h, w, i] of await git.statusMatrix(REPO)) {
|
|
216
|
-
if ((h === 1 && w === 1 && i === 1) || (h === 0 && i === 0 && await git.isIgnored({ ...REPO, filepath: f }))) continue;
|
|
217
|
-
if (w === 0) { if (i !== 0) await git.remove({ ...REPO, filepath: f }); } else await git.add({ ...REPO, filepath: f });
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
314
|
const author = { name: "user", email: "user@chalky" };
|
|
221
315
|
const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
|
|
222
316
|
await stage();
|
|
223
317
|
try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
|
|
224
|
-
await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true });
|
|
225
|
-
const files =
|
|
226
|
-
for (const f of files) for (let d = path.dirname(f);
|
|
227
|
-
try { fs.rmdirSync(path.join(
|
|
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; }
|
|
228
322
|
}
|
|
229
323
|
await git.commit({ ...REPO, message: `recover to ${subject}`, author });
|
|
230
324
|
for (const name of host.agents.keys()) await host.reloadAgent(name);
|
|
231
|
-
if (files.includes("
|
|
325
|
+
if (files.includes("built/card.json")) { host.shared.get("card")?.load?.(); host.api.history?.add("user", `recovered to ${subject}`); }
|
|
232
326
|
for (const name of host.agents.keys())
|
|
233
|
-
host.api.
|
|
234
|
-
+ `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/
|
|
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.`);
|
|
235
329
|
console.log(`recovered to ${subject}`);
|
|
236
330
|
res.writeHead(303, { location: "/" }).end();
|
|
237
331
|
}
|
|
@@ -240,6 +334,11 @@ const server = http.createServer((req, res) => {
|
|
|
240
334
|
const url = new URL(req.url!, "http://x").pathname;
|
|
241
335
|
if (url === "/recover") return void recoverPage(res);
|
|
242
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
|
+
}
|
|
243
342
|
let route = host.routes.get(url);
|
|
244
343
|
for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
|
|
245
344
|
if (route) return route(req, res);
|
|
@@ -257,7 +356,7 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
|
|
|
257
356
|
const data = JSON.parse(String(raw));
|
|
258
357
|
const kind = data.type ?? "chat";
|
|
259
358
|
if (kind === "chat") host.send("user", data.to === "main" || !host.agents.has(data.to) ? "agent1" : data.to, data.text);
|
|
260
|
-
else if (kind === "stop" && host.api.
|
|
359
|
+
else if (kind === "stop" && host.api.team) await host.api.team.stop();
|
|
261
360
|
else if (kind === "stop") {
|
|
262
361
|
for (const a of host.agents.values()) await host.abort(a.name);
|
|
263
362
|
emit("stopped", { agent: "all" });
|
|
@@ -267,8 +366,8 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
|
|
|
267
366
|
|
|
268
367
|
// agent1, then every agent saved by an earlier run (their conversations come back from their session files)
|
|
269
368
|
await host.createAgent("agent1", "the user's main agent");
|
|
270
|
-
for (const name of fs.readdirSync(path.join(
|
|
271
|
-
const { role, app } = JSON.parse(fs.readFileSync(path.join(
|
|
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"));
|
|
272
371
|
await host.createAgent(name, role, app);
|
|
273
372
|
}
|
|
274
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,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.
|