@chalky/chalky 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +45 -2
- package/app/SYSTEM.md +31 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +398 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +166 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +85 -0
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +247 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +112 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +364 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +291 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/mcp.json +3 -0
- package/app/settings.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +281 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* xlsx.js (C) 2013-present SheetJS -- http://sheetjs.com */
|
|
2
|
+
importScripts('dist/shim.min.js');
|
|
3
|
+
/* uncomment the next line for encoding support */
|
|
4
|
+
importScripts('dist/cpexcel.js');
|
|
5
|
+
importScripts('xlsx.js');
|
|
6
|
+
postMessage({t:"ready"});
|
|
7
|
+
|
|
8
|
+
onmessage = function (evt) {
|
|
9
|
+
var v;
|
|
10
|
+
try {
|
|
11
|
+
v = XLSX.read(evt.data.d, {type: evt.data.b, codepage: evt.data.c});
|
|
12
|
+
postMessage({t:"xlsx", d:JSON.stringify(v)});
|
|
13
|
+
} catch(e) { postMessage({t:"e",d:e.stack||e}); }
|
|
14
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chalky/chalky",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Chalky on pi: agents build what you see in the browser, and you point at it and mark it up",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"chalky": "bin/chalky.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"server.ts",
|
|
13
|
+
"app/",
|
|
14
|
+
"!app/auth.json",
|
|
15
|
+
"!app/models-store.json",
|
|
16
|
+
"!app/mcp-auth.json",
|
|
17
|
+
"!app/ui/surfaces.json",
|
|
18
|
+
"!app/ui/apps/*",
|
|
19
|
+
"app/ui/apps/.keep"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"bundleDependencies": [
|
|
25
|
+
"xlsx"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.19.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
32
|
+
"dom-to-image-more": "3.11.0",
|
|
33
|
+
"dompurify": "3.4.17",
|
|
34
|
+
"isomorphic-git": "1.44.2",
|
|
35
|
+
"jiti": "2.7.0",
|
|
36
|
+
"mammoth": "1.13.0",
|
|
37
|
+
"marked": "18.0.14",
|
|
38
|
+
"node-pptx-parser": "1.0.1",
|
|
39
|
+
"unpdf": "1.9.1",
|
|
40
|
+
"word-extractor": "1.0.4",
|
|
41
|
+
"ws": "8.22.0",
|
|
42
|
+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Host: the fixed core under the agents. Everything else, the page included, is a pi extension in
|
|
2
|
+
// app/extensions/<name>/ (agents can change those; this file they can't). This file only:
|
|
3
|
+
// - runs the web server: its WebSocket /ws, and every route extensions add (the page: extensions/page);
|
|
4
|
+
// - keeps one pi AgentSession per agent (session name = agent name; saved under state/sessions/<agent>);
|
|
5
|
+
// - serves /recover: going back to a working version when an agent broke the page (the safety net);
|
|
6
|
+
// - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
|
|
7
|
+
// opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
|
|
8
|
+
// 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=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
|
|
10
|
+
// CHALKY_LOGIN=off: no /login, /logout, /model on the page; agents always use MODEL_BASE_URL (a cloud deployment)
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, SessionManager,
|
|
16
|
+
SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import git from "isomorphic-git";
|
|
18
|
+
import { WebSocketServer } from "ws";
|
|
19
|
+
|
|
20
|
+
const ROOT = import.meta.dirname; // this package: server.ts and the starting app/
|
|
21
|
+
// HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder
|
|
22
|
+
const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
|
|
23
|
+
const APP = path.join(HOME, "app"); // agent-editable, pi's agentDir: extensions/, skills/, ui/ (what they built)
|
|
24
|
+
const STATE = path.join(HOME, "state"); // conversations and data, not code
|
|
25
|
+
// our dependencies' folder (npm may put it above this package)
|
|
26
|
+
const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
|
|
27
|
+
if (!fs.existsSync(APP)) fs.cpSync(path.join(ROOT, "app"), APP, { recursive: true }); // first run: the starting code
|
|
28
|
+
if (HOME !== ROOT) { // HOME/app's extensions import our dependencies: Node looks for a node_modules above them
|
|
29
|
+
const link = path.join(HOME, "node_modules");
|
|
30
|
+
if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
|
|
31
|
+
if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
|
|
32
|
+
fs.symlinkSync(MODULES, link, "junction");
|
|
33
|
+
}
|
|
34
|
+
// pi's agent folder for the code that doesn't take our agentDir but calls getAgentDir() (its MCP extension: mcp.json
|
|
35
|
+
// and mcp-auth.json): the same app/
|
|
36
|
+
process.env.PI_CODING_AGENT_DIR = APP;
|
|
37
|
+
const WORK = path.join(HOME, "workspace"); // the agents' working folder (bash runs here)
|
|
38
|
+
const PORT = Number(process.env.PORT || 3000); // the page's port
|
|
39
|
+
for (const d of [STATE, WORK]) fs.mkdirSync(d, { recursive: true });
|
|
40
|
+
|
|
41
|
+
type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
|
|
42
|
+
stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
|
|
43
|
+
|
|
44
|
+
const sockets = new Set<any>();
|
|
45
|
+
const host = {
|
|
46
|
+
root: ROOT, app: APP, state: STATE, work: WORK, modules: MODULES, port: PORT,
|
|
47
|
+
agents: new Map<string, Agent>(),
|
|
48
|
+
routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
|
|
49
|
+
pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
|
|
50
|
+
eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
|
|
51
|
+
// also what pi.on misses, e.g. a message filed while idle); key -> handler
|
|
52
|
+
connectHandlers: new Map<string, (send: (type: string, data: object) => void) => void>(), // a page opened: what it gets first
|
|
53
|
+
shared: new Map<string, any>(), // state extensions share across agents (e.g. the surfaces)
|
|
54
|
+
reloadAfterTurn: new Map<string, boolean>(), // agents whose extensions load again when their turn ends -> quiet
|
|
55
|
+
api: {} as Record<string, any>, // services extensions offer by name (e.g. swarm); the newest load wins
|
|
56
|
+
file, // file(res, path): send a file (type from its extension; 404 if none)
|
|
57
|
+
|
|
58
|
+
broadcast(type: string, data: object) {
|
|
59
|
+
const text = JSON.stringify({ type, ...data });
|
|
60
|
+
for (const s of sockets) s.send(text);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Without the swarm extension: each agent idle or working, and messages go straight to a run
|
|
64
|
+
status() {
|
|
65
|
+
if (host.api.swarm) return host.api.swarm.status();
|
|
66
|
+
return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
|
|
67
|
+
state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
|
|
68
|
+
},
|
|
69
|
+
send(from: string, to: string, text: string) {
|
|
70
|
+
if (host.api.swarm) return host.api.swarm.send(from, to, text);
|
|
71
|
+
void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// One run: the agent answers input. Returns its final answer, or "(error: ...)". Then a reload it asked for.
|
|
75
|
+
// replyTo: who the answer goes to (the page labels it). Saved as a "chalky.turn" entry, which the model doesn't see.
|
|
76
|
+
// images: pictures sent with the input (pi's {type: "image", data: base64, mimeType}), e.g. doodle's steps.
|
|
77
|
+
async prompt(name: string, input: string, replyTo?: string, images?: { type: "image"; data: string; mimeType: string }[]) {
|
|
78
|
+
const a = host.agents.get(name)!;
|
|
79
|
+
if (replyTo) a.session.sessionManager.appendCustomEntry("chalky.turn", { reply_to: replyTo }); // for the user message next
|
|
80
|
+
a.stopped = false;
|
|
81
|
+
try {
|
|
82
|
+
await a.session.prompt(input, images?.length ? { images } : undefined);
|
|
83
|
+
const last = [...a.session.messages].reverse().find((m: any) => m.role === "assistant");
|
|
84
|
+
if (a.stopped) return "(stopped)"; // pi 1.4.0 can record a stop as stopReason "error" ("This operation was aborted")
|
|
85
|
+
if (last?.stopReason === "error") {
|
|
86
|
+
emit("error", { agent: name, text: last.errorMessage });
|
|
87
|
+
return `(error: ${last.errorMessage})`;
|
|
88
|
+
}
|
|
89
|
+
return last ? text(last.content) : "";
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (a.stopped) return "(stopped)";
|
|
92
|
+
emit("error", { agent: name, text: String(e) });
|
|
93
|
+
return `(error: ${e})`;
|
|
94
|
+
} finally {
|
|
95
|
+
const quiet = host.reloadAfterTurn.get(name);
|
|
96
|
+
if (host.reloadAfterTurn.delete(name)) await host.reloadAgent(name, quiet);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Stop an agent's run on purpose (Stop, the step limit): not shown as an error
|
|
101
|
+
async abort(name: string) {
|
|
102
|
+
const a = host.agents.get(name);
|
|
103
|
+
if (!a) return;
|
|
104
|
+
a.stopped = true;
|
|
105
|
+
await a.session.abort();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// load an agent's extensions, skills and prompt again (pi's reload; the conversation stays). quiet: no
|
|
109
|
+
// "extensions reloaded" note on the page (errors still show)
|
|
110
|
+
async reloadAgent(name: string, quiet = false) {
|
|
111
|
+
const a = host.agents.get(name)!;
|
|
112
|
+
await a.session.reload();
|
|
113
|
+
for (const e of a.session.resourceLoader.getExtensions().errors) emit("error", { agent: name, text: `extension ${e.path}: ${e.error}` });
|
|
114
|
+
if (!quiet) emit("error", { agent: name, text: "extensions reloaded" }); // shown as a note on the page
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// fresh: a new session file (an empty memory); the old files stay, and the page still shows them
|
|
118
|
+
async createAgent(name: string, role: string, app?: string, fresh = false) {
|
|
119
|
+
const old = host.agents.get(name);
|
|
120
|
+
if (old) { old.unsubscribe(); old.session.dispose(); }
|
|
121
|
+
const dir = path.join(STATE, "sessions", name);
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
+
// pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
|
|
124
|
+
// SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
|
|
125
|
+
const settingsManager = SettingsManager.create(WORK, APP, { projectTrusted: false });
|
|
126
|
+
// agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
|
|
127
|
+
// pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in app/mcp.json, with
|
|
128
|
+
// codemode and tool_search for servers whose tools aren't direct. Sign-ins: app/mcp-auth.json (not versioned).
|
|
129
|
+
const resourceLoader = new DefaultResourceLoader({ cwd: WORK, agentDir: APP, settingsManager, noContextFiles: true,
|
|
130
|
+
extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
|
|
131
|
+
createMcpExtension({ logPath: path.join(STATE, "mcp.log") })] });
|
|
132
|
+
await resourceLoader.reload();
|
|
133
|
+
for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
|
|
134
|
+
const { session } = await createAgentSession({
|
|
135
|
+
cwd: WORK, agentDir: APP, resourceLoader, settingsManager,
|
|
136
|
+
// the saved conversation, or a new one
|
|
137
|
+
sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
|
|
138
|
+
});
|
|
139
|
+
if (session.sessionName !== name) session.setSessionName(name);
|
|
140
|
+
const empty = session.sessionManager.buildSessionContext().messages.length === 0;
|
|
141
|
+
// starts the extensions' session (session_start). onError, besides showing extensions' errors: pi 1.4.0's reload()
|
|
142
|
+
// sends session_start again only to a session with a binding, and without it pi's MCP doesn't reconnect after a reload
|
|
143
|
+
await session.bindExtensions({ onError: (e) => {
|
|
144
|
+
console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
|
|
145
|
+
emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
|
|
146
|
+
} });
|
|
147
|
+
// the model: the one picked with /login or /model (extensions/login), else the MODEL_BASE_URL endpoint (extensions/model).
|
|
148
|
+
// CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
|
|
149
|
+
const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.login?.modelFor(name, session);
|
|
150
|
+
const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
|
|
151
|
+
if (model) await session.setModel(model);
|
|
152
|
+
// thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
|
|
153
|
+
// conversation starts at "medium". Set here: pi restores it only when it knows the model then, and ours (the
|
|
154
|
+
// model extension) comes later, so pi would say "off"
|
|
155
|
+
session.setThinkingLevel(empty ? "medium" : session.sessionManager.buildSessionContext().thinkingLevel);
|
|
156
|
+
const a: Agent = { name, role, app, session, unsubscribe: () => {} };
|
|
157
|
+
host.agents.set(name, a);
|
|
158
|
+
a.unsubscribe = session.subscribe((ev: any) => {
|
|
159
|
+
for (const [key, fn] of host.eventHandlers) try { fn(name, ev); } catch (e) { console.error(`event handler ${key}:`, e); }
|
|
160
|
+
});
|
|
161
|
+
fs.writeFileSync(path.join(dir, "role.json"), JSON.stringify({ role, app }));
|
|
162
|
+
host.broadcast("status", { agents: host.status() });
|
|
163
|
+
return a;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
(globalThis as any).chalky = host;
|
|
167
|
+
|
|
168
|
+
// An error nothing handled (in an extension's route or timer, say): keep serving, and show it. Without this, one
|
|
169
|
+
// mistake in any extension, also one an agent wrote, would stop the whole process (no start.sh here).
|
|
170
|
+
process.on("uncaughtException", (e) => {
|
|
171
|
+
console.error("uncaught:", e);
|
|
172
|
+
host.broadcast("error", { agent: "server", text: `uncaught ${e?.name}: ${e?.message}` });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const text = (content: any) => typeof content === "string" ? content
|
|
176
|
+
: content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("");
|
|
177
|
+
|
|
178
|
+
const emit = (type: string, data: object) => host.broadcast(type, data);
|
|
179
|
+
|
|
180
|
+
// ---- web server: /ws, and the routes extensions add ----
|
|
181
|
+
const types: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript",
|
|
182
|
+
".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" };
|
|
183
|
+
function file(res: http.ServerResponse, p: string) {
|
|
184
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.writeHead(404).end();
|
|
185
|
+
res.writeHead(200, { "content-type": types[path.extname(p)] ?? "application/octet-stream", "cache-control": "no-cache" });
|
|
186
|
+
fs.createReadStream(p).pipe(res);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- recovery: the safety net for when an agent breaks the page (or the versions extension, so its panel) ----
|
|
190
|
+
// Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever app/ holds.
|
|
191
|
+
// GET /recover the saved versions (state/versions.git, written by the versions extension), newest first;
|
|
192
|
+
// "the page worked" = the page took its picture of that version (state/shots/<sha>.png);
|
|
193
|
+
// the newest too (a broken versions extension doesn't save the edit that broke it)
|
|
194
|
+
// POST /recover/<sha> that version's files back into app/, as a new commit (as the versions panel's rollback,
|
|
195
|
+
// without its extras), then every agent's extensions load again; back to the page
|
|
196
|
+
// 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
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
199
|
+
|
|
200
|
+
async function recoverPage(res: http.ServerResponse) {
|
|
201
|
+
const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO) : [];
|
|
202
|
+
const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
|
|
203
|
+
<td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
|
|
204
|
+
<td>${fs.existsSync(path.join(STATE, "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
|
|
205
|
+
<td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
|
|
206
|
+
const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
|
|
207
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
|
|
208
|
+
<style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
|
|
209
|
+
<h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
|
|
210
|
+
files are saved first, and going back is itself a new version. <a href="/">Back to the page</a></p>
|
|
211
|
+
${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(APP, e.path))}: ${esc(String(e.error))}</p>`).join("")}
|
|
212
|
+
<table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function recover(sha: string, res: http.ServerResponse) {
|
|
216
|
+
try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
|
|
217
|
+
await host.shared.get("versions")?.queue; // after the versions extension's own work
|
|
218
|
+
try { await host.api.swarm?.stop(); } catch {} // the swarm extension may be the broken one
|
|
219
|
+
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
|
+
const author = { name: "user", email: "user@chalky" };
|
|
227
|
+
const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
|
|
228
|
+
await stage();
|
|
229
|
+
try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
|
|
230
|
+
await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true });
|
|
231
|
+
const files = (await git.statusMatrix(REPO)).filter(([, h, w, i]) => !(h === 1 && w === 1 && i === 1)).map(([f]) => f);
|
|
232
|
+
for (const f of files) for (let d = path.dirname(f); d !== "."; d = path.dirname(d)) { // folders it emptied
|
|
233
|
+
try { fs.rmdirSync(path.join(APP, d)); } catch { break; }
|
|
234
|
+
}
|
|
235
|
+
await git.commit({ ...REPO, message: `recover to ${subject}`, author });
|
|
236
|
+
for (const name of host.agents.keys()) await host.reloadAgent(name);
|
|
237
|
+
if (files.includes("ui/surfaces.json")) host.shared.get("ui")?.load?.();
|
|
238
|
+
for (const name of host.agents.keys())
|
|
239
|
+
host.api.swarm?.fyi("system", name, `The user recovered ${APP} to version ${subject} (the page was broken). `
|
|
240
|
+
+ `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/versions). Check before relying on them.`);
|
|
241
|
+
console.log(`recovered to ${subject}`);
|
|
242
|
+
res.writeHead(303, { location: "/" }).end();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const server = http.createServer((req, res) => {
|
|
246
|
+
const url = new URL(req.url!, "http://x").pathname;
|
|
247
|
+
if (url === "/recover") return void recoverPage(res);
|
|
248
|
+
if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
|
|
249
|
+
let route = host.routes.get(url);
|
|
250
|
+
for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
|
|
251
|
+
if (route) return route(req, res);
|
|
252
|
+
if (url === "/") return void recoverPage(res); // nothing serves the page: an extension broke
|
|
253
|
+
res.writeHead(404).end();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
|
|
257
|
+
const send = (type: string, data: object) => sock.send(JSON.stringify({ type, ...data }));
|
|
258
|
+
send("status", { agents: host.status() });
|
|
259
|
+
for (const [key, fn] of host.connectHandlers) try { fn(send); } catch (e) { console.error(`connect handler ${key}:`, e); }
|
|
260
|
+
sockets.add(sock);
|
|
261
|
+
sock.on("close", () => sockets.delete(sock));
|
|
262
|
+
sock.on("message", async (raw) => {
|
|
263
|
+
const data = JSON.parse(String(raw));
|
|
264
|
+
const kind = data.type ?? "chat";
|
|
265
|
+
if (kind === "chat") host.send("user", data.to === "main" || !host.agents.has(data.to) ? "agent1" : data.to, data.text);
|
|
266
|
+
else if (kind === "stop" && host.api.swarm) await host.api.swarm.stop();
|
|
267
|
+
else if (kind === "stop") {
|
|
268
|
+
for (const a of host.agents.values()) await host.abort(a.name);
|
|
269
|
+
emit("stopped", { agent: "all" });
|
|
270
|
+
} else for (const fn of host.pageHandlers.values()) await fn(data);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// agent1, then every agent saved by an earlier run (their conversations come back from their session files)
|
|
275
|
+
await host.createAgent("agent1", "the user's main agent");
|
|
276
|
+
for (const name of fs.readdirSync(path.join(STATE, "sessions")).filter((n) => n !== "agent1")) {
|
|
277
|
+
const { role, app } = JSON.parse(fs.readFileSync(path.join(STATE, "sessions", name, "role.json"), "utf8"));
|
|
278
|
+
await host.createAgent(name, role, app);
|
|
279
|
+
}
|
|
280
|
+
const hostName = process.env.UI_HOST || "127.0.0.1";
|
|
281
|
+
server.listen(PORT, hostName, () => console.log(`chalky (pi) on http://${hostName}:${PORT} (if the page breaks: http://${hostName}:${PORT}/recover)`));
|