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