@chalky/chalky 0.1.4 → 0.1.6
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 +16 -5
- package/agent/SYSTEM.md +36 -0
- package/agent/extensions/app/README.md +72 -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/app/skills/test-your-app/SKILL.md +23 -0
- package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
- package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
- package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
- 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 -129
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
- package/agent/extensions/command/guide.md +2 -0
- package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
- 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 +182 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +90 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -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/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
- package/agent/extensions/note/README.md +37 -0
- package/agent/extensions/note/browser.js +382 -0
- package/agent/extensions/note/guide.md +9 -0
- package/agent/extensions/note/index.ts +215 -0
- package/agent/extensions/page/browser.js +453 -0
- package/{app → agent}/extensions/page/index.ts +72 -3
- package/{app → agent}/extensions/page/page.html +102 -49
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +10 -23
- 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 +19 -10
- package/agent/extensions/version/index.ts +205 -0
- 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/{app → agent}/extensions/voice/browser.js +4 -3
- package/{app → agent}/extensions/voice/index.ts +8 -6
- 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 +15 -15
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/agent/skills/show-me/SKILL.md +66 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +2 -2
- package/package.json +9 -8
- package/server.ts +313 -58
- package/app/SYSTEM.md +0 -31
- 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/commands/guide.md +0 -3
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -398
- 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 -85
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -247
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -112
- 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 -291
- package/app/extensions/versions/index.ts +0 -168
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/settings.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- package/node_modules/xlsx/CHANGELOG.md +0 -361
- package/node_modules/xlsx/LICENSE +0 -201
- package/node_modules/xlsx/README.md +0 -41
- package/node_modules/xlsx/bin/xlsx.njs +0 -310
- package/node_modules/xlsx/bower.json +0 -22
- package/node_modules/xlsx/dist/LICENSE +0 -201
- package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
- package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
- package/node_modules/xlsx/dist/cpexcel.js +0 -1506
- package/node_modules/xlsx/dist/shim.min.js +0 -2
- package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
- package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
- package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
- package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
- package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
- package/node_modules/xlsx/dist/zahl.d.ts +0 -4
- package/node_modules/xlsx/package.json +0 -183
- package/node_modules/xlsx/types/index.d.ts +0 -1065
- package/node_modules/xlsx/types/tsconfig.json +0 -15
- package/node_modules/xlsx/xlsx.js +0 -28103
- package/node_modules/xlsx/xlsx.mjs +0 -28228
- package/node_modules/xlsx/xlsxworker.js +0 -14
- /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,8 +1,11 @@
|
|
|
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);
|
|
8
|
+
// - brings a new release of this package into the home's agent/, merged with what the agents changed (host.update);
|
|
6
9
|
// - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
|
|
7
10
|
// opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
|
|
8
11
|
// Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
|
|
@@ -15,44 +18,118 @@ import path from "node:path";
|
|
|
15
18
|
import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, SessionManager,
|
|
16
19
|
SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
17
20
|
import git from "isomorphic-git";
|
|
21
|
+
import diff3Merge from "diff3";
|
|
18
22
|
import { WebSocketServer } from "ws";
|
|
19
23
|
|
|
20
|
-
const ROOT = import.meta.dirname; // this package: server.ts and the starting
|
|
21
|
-
// HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder
|
|
24
|
+
const ROOT = import.meta.dirname; // this package: server.ts and the starting agent/
|
|
25
|
+
// HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder.
|
|
26
|
+
// Four places, one job each (the prompt gives them as {agent}, {built}, {data}, {workspace}):
|
|
22
27
|
const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
28
|
+
const AGENT = path.join(HOME, "agent"); // what the agents ARE, pi's agentDir: SYSTEM.md, settings, extensions/, skills/
|
|
29
|
+
const BUILT = path.join(HOME, "built"); // what they MADE for the user: card.json, board.json, apps/; served at /built/
|
|
30
|
+
const DATA = path.join(HOME, "data"); // what is kept while running: conversations, typed data, notes (not versioned)
|
|
31
|
+
const WORKSPACE = path.join(HOME, "workspace"); // the agents' working folder (bash runs here); workspace/drops: the user's files
|
|
32
|
+
// The version history covers agent/ and built/ together (a rollback puts both back), but not the logins and endpoints
|
|
33
|
+
// (the model extension writes them, not the agents) or installed packages. Here, so /recover follows it too.
|
|
34
|
+
const NOT_IN_HISTORY = ["agent/auth.json", "agent/models.json", "agent/models-store.json", "agent/mcp-auth.json"];
|
|
35
|
+
const inHistory = (f: string) => /^(agent|built)\//.test(f) && !NOT_IN_HISTORY.includes(f) && !f.split("/").includes("node_modules");
|
|
36
|
+
// What an agent is made of (paths in agent/). A new home starts with these from this package (no logins, nothing
|
|
37
|
+
// built); anything else an agent puts in agent/ doesn't belong there (the version extension tells it so).
|
|
38
|
+
const AGENT_PARTS = ["extensions/", "skills/", "SYSTEM.md", "mcp.json", "settings.json"];
|
|
39
|
+
const isAgentPart = (rel: string) => !rel.split(path.sep).includes("node_modules")
|
|
40
|
+
&& AGENT_PARTS.some((p) => (rel.replaceAll(path.sep, "/") + "/").startsWith(p) || rel === p);
|
|
41
|
+
const startAgent = () => fs.cpSync(path.join(ROOT, "agent"), AGENT, { recursive: true,
|
|
42
|
+
filter: (src) => { const rel = path.relative(path.join(ROOT, "agent"), src); return !rel || isAgentPart(rel); } });
|
|
25
43
|
// our dependencies' folder (npm may put it above this package)
|
|
26
44
|
const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
|
|
46
|
+
// The layout before 2026-11 (app/: the agent and what it built; state/: the data), moved once into the four places.
|
|
47
|
+
// An old app/ (an installed home) gives up only the user's things: its code stays as app-before-reorg/, and agent/ is
|
|
48
|
+
// this package's (the old code expects the old layout). The old version history stays too: its paths were app/'s.
|
|
49
|
+
function migrate() {
|
|
50
|
+
const move = (from: string, to: string) => {
|
|
51
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return;
|
|
52
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
53
|
+
fs.renameSync(from, to);
|
|
54
|
+
console.log(`moved ${path.relative(HOME, from)} -> ${path.relative(HOME, to)}`);
|
|
55
|
+
};
|
|
56
|
+
const old = path.join(HOME, "app");
|
|
57
|
+
if (fs.existsSync(old)) {
|
|
58
|
+
if (!fs.existsSync(AGENT)) startAgent();
|
|
59
|
+
for (const f of [...NOT_IN_HISTORY.map((f) => path.basename(f)), "mcp.json", "settings.json"]) // the user's, not code
|
|
60
|
+
if (fs.existsSync(path.join(old, f))) { fs.rmSync(path.join(AGENT, f), { force: true }); move(path.join(old, f), path.join(AGENT, f)); }
|
|
61
|
+
}
|
|
62
|
+
for (const ui of [path.join(old, "ui"), path.join(AGENT, "ui")]) { // what they built: app/ui (installed), agent/ui (this repo)
|
|
63
|
+
const apps = path.join(ui, "apps");
|
|
64
|
+
if (fs.existsSync(apps)) for (const name of fs.readdirSync(apps)) if (name !== ".keep") move(path.join(apps, name), path.join(BUILT, "apps", name));
|
|
65
|
+
move(path.join(ui, "surfaces.json"), path.join(BUILT, "card.json"));
|
|
66
|
+
move(path.join(ui, "boards.json"), path.join(BUILT, "board.json"));
|
|
67
|
+
fs.rmSync(path.join(apps, ".keep"), { force: true });
|
|
68
|
+
for (const d of [apps, ui]) try { fs.rmdirSync(d); } catch {}
|
|
69
|
+
}
|
|
70
|
+
if (fs.existsSync(old)) move(old, path.join(HOME, "app-before-reorg"));
|
|
71
|
+
move(path.join(HOME, "state"), DATA);
|
|
72
|
+
for (const [from, to] of [["ui-data.json", "card.json"], ["ui-history.jsonl", "history.jsonl"], ["notes.json", "note.json"],
|
|
73
|
+
["frames.json", "frame.json"], ["login.json", "model.json"], ["app-data", "app"], ["screens", "screen"], ["pointed", "viewer"],
|
|
74
|
+
["shots", "version/shots"], ["versions.git", "version-before-reorg.git"]]) move(path.join(DATA, from), path.join(DATA, to));
|
|
75
|
+
// what was in them, in the new words: a card's "components" are its "blocks", each {"block": kind}; the Card block
|
|
76
|
+
// is Panel; a card that was one App block shows that app; the undo steps' "surfaces" are "cards"; apps use the new addresses
|
|
77
|
+
const blocks = (list: any[]) => list.map(({ component, ...b }: any) => ({ ...b, block: component === "Card" ? "Panel" : component }));
|
|
78
|
+
const content = ({ components, ...rest }: any) => components.length === 1 && components[0].component === "App"
|
|
79
|
+
? { ...rest, app: `apps/${components[0].src}`, ...(components[0].height ? { height: components[0].height } : {}) }
|
|
80
|
+
: { ...rest, blocks: blocks(components) };
|
|
81
|
+
const card = (r: any) => {
|
|
82
|
+
if (!r.components) return r;
|
|
83
|
+
const out: any = content(r);
|
|
84
|
+
if (out.options) out.options = out.options.map((o: any) => o.components ? content(o) : o);
|
|
85
|
+
if (out.kinds) out.kinds = Object.fromEntries(Object.entries(out.kinds).filter(([k]) => k !== "App").map(([k, v]) => [k === "Card" ? "Panel" : k, v]));
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
const cards = (all: Record<string, any>) => Object.fromEntries(Object.entries(all).map(([id, r]) => [id, card(r)]));
|
|
89
|
+
const rewrite = (file: string, change: (text: string) => string) => {
|
|
90
|
+
const text = fs.readFileSync(file, "utf8"), now = change(text);
|
|
91
|
+
if (now !== text) { fs.writeFileSync(file, now); console.log(`updated ${path.relative(HOME, file)}`); }
|
|
92
|
+
};
|
|
93
|
+
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);
|
|
94
|
+
if (fs.existsSync(path.join(DATA, "history.jsonl"))) rewrite(path.join(DATA, "history.jsonl"), (t) => !t.includes('"surfaces"') ? t
|
|
95
|
+
: 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"));
|
|
96
|
+
const apps = path.join(BUILT, "apps");
|
|
97
|
+
if (fs.existsSync(apps)) for (const f of fs.readdirSync(apps, { recursive: true }) as string[])
|
|
98
|
+
if (/\.(html|js|mjs)$/.test(f) && !f.split(path.sep).includes("node_modules")) rewrite(path.join(apps, f), (t) => t
|
|
99
|
+
.replaceAll("/ui/agents.js", "/app/agents.js").replaceAll("/ui/voice.js", "/voice/voice.js")
|
|
100
|
+
.replaceAll("/ui-api/app-data/", "/app/data/").replaceAll("/ui/apps/", "/built/apps/"));
|
|
101
|
+
}
|
|
102
|
+
migrate(); // (a new home's agent/ comes from the update below: the first release, merged into nothing)
|
|
103
|
+
if (HOME !== ROOT) { // HOME/agent's extensions import our dependencies: Node looks for a node_modules above them
|
|
104
|
+
fs.mkdirSync(HOME, { recursive: true }); // a new home: nothing made it yet
|
|
29
105
|
const link = path.join(HOME, "node_modules");
|
|
30
106
|
if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
|
|
31
107
|
if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
|
|
32
108
|
fs.symlinkSync(MODULES, link, "junction");
|
|
33
109
|
}
|
|
34
110
|
// 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
|
|
36
|
-
process.env.PI_CODING_AGENT_DIR =
|
|
37
|
-
const WORK = path.join(HOME, "workspace"); // the agents' working folder (bash runs here)
|
|
111
|
+
// and mcp-auth.json): the same agent/
|
|
112
|
+
process.env.PI_CODING_AGENT_DIR = AGENT;
|
|
38
113
|
const PORT = Number(process.env.PORT || 3000); // the page's port
|
|
39
|
-
for (const d of [
|
|
114
|
+
for (const d of [path.join(BUILT, "apps"), DATA, WORKSPACE]) fs.mkdirSync(d, { recursive: true });
|
|
40
115
|
|
|
41
116
|
type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
|
|
42
117
|
stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
|
|
43
118
|
|
|
44
119
|
const sockets = new Set<any>();
|
|
45
120
|
const host = {
|
|
46
|
-
root: ROOT,
|
|
121
|
+
root: ROOT, home: HOME, agent: AGENT, built: BUILT, data: DATA, workspace: WORKSPACE, modules: MODULES, port: PORT,
|
|
122
|
+
inHistory, // inHistory(path in HOME): whether the version history keeps that file
|
|
123
|
+
isAgentPart, // isAgentPart(path in agent/): whether it is part of an agent
|
|
47
124
|
agents: new Map<string, Agent>(),
|
|
48
125
|
routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
|
|
49
126
|
pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
|
|
50
127
|
eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
|
|
51
128
|
// also what pi.on misses, e.g. a message filed while idle); key -> handler
|
|
52
129
|
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
|
|
130
|
+
shared: new Map<string, any>(), // state extensions share across agents (e.g. the cards)
|
|
54
131
|
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.
|
|
132
|
+
api: {} as Record<string, any>, // services extensions offer by name (e.g. team); the newest load wins
|
|
56
133
|
file, // file(res, path): send a file (type from its extension; 404 if none)
|
|
57
134
|
|
|
58
135
|
broadcast(type: string, data: object) {
|
|
@@ -60,14 +137,14 @@ const host = {
|
|
|
60
137
|
for (const s of sockets) s.send(text);
|
|
61
138
|
},
|
|
62
139
|
|
|
63
|
-
// Without the
|
|
140
|
+
// Without the team extension: each agent idle or working, and messages go straight to a run
|
|
64
141
|
status() {
|
|
65
|
-
if (host.api.
|
|
142
|
+
if (host.api.team) return host.api.team.status();
|
|
66
143
|
return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
|
|
67
144
|
state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
|
|
68
145
|
},
|
|
69
146
|
send(from: string, to: string, text: string) {
|
|
70
|
-
if (host.api.
|
|
147
|
+
if (host.api.team) return host.api.team.send(from, to, text);
|
|
71
148
|
void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
|
|
72
149
|
},
|
|
73
150
|
|
|
@@ -118,23 +195,23 @@ const host = {
|
|
|
118
195
|
async createAgent(name: string, role: string, app?: string, fresh = false) {
|
|
119
196
|
const old = host.agents.get(name);
|
|
120
197
|
if (old) { old.unsubscribe(); old.session.dispose(); }
|
|
121
|
-
const dir = path.join(
|
|
198
|
+
const dir = path.join(DATA, "sessions", name);
|
|
122
199
|
fs.mkdirSync(dir, { recursive: true });
|
|
123
200
|
// pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
|
|
124
201
|
// 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(
|
|
202
|
+
const settingsManager = SettingsManager.create(WORKSPACE, AGENT, { projectTrusted: false });
|
|
126
203
|
// 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
|
|
128
|
-
// codemode and tool_search for servers whose tools aren't direct. Sign-ins:
|
|
129
|
-
const resourceLoader = new DefaultResourceLoader({ cwd:
|
|
204
|
+
// pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in agent/mcp.json, with
|
|
205
|
+
// codemode and tool_search for servers whose tools aren't direct. Sign-ins: agent/mcp-auth.json (not versioned).
|
|
206
|
+
const resourceLoader = new DefaultResourceLoader({ cwd: WORKSPACE, agentDir: AGENT, settingsManager, noContextFiles: true,
|
|
130
207
|
extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
|
|
131
|
-
createMcpExtension({ logPath: path.join(
|
|
208
|
+
createMcpExtension({ logPath: path.join(DATA, "mcp.log") })] });
|
|
132
209
|
await resourceLoader.reload();
|
|
133
210
|
for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
|
|
134
211
|
const { session } = await createAgentSession({
|
|
135
|
-
cwd:
|
|
212
|
+
cwd: WORKSPACE, agentDir: AGENT, resourceLoader, settingsManager,
|
|
136
213
|
// the saved conversation, or a new one
|
|
137
|
-
sessionManager: fresh ? SessionManager.create(
|
|
214
|
+
sessionManager: fresh ? SessionManager.create(WORKSPACE, dir) : SessionManager.continueRecent(WORKSPACE, dir),
|
|
138
215
|
});
|
|
139
216
|
if (session.sessionName !== name) session.setSessionName(name);
|
|
140
217
|
const empty = session.sessionManager.buildSessionContext().messages.length === 0;
|
|
@@ -144,9 +221,9 @@ const host = {
|
|
|
144
221
|
console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
|
|
145
222
|
emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
|
|
146
223
|
} });
|
|
147
|
-
// the model: the one picked with /login or /model
|
|
224
|
+
// the model: the one picked with /login or /model, else the MODEL_BASE_URL endpoint (both: extensions/model).
|
|
148
225
|
// 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.
|
|
226
|
+
const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.model?.modelFor(name, session);
|
|
150
227
|
const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
|
|
151
228
|
if (model) await session.setModel(model);
|
|
152
229
|
// thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
|
|
@@ -186,58 +263,226 @@ function file(res: http.ServerResponse, p: string) {
|
|
|
186
263
|
fs.createReadStream(p).pipe(res);
|
|
187
264
|
}
|
|
188
265
|
|
|
189
|
-
// ----
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
266
|
+
// ---- the version history: one git repo for agent/ and built/ (inHistory), kept in data/version/git ----
|
|
267
|
+
// Outside the folders it keeps, so an agent's own git commands can't rewrite it. Git through isomorphic-git (pure JS).
|
|
268
|
+
// The version extension commits and rolls back with this; /recover too, so it works when that extension is broken.
|
|
269
|
+
// repo isomorphic-git's {fs, dir: HOME, gitdir}
|
|
270
|
+
// stage() git add -A for what inHistory keeps (.gitignore files play no part); returns the changed paths
|
|
271
|
+
// restore(oid) that version's agent/ and built/ into the folders and the index, removing files it doesn't have
|
|
272
|
+
// (the branch stays where it is; what the history doesn't keep, logins and endpoints, isn't touched)
|
|
273
|
+
// versions() the user's versions, newest first, as git.log gives them ({oid, commit}): main's own line, without
|
|
274
|
+
// the releases an update merged in (see "updates" below)
|
|
275
|
+
const REPO = { fs, dir: HOME, gitdir: path.join(DATA, "version", "git") };
|
|
276
|
+
const KEPT = { filepaths: ["agent", "built"], ignored: true, filter: inHistory }; // statusMatrix's walk: only these
|
|
277
|
+
// isomorphic-git 1.44.2 takes a file whose size and mtime (whole seconds) match the index as unchanged: a same-size
|
|
278
|
+
// change in the second after it was staged would be missed (and an update would write over it). Real git checks
|
|
279
|
+
// those "racy" files; here every file that looks unchanged is checked by its content. Its status also misses a change
|
|
280
|
+
// of the x bit alone: checked too.
|
|
281
|
+
async function stage() {
|
|
282
|
+
const changed = [], staged = new Map<string, string>(); // the index: path -> "oid mode"
|
|
283
|
+
await git.walk({ ...REPO, trees: [git.STAGE()], map: async (f, [e]) => {
|
|
284
|
+
if (e && f !== "." && (await e.type()) === "blob") staged.set(f, `${await e.oid()} ${await e.mode()}`);
|
|
285
|
+
return true;
|
|
286
|
+
} });
|
|
287
|
+
const now = async (f: string) => { const p = path.join(HOME, f); // the file as git would stage it
|
|
288
|
+
return `${(await git.hashBlob({ object: fs.readFileSync(p) })).oid} ${fs.statSync(p).mode & 0o111 ? 0o100755 : 0o100644}`; };
|
|
289
|
+
for (const [filepath, head, workdir, index] of await git.statusMatrix({ ...REPO, ...KEPT })) {
|
|
290
|
+
if (head === 1 && workdir === 1 && index === 1 && await now(filepath) === staged.get(filepath)) continue; // unchanged
|
|
291
|
+
if (workdir === 0) { if (index !== 0) await git.remove({ ...REPO, filepath }); }
|
|
292
|
+
else await git.add({ ...REPO, filepath, force: true });
|
|
293
|
+
changed.push(filepath);
|
|
294
|
+
}
|
|
295
|
+
return changed;
|
|
296
|
+
}
|
|
297
|
+
// Only the folders that either version has files in: for a folder neither has (built/ before anything was made, still
|
|
298
|
+
// on disk as built/apps/), isomorphic-git 1.44.2's checkout takes it for a file to delete and throws EISDIR.
|
|
299
|
+
async function restore(oid: string) {
|
|
300
|
+
const has = async (ref: string, dir: string) => (await git.listFiles({ ...REPO, ref })).some((f) => f.startsWith(`${dir}/`));
|
|
301
|
+
const head = await git.resolveRef({ ...REPO, ref: "HEAD" });
|
|
302
|
+
const filepaths = [];
|
|
303
|
+
for (const dir of KEPT.filepaths) if (await has(oid, dir) || await has(head, dir)) filepaths.push(dir);
|
|
304
|
+
if (filepaths.length) await git.checkout({ ...REPO, ref: oid, noUpdateHead: true, force: true, filepaths });
|
|
305
|
+
}
|
|
306
|
+
Object.assign(host, { history: { repo: REPO, kept: KEPT, stage, restore } });
|
|
307
|
+
|
|
308
|
+
// ---- updates: each release of this package is a commit on the history's "package" branch, merged into main ----
|
|
309
|
+
// The package's agent/ is a release; the home's agent/ is what runs, and the agents change it. At start, a release
|
|
310
|
+
// not merged yet is committed on "package" (its agent/ only) and merged into main, file by file. The base is the
|
|
311
|
+
// release merged last (git's merge base), so for each file:
|
|
312
|
+
// only the release changed it -> the release's only the home changed it -> the home's
|
|
313
|
+
// both, in different lines -> both changes (diff3)
|
|
314
|
+
// both, in the same lines, or one side deleted it -> the home's stays; the release's goes next to it as
|
|
315
|
+
// <file>.new, and agent1 is asked to merge them (host.update.conflicts)
|
|
316
|
+
// First a "Before update" commit of unsaved changes, then one commit with both parents: the version panel undoes an
|
|
317
|
+
// update like any change. A new home is the first release merged into nothing; a home from before this has no
|
|
318
|
+
// "package" yet: its base is its "original" version (the release it started from). If the update fails, the home
|
|
319
|
+
// runs as it was and the page says why (host.update.error). Not when the home is this folder (node server.ts here).
|
|
320
|
+
// agent/mcp.json is the user's own list of MCP servers: the package never ships it, so no release brings or removes it.
|
|
321
|
+
const RELEASE = `Chalky ${JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version}`;
|
|
322
|
+
const CHALKY = { name: "chalky", email: "chalky@chalky" };
|
|
323
|
+
const OWN = ["agent/mcp.json"];
|
|
324
|
+
const ref = (r: string) => git.resolveRef({ ...REPO, ref: r }).catch(() => undefined);
|
|
325
|
+
const blob = async (oid: string) => Buffer.from((await git.readBlob({ ...REPO, oid })).blob);
|
|
326
|
+
|
|
327
|
+
// The user's versions: from HEAD by first parents only (an update's first parent is the version before it; its
|
|
328
|
+
// second, the release), stopping at a release (a new home's first version comes after one). Not git.log: it walks
|
|
329
|
+
// both parents, so the releases would be listed as versions (going back to one would take the agents' changes and
|
|
330
|
+
// built/ away), the first release twice, and versions made in the same second out of order.
|
|
331
|
+
async function versions() {
|
|
332
|
+
const releases = new Set<string>(), original = await ref("refs/tags/original"); // a home from before: its "original" isn't one
|
|
333
|
+
for (let oid = await ref("package"); oid && oid !== original; oid = (await git.readCommit({ ...REPO, oid })).commit.parent[0])
|
|
334
|
+
releases.add(oid);
|
|
335
|
+
const out = [];
|
|
336
|
+
for (let oid = await ref("HEAD"); oid && !releases.has(oid); ) {
|
|
337
|
+
const c = await git.readCommit({ ...REPO, oid });
|
|
338
|
+
out.push(c);
|
|
339
|
+
oid = c.commit.parent[0];
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
Object.assign(host.history, { versions });
|
|
344
|
+
|
|
345
|
+
async function releaseTree(dir: string): Promise<string | null> { // the package's agent/ (what startAgent copies) as a git tree
|
|
346
|
+
const tree = [];
|
|
347
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
348
|
+
const p = path.join(dir, e.name);
|
|
349
|
+
const rel = path.relative(path.join(ROOT, "agent"), p);
|
|
350
|
+
if (!isAgentPart(rel) || OWN.includes(`agent/${rel}`)) continue;
|
|
351
|
+
if (e.isFile()) tree.push({ mode: fs.statSync(p).mode & 0o111 ? "100755" : "100644", path: e.name, type: "blob" as const,
|
|
352
|
+
oid: await git.writeBlob({ ...REPO, blob: fs.readFileSync(p) }) });
|
|
353
|
+
else if (e.isDirectory()) { const oid = await releaseTree(p); if (oid) tree.push({ mode: "040000", path: e.name, type: "tree" as const, oid }); }
|
|
354
|
+
}
|
|
355
|
+
return tree.length ? git.writeTree({ ...REPO, tree }) : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function filesOf(commit?: string) { // agent/'s files in a commit: path -> {oid, mode}
|
|
359
|
+
const files = new Map<string, { oid: string; mode: number }>();
|
|
360
|
+
if (commit) await git.walk({ ...REPO, trees: [git.TREE({ ref: commit })], map: async (p, [e]) => {
|
|
361
|
+
if (p !== "." && p !== "agent" && !p.startsWith("agent/")) return null; // built/: not the release's
|
|
362
|
+
if (e && (await e.type()) === "blob") files.set(p, { oid: await e.oid(), mode: await e.mode() });
|
|
363
|
+
return true;
|
|
364
|
+
} });
|
|
365
|
+
return files;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function merge3(base: Buffer, ours: Buffer, theirs: Buffer) { // both changes, or null if they touch the same lines
|
|
369
|
+
if ([base, ours, theirs].some((b) => b.includes(0))) return null; // not text
|
|
370
|
+
const lines = (b: Buffer) => b.toString("utf8").match(/^.*(\r?\n|$)/gm) ?? [];
|
|
371
|
+
const parts = diff3Merge(lines(ours), lines(base), lines(theirs));
|
|
372
|
+
return parts.some((p: any) => p.conflict) ? null : parts.flatMap((p: any) => p.ok).join("");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function update() {
|
|
376
|
+
let main = await ref("main"), pkg = await ref("package");
|
|
377
|
+
const last = pkg && (await git.readCommit({ ...REPO, oid: pkg })).commit.message.trim();
|
|
378
|
+
if (last === RELEASE && main && (await git.findMergeBase({ ...REPO, oids: [main, pkg!] }))[0] === pkg) return; // merged
|
|
379
|
+
await git.init({ ...REPO, defaultBranch: "main" }); // (does nothing if there is one)
|
|
380
|
+
if ((await stage()).length) main = await git.commit({ ...REPO, message: `Before update to ${RELEASE}`, author: CHALKY });
|
|
381
|
+
if (last !== RELEASE) {
|
|
382
|
+
const agent = await releaseTree(path.join(ROOT, "agent"));
|
|
383
|
+
const tree = await git.writeTree({ ...REPO, tree: agent ? [{ mode: "040000", path: "agent", type: "tree", oid: agent }] : [] });
|
|
384
|
+
const parent = pkg ?? (main && await ref("refs/tags/original")); // a home from before this: the release it started from
|
|
385
|
+
pkg = await git.commit({ ...REPO, ref: "refs/heads/package", tree, parent: parent ? [parent] : [], message: RELEASE, author: CHALKY });
|
|
386
|
+
}
|
|
387
|
+
const [base] = main ? await git.findMergeBase({ ...REPO, oids: [main, pkg!] }) : [];
|
|
388
|
+
const [B, O, T] = await Promise.all([base, main, pkg].map(filesOf));
|
|
389
|
+
const writes: [string, Buffer | null, number?][] = [], conflicts: string[] = []; // [path in HOME, content (null: delete), mode]
|
|
390
|
+
for (const f of new Set([...O.keys(), ...T.keys()])) {
|
|
391
|
+
if (OWN.includes(f)) continue;
|
|
392
|
+
const [b, o, t] = [B.get(f), O.get(f), T.get(f)], same = (x?: typeof b, y?: typeof b) => x?.oid === y?.oid && x?.mode === y?.mode;
|
|
393
|
+
if (same(o, t) || same(b, t)) continue; // the same, or the release didn't change it: the home's
|
|
394
|
+
if (same(b, o)) { writes.push([f, t ? await blob(t.oid) : null, t?.mode]); continue; } // only the release changed it
|
|
395
|
+
const merged = b && o && t && merge3(await blob(b.oid), await blob(o.oid), await blob(t.oid)); // (and the x bit: whose changed)
|
|
396
|
+
if (merged != null) { writes.push([f, Buffer.from(merged), b!.mode === t!.mode ? o!.mode : t!.mode]); continue; }
|
|
397
|
+
if (t) writes.push([`${f}.new`, await blob(t.oid), t.mode]);
|
|
398
|
+
conflicts.push(!o ? `${f}: you had deleted it; this release changed it (its version: ${f}.new)`
|
|
399
|
+
: !t ? `${f}: this release removed it; you had changed it (yours is kept)`
|
|
400
|
+
: `${f}: you and this release changed the same lines (its version: ${f}.new)`);
|
|
401
|
+
}
|
|
402
|
+
try { // (then saved: main moves only when every file is written)
|
|
403
|
+
for (const [f, content, mode] of writes) {
|
|
404
|
+
const p = path.join(HOME, f);
|
|
405
|
+
if (!content) { // and the folders that leaves empty
|
|
406
|
+
fs.rmSync(p, { force: true });
|
|
407
|
+
for (let d = path.dirname(p); d !== AGENT && fs.existsSync(d) && !fs.readdirSync(d).length; d = path.dirname(d)) fs.rmdirSync(d);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
411
|
+
fs.writeFileSync(p, content);
|
|
412
|
+
if (mode) fs.chmodSync(p, mode & 0o777);
|
|
413
|
+
}
|
|
414
|
+
await stage();
|
|
415
|
+
await git.commit({ ...REPO, message: main ? `Updated to ${RELEASE}` : `Started with ${RELEASE}`, author: CHALKY,
|
|
416
|
+
parent: main ? [main, pkg!] : [pkg!] });
|
|
417
|
+
} catch (e) { // each file written goes back as main has it (contents and x bit), or away (.new files too)
|
|
418
|
+
for (const [f] of writes) {
|
|
419
|
+
const p = path.join(HOME, f), o = O.get(f);
|
|
420
|
+
try {
|
|
421
|
+
if (!o) { fs.rmSync(p, { force: true }); continue; }
|
|
422
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
423
|
+
fs.writeFileSync(p, await blob(o.oid));
|
|
424
|
+
fs.chmodSync(p, o.mode & 0o777);
|
|
425
|
+
} catch (e2) { console.error(`could not put ${f} back:`, e2); }
|
|
426
|
+
}
|
|
427
|
+
throw e;
|
|
428
|
+
}
|
|
429
|
+
const files = writes.length - writes.filter(([f]) => f.endsWith(".new")).length;
|
|
430
|
+
if (main) return { release: RELEASE, files, conflicts }; // (a new home: nothing to tell)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (HOME !== ROOT) try { Object.assign(host, { update: await update() }); } catch (e: any) { // run as it was: main didn't move
|
|
434
|
+
console.error(`update to ${RELEASE} failed:`, e);
|
|
435
|
+
const main = await ref("main");
|
|
436
|
+
if (main) await restore(main).catch((e) => console.error("could not put agent/ and built/ back as main has them:", e)); // and the index
|
|
437
|
+
Object.assign(host, { update: { release: RELEASE, error: String(e?.message ?? e) } });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- recovery: the safety net for when an agent breaks the page (or the version extension, so its panel) ----
|
|
441
|
+
// Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever agent/ holds.
|
|
442
|
+
// GET /recover the saved versions, newest first; "the page worked" = the page took its picture of that
|
|
443
|
+
// version (data/version/shots/<sha>.png); the newest too (a broken version extension doesn't
|
|
444
|
+
// save the edit that broke it)
|
|
445
|
+
// POST /recover/<sha> that version's agent/ and built/ back, as a new commit (as the version panel's rollback,
|
|
195
446
|
// without its extras), then every agent's extensions load again; back to the page
|
|
196
447
|
// 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
448
|
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
199
449
|
|
|
200
450
|
async function recoverPage(res: http.ServerResponse) {
|
|
201
|
-
|
|
451
|
+
// the history's folder comes before its first commit: right after a start there may be no versions yet
|
|
452
|
+
const log = await versions(); // (none yet: [])
|
|
202
453
|
const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
|
|
203
454
|
<td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
|
|
204
|
-
<td>${fs.existsSync(path.join(
|
|
455
|
+
<td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
|
|
205
456
|
<td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
|
|
206
457
|
const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
|
|
207
458
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
|
|
208
459
|
<style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
|
|
209
460
|
<h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
|
|
210
461
|
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(
|
|
462
|
+
${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(AGENT, e.path))}: ${esc(String(e.error))}</p>`).join("")}
|
|
212
463
|
<table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
|
|
213
464
|
}
|
|
214
465
|
|
|
215
466
|
async function recover(sha: string, res: http.ServerResponse) {
|
|
216
467
|
try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
|
|
217
|
-
await host.shared.get("
|
|
218
|
-
try { await host.api.
|
|
468
|
+
await host.shared.get("version")?.queue; // after the versions extension's own work
|
|
469
|
+
try { await host.api.team?.stop(); } catch {} // the team extension may be the broken one
|
|
219
470
|
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
471
|
const author = { name: "user", email: "user@chalky" };
|
|
227
472
|
const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
|
|
228
473
|
await stage();
|
|
229
474
|
try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
|
|
230
|
-
await
|
|
231
|
-
const files =
|
|
232
|
-
for (const f of files) for (let d = path.dirname(f);
|
|
233
|
-
try { fs.rmdirSync(path.join(
|
|
475
|
+
await restore(sha);
|
|
476
|
+
const files = await stage();
|
|
477
|
+
for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) { // folders it emptied
|
|
478
|
+
try { fs.rmdirSync(path.join(HOME, d)); } catch { break; }
|
|
234
479
|
}
|
|
235
480
|
await git.commit({ ...REPO, message: `recover to ${subject}`, author });
|
|
236
481
|
for (const name of host.agents.keys()) await host.reloadAgent(name);
|
|
237
|
-
if (files.includes("
|
|
482
|
+
if (files.includes("built/card.json")) { host.shared.get("card")?.load?.(); host.api.history?.add("user", `recovered to ${subject}`); }
|
|
238
483
|
for (const name of host.agents.keys())
|
|
239
|
-
host.api.
|
|
240
|
-
+ `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/
|
|
484
|
+
host.api.team?.fyi("system", name, `The user recovered ${AGENT} and ${BUILT} to version ${subject} (the page was broken). `
|
|
485
|
+
+ `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/version). Check before relying on them.`);
|
|
241
486
|
console.log(`recovered to ${subject}`);
|
|
242
487
|
res.writeHead(303, { location: "/" }).end();
|
|
243
488
|
}
|
|
@@ -246,6 +491,11 @@ const server = http.createServer((req, res) => {
|
|
|
246
491
|
const url = new URL(req.url!, "http://x").pathname;
|
|
247
492
|
if (url === "/recover") return void recoverPage(res);
|
|
248
493
|
if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
|
|
494
|
+
if (url.startsWith("/built/")) { // what the agents made, at the same path as on disk (built/ holds no secrets)
|
|
495
|
+
let p = "";
|
|
496
|
+
try { p = path.join(HOME, path.posix.normalize(decodeURIComponent(url))); } catch {} // a bad %-escape: 404
|
|
497
|
+
return p.startsWith(BUILT + path.sep) ? file(res, p) : void res.writeHead(404).end(); // also a ".." out of built/
|
|
498
|
+
}
|
|
249
499
|
let route = host.routes.get(url);
|
|
250
500
|
for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
|
|
251
501
|
if (route) return route(req, res);
|
|
@@ -263,7 +513,7 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
|
|
|
263
513
|
const data = JSON.parse(String(raw));
|
|
264
514
|
const kind = data.type ?? "chat";
|
|
265
515
|
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.
|
|
516
|
+
else if (kind === "stop" && host.api.team) await host.api.team.stop();
|
|
267
517
|
else if (kind === "stop") {
|
|
268
518
|
for (const a of host.agents.values()) await host.abort(a.name);
|
|
269
519
|
emit("stopped", { agent: "all" });
|
|
@@ -273,8 +523,13 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
|
|
|
273
523
|
|
|
274
524
|
// agent1, then every agent saved by an earlier run (their conversations come back from their session files)
|
|
275
525
|
await host.createAgent("agent1", "the user's main agent");
|
|
276
|
-
|
|
277
|
-
|
|
526
|
+
const conflicts: string[] = (host as any).update?.conflicts ?? []; // the update's files to merge: agent1's task
|
|
527
|
+
if (conflicts.length) host.send("chalky", "agent1", `Chalky was updated to ${(host as any).update.release}. These files `
|
|
528
|
+
+ `were changed both by you (or another agent) and by the update, so yours were kept:\n${conflicts.map((c) => `- ${c}`).join("\n")}\n`
|
|
529
|
+
+ "For each, read both versions and make one that keeps what the user had and adds what the update brings, then "
|
|
530
|
+
+ "delete the .new file. If you can't tell what the user would want, keep theirs and tell them what the update had.");
|
|
531
|
+
for (const name of fs.readdirSync(path.join(DATA, "sessions")).filter((n) => n !== "agent1")) {
|
|
532
|
+
const { role, app } = JSON.parse(fs.readFileSync(path.join(DATA, "sessions", name, "role.json"), "utf8"));
|
|
278
533
|
await host.createAgent(name, role, app);
|
|
279
534
|
}
|
|
280
535
|
const hostName = process.env.UI_HOST || "127.0.0.1";
|
package/app/SYSTEM.md
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (UI
|
|
2
|
-
surfaces and apps) fills the screen, and the conversation is a side panel. The user says what they want; you put a
|
|
3
|
-
working first version on screen; then they point at things on it and leave notes, and you change exactly that.
|
|
4
|
-
- Build, don't describe. A first version they can point at beats questions you could answer by building.
|
|
5
|
-
- Your final reply is the only text they read in full: one or two sentences on what changed.
|
|
6
|
-
- The page around your work is the user's: change it only when asked. Build inside the main space.
|
|
7
|
-
|
|
8
|
-
What follows this: the skills you can load, your working folder (<cwd>: bash runs there), one short section for
|
|
9
|
-
each part of you that has something to say (each comes from an extension), and <paths>: where your folders
|
|
10
|
-
({code}, {work}, {data}), the host and pi's docs are. In these texts and the README.md files, {code}/..., {work}/...
|
|
11
|
-
and {data}/... are paths in those folders. A section that names a README.md is the short version of its
|
|
12
|
-
extension: read that file when the section says, once (it stays in your conversation).
|
|
13
|
-
|
|
14
|
-
Your tools:
|
|
15
|
-
- read to look at files and pictures (not cat or sed); bash for ls, grep, find and running things.
|
|
16
|
-
- edit for precise changes: each edits[].oldText must match the file exactly and only once; keep it as small as
|
|
17
|
-
that allows. Several changes in one file: one edit call with several entries, each matched against the original
|
|
18
|
-
file, none overlapping. An edit whose newText equals its oldText changes nothing and is refused.
|
|
19
|
-
- write only for new files or complete rewrites.
|
|
20
|
-
- Name files by their full path when you mention them.
|
|
21
|
-
|
|
22
|
-
Changing yourself: when the work needs it, you can change any part of you, the page included. You are pi
|
|
23
|
-
extensions, one folder each: {code}/extensions/<name>/. In it, index.ts is `export default function (pi:
|
|
24
|
-
ExtensionAPI) { ... }`, which registers tools (pi.registerTool), prompt sections and event handlers (pi.on); and as
|
|
25
|
-
the extension needs them, guide.md (its short prompt section), README.md (the whole of it), browser.js (its part
|
|
26
|
-
of the page) and skills/. The page itself is the page extension; this text is {code}/SYSTEM.md; pi's settings (e.g. compaction) are
|
|
27
|
-
{code}/settings.json (pi's docs/settings.md). Extensions reach
|
|
28
|
-
shared state and the web server through globalThis.chalky: the host, the fixed core under them (don't edit it).
|
|
29
|
-
Read pi's extension docs (<paths>) before writing or changing an extension. After editing, call the reload tool:
|
|
30
|
-
your extensions load again when your turn ends, and your conversation is kept. A changed page or browser.js shows
|
|
31
|
-
when the page reloads, after your turn.
|
|
@@ -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
|
-
}
|