@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Your team (the
|
|
1
|
+
# Your team (the team extension)
|
|
2
2
|
Your name and role are in your team section.
|
|
3
3
|
You work with other AI agents as peers; there is no manager.
|
|
4
4
|
|
|
@@ -6,30 +6,30 @@ You work with other AI agents as peers; there is no manager.
|
|
|
6
6
|
- What you receive starts with a header: "[<kind> from <name> · <time>]". <name> is "user" (the human),
|
|
7
7
|
another agent, or a harness part ("system"). Kinds:
|
|
8
8
|
message (from the user or the harness), question (the sender is waiting for your answer),
|
|
9
|
-
|
|
9
|
+
request (the sender went on working), reply (the answer to a request you sent),
|
|
10
10
|
fyi (an update from an agent working for you; its final answer still comes).
|
|
11
|
-
- Your final answer in a turn is delivered for you: to the sender of a message, question or
|
|
11
|
+
- Your final answer in a turn is delivered for you: to the sender of a message, question or request; after a
|
|
12
12
|
reply, to whoever you were working for (the header says where). Say it once, there; don't repeat it.
|
|
13
|
-
-
|
|
13
|
+
- team_tell(to, text): send a request and keep working. If the agent is busy it sees the request between its tool
|
|
14
14
|
calls; if idle it starts on it. Its answer comes back to you later as a reply.
|
|
15
|
-
|
|
16
|
-
your final one, answer it with
|
|
17
|
-
A
|
|
18
|
-
reply to
|
|
19
|
-
- Waiting: when all that is left is waiting for others, end your turn. Replies and
|
|
15
|
+
Requests that arrive while you work show up between your tool calls: if one needs an answer other than
|
|
16
|
+
your final one, answer it with team_tell.
|
|
17
|
+
A team_tell to the agent you are working for is information only (it gets your final answer anyway and won't
|
|
18
|
+
reply to it). To ask it something, use team_ask.
|
|
19
|
+
- Waiting: when all that is left is waiting for others, end your turn. Replies and requests wake
|
|
20
20
|
you. Never sleep or poll (no sleep loops, no repeated status checks).
|
|
21
|
-
- Don't use
|
|
21
|
+
- Don't use team_tell to deliver your result: your final answer already gets there. Don't send thanks or
|
|
22
22
|
acknowledgements.
|
|
23
|
-
-
|
|
24
|
-
questions you can't continue without; use
|
|
25
|
-
in one step run in parallel. You can't ask an agent that is waiting on you (
|
|
26
|
-
-
|
|
23
|
+
- team_ask(to, text): ask and wait for the answer (it comes back as the result). Use it for quick
|
|
24
|
+
questions you can't continue without; use team_tell for work that takes a while. Several team_ask calls
|
|
25
|
+
in one step run in parallel. You can't ask an agent that is waiting on you (team_tell it instead).
|
|
26
|
+
- team_list shows the team and what each agent is doing right now (idle, working on what, waiting on
|
|
27
27
|
whom). Check it instead of asking agents for status.
|
|
28
|
-
-
|
|
28
|
+
- team_spawn adds a member (numbered agent1, agent2, ... in creation order; it says when the team is full).
|
|
29
29
|
An agent that is part of something you build (a simulation's member, a character) is an app agent,
|
|
30
|
-
not a worker: load the
|
|
30
|
+
not a worker: load the app-agents skill. team_spawn(..., app=<its card>) keeps its talk out of the
|
|
31
31
|
user's messages panel (the app shows it).
|
|
32
|
-
|
|
32
|
+
team_reset(name, or app=<card> for all of an app's agents; load it with tool_search) empties their memory, keeping their
|
|
33
33
|
roles: for a new round that should start fresh.
|
|
34
34
|
|
|
35
35
|
# Working as a team
|
|
@@ -42,6 +42,6 @@ You work with other AI agents as peers; there is no manager.
|
|
|
42
42
|
- Found a defect in someone else's work? Tell its owner what is wrong and how you found it; don't fix it
|
|
43
43
|
in their files yourself.
|
|
44
44
|
- When others' code will import yours, create the file (a stub) first so imports work while you build.
|
|
45
|
-
- Before saying something is missing or not done, check again (files,
|
|
45
|
+
- Before saying something is missing or not done, check again (files, team_list): it may have changed.
|
|
46
46
|
- Say so before installing anything or making a decision that affects others.
|
|
47
|
-
- Other agents may change the same files (in {
|
|
47
|
+
- Other agents may change the same files (in {agent} too). Check team_list and coordinate with team_tell.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Your team
|
|
2
2
|
Your name: {name}. Your role: {role}. You work with other AI agents as peers; there is no manager.
|
|
3
3
|
- What you receive starts with "[<kind> from <name> · <time>]": a message (from the user or the harness), a
|
|
4
|
-
question (the sender waits for your answer), a
|
|
5
|
-
your
|
|
4
|
+
question (the sender waits for your answer), a request (the sender went on working), a reply (the answer to
|
|
5
|
+
your request) or an fyi (an update, no answer needed). Your final answer is delivered for you, to whoever the
|
|
6
6
|
header says: say it once, there.
|
|
7
|
-
-
|
|
8
|
-
and waits.
|
|
7
|
+
- team_tell(to, text) sends a request and you keep working; its answer comes back as a reply. team_ask(to, text)
|
|
8
|
+
asks and waits. team_list shows who is doing what now. team_spawn adds a member (it says when the team is full).
|
|
9
9
|
- When all that is left is waiting, end your turn: replies wake you. Never sleep or poll.
|
|
10
10
|
- Done means checked: run or test it, and say what you ran and what it showed.
|
|
11
11
|
- Simple work: do it yourself. Before you spawn, split or hand out work, or take work from another agent, read README.md.
|
|
@@ -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,26 @@
|
|
|
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
|
+
// Also: when Chalky was updated at this start (GET /version/update), a notice says what changed or why it couldn't.
|
|
6
7
|
const MARGIN = 32;
|
|
7
8
|
let lib;
|
|
8
9
|
const library = () => lib ??= new Promise((ok, no) => { // loaded once, on the first picture
|
|
9
|
-
const s = Object.assign(document.createElement("script"), { src: "/
|
|
10
|
+
const s = Object.assign(document.createElement("script"), { src: "/version/dom-to-image-more.js", onload: () => ok(window.domtoimage), onerror: no });
|
|
10
11
|
document.head.append(s);
|
|
11
12
|
});
|
|
12
13
|
const image = (src) => new Promise((ok, no) => Object.assign(new Image(), { onload() { ok(this); }, onerror: no, src }));
|
|
13
14
|
|
|
14
|
-
async function picture() { // a PNG blob of the
|
|
15
|
-
const top = document.getElementById("top"),
|
|
16
|
-
if (!
|
|
15
|
+
async function picture() { // a PNG blob of the cards, or null if there are none
|
|
16
|
+
const top = document.getElementById("top"), cards = [...top.querySelectorAll("[data-card]")];
|
|
17
|
+
if (!cards.length) return null;
|
|
17
18
|
const domtoimage = await library(), at = top.getBoundingClientRect();
|
|
18
19
|
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 =
|
|
20
|
+
const all = cards.map(box);
|
|
20
21
|
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
22
|
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
|
|
23
|
+
const width = Math.ceil(x1), height = Math.ceil(y1); // the whole main space up to the last card; cropped below
|
|
23
24
|
const bgcolor = getComputedStyle(document.body).backgroundColor;
|
|
24
25
|
const page = await image(await domtoimage.toPng(top, { width, height, bgcolor,
|
|
25
26
|
style: { position: "static", inset: "auto", overflow: "visible", width: `${width}px`, height: `${height}px` } }));
|
|
@@ -44,15 +45,23 @@ let busy = Promise.resolve();
|
|
|
44
45
|
function shoot(sha) { // one picture at a time
|
|
45
46
|
busy = busy.then(async () => {
|
|
46
47
|
const png = await picture();
|
|
47
|
-
if (png) await fetch(`/
|
|
48
|
+
if (png) await fetch(`/version/${sha}/shot`, { method: "PUT", body: png });
|
|
48
49
|
}).catch((e) => console.warn("versions: no picture", e));
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export default async function (page) {
|
|
52
53
|
if (new URLSearchParams(location.search).has("shot")) return; // the old headless view
|
|
53
54
|
page.on("version", (ev) => setTimeout(() => shoot(ev.sha), 2000)); // after the page has redrawn what changed
|
|
55
|
+
fetch("/version/update").then((r) => r.json()).then((u) => {
|
|
56
|
+
if (!u) return;
|
|
57
|
+
const kept = u.conflicts?.length;
|
|
58
|
+
page.notice(u.error ? [`Chalky couldn't update to ${u.release}, so it runs as before. ${u.error}`]
|
|
59
|
+
: [`Updated to ${u.release}: ${u.files} file${u.files === 1 ? "" : "s"} changed.`
|
|
60
|
+
+ (kept ? ` ${kept} kept your change; agent1 is merging the update into ${kept === 1 ? "it" : "them"}.` : "")
|
|
61
|
+
+ " To undo it, go back a version in Versions."]);
|
|
62
|
+
}).catch(() => {});
|
|
54
63
|
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
|
|
64
|
+
const current = (await (await fetch("/version")).json()).find((v) => v.current);
|
|
65
|
+
if (current && !current.shot) setTimeout(() => shoot(current.short), 3000); // once the cards are drawn
|
|
57
66
|
} catch {}
|
|
58
67
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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" (in an installed home,
|
|
5
|
+
// server.ts makes the first one: "Started with Chalky <version>"; it also merges each new release, as a version).
|
|
6
|
+
// - When an agent's turn is over (pi's agent_settled) and either folder changed: commit, author = that agent. The
|
|
7
|
+
// message's first line (the label) says what changed, made here from facts, not from what was said: the card
|
|
8
|
+
// extension's words for the agent's card changes ("showed trip"), then the other changed things named from
|
|
9
|
+
// their paths (NAMES: "new app planner", "changed extension card"), at most 3, then its reload reason, if any.
|
|
10
|
+
// The second line: the first line of what it was asked (detail, not shown in the list).
|
|
11
|
+
// Event "version" to the pages ({agent, sha, message, files, restart: false}: they refresh what changed).
|
|
12
|
+
// - Pictures for the panel: taken by the open page itself (browser.js here, with dom-to-image-more; no
|
|
13
|
+
// headless browser), after each new version and, when a page opens, for the current version if it has none.
|
|
14
|
+
// In data/version/shots/<sha>.png. A version made while no page was open and changed again before one opened: none.
|
|
15
|
+
// - GET /version the user's versions, newest first ({sha, short, author, date, message, current, shot}):
|
|
16
|
+
// host.history.versions(), so not the releases an update merged in
|
|
17
|
+
// GET /version/update this start's update of Chalky (server.ts's host.update), to the first page that
|
|
18
|
+
// asks (browser.js: a notice); later: null. {release, files, conflicts} or {release, error}
|
|
19
|
+
// GET /version/<sha>/shot that commit's picture (404 if none; HEAD too)
|
|
20
|
+
// PUT /version/<sha>/shot the page's picture (PNG, at most 10 MB; {saved: false} if it has one: the first page wins)
|
|
21
|
+
// POST /version/<sha>/rollback that commit's files as a NEW commit (history is never rewritten, so a rollback
|
|
22
|
+
// can be rolled back). The Python agent restarted its process; here: stop every
|
|
23
|
+
// agent, put the files back, reload every agent's extensions (pi's reload), read
|
|
24
|
+
// the cards again, and tell each agent (an fyi: no turn).
|
|
25
|
+
// Guard: agent/ is the agents themselves. If a commit adds other files there, its agent is told where they belong
|
|
26
|
+
// (built/ for what it makes for the user, workspace/ for project work).
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import git from "isomorphic-git";
|
|
30
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
|
|
32
|
+
const host = (globalThis as any).chalky;
|
|
33
|
+
const { repo, stage, restore, versions } = host.history;
|
|
34
|
+
const SHOTS = path.join(host.data, "version", "shots");
|
|
35
|
+
const MAX_SHOT = 10 * 1024 * 1024;
|
|
36
|
+
const who = (name: string) => ({ name, email: `${name}@chalky` });
|
|
37
|
+
|
|
38
|
+
const shared = host.shared.get("version") ?? { queue: Promise.resolve(), turnFrom: {} as Record<string, number>, cards: {} as Record<string, string[]> };
|
|
39
|
+
shared.cards ??= {}; // (shared state made by an older copy of this file, before a reload)
|
|
40
|
+
host.shared.set("version", shared);
|
|
41
|
+
function queued<T>(fn: () => Promise<T>): Promise<T> { // history operations one at a time (agents can finish together)
|
|
42
|
+
const run = shared.queue.then(fn);
|
|
43
|
+
shared.queue = run.catch(() => {});
|
|
44
|
+
return run;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
// The first time (once per start, though every agent loads this): the history starts with both folders as they are.
|
|
49
|
+
// "No commit yet", not "no repo yet": a start stopped between init and the first commit leaves a repo without HEAD.
|
|
50
|
+
if (!shared.creating) shared.creating = queued(async () => {
|
|
51
|
+
try { await git.resolveRef({ ...repo, ref: "HEAD" }); return; } catch {}
|
|
52
|
+
await git.init({ ...repo, defaultBranch: "main" });
|
|
53
|
+
await stage();
|
|
54
|
+
await git.commit({ ...repo, message: "original", author: who("chalky") });
|
|
55
|
+
await git.tag({ ...repo, ref: "original" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const head = async () => { await shared.queue; return git.resolveRef({ ...repo, ref: "HEAD" }); }; // after queued work (e.g. the first commit)
|
|
59
|
+
|
|
60
|
+
async function commit(author: string, words: string | ((changed: string[]) => Promise<string>)) { // the changed files, or [] if nothing changed
|
|
61
|
+
const changed = await stage();
|
|
62
|
+
if (!changed.length) return [];
|
|
63
|
+
const message = typeof words === "string" ? words : await words(changed);
|
|
64
|
+
let sha: string;
|
|
65
|
+
try { sha = await git.commit({ ...repo, message, author: who(author), disallowEmpty: true }); }
|
|
66
|
+
catch (e: any) { if (e.code === "EmptyCommitError") return []; throw e; } // nothing really changed
|
|
67
|
+
host.broadcast("version", { agent: author, sha: sha.slice(0, 7), message: message.split("\n")[0], files: changed, restart: false }); // pages take its picture
|
|
68
|
+
return changed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A version's label: what changed, in words. The things a path is part of (anything else: the path itself):
|
|
72
|
+
const NAMES: [RegExp, string][] = [
|
|
73
|
+
[/^agent\/extensions\/([^/]+)\//, "extension $1"],
|
|
74
|
+
[/^agent\/skills\/([^/]+)\//, "skill $1"],
|
|
75
|
+
[/^agent\/SYSTEM\.md$/, "instructions"],
|
|
76
|
+
[/^agent\/settings\.json$/, "settings"],
|
|
77
|
+
[/^built\/apps\/([^/]+)\//, "app $1"],
|
|
78
|
+
[/^built\/card\.json$/, "cards"], // left out when the card extension's words say what changed
|
|
79
|
+
[/^built\/board\.json$/, "boards"],
|
|
80
|
+
[/^built\//, ""], // another file: its path in built/
|
|
81
|
+
];
|
|
82
|
+
function nameOf(p: string) { // "agent/extensions/card/browser.js" -> "extension card"; "built/notes/a.txt" -> "notes/a.txt"
|
|
83
|
+
for (const [re, name] of NAMES) {
|
|
84
|
+
const m = p.match(re);
|
|
85
|
+
if (m) return name ? m[0].replace(re, name) : p.slice(m[0].length);
|
|
86
|
+
}
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
function describe(files: { path: string, how: "new" | "changed" | "removed" }[], cards: string[], reason?: string) {
|
|
90
|
+
const hows = new Map<string, Set<string>>(); // thing -> how its files changed
|
|
91
|
+
for (const f of files) { const t = nameOf(f.path); if (t !== "cards" || !cards.length) hows.set(t, (hows.get(t) ?? new Set()).add(f.how)); }
|
|
92
|
+
const parts = [...new Set(cards), ...[...hows].map(([t, h]) => `${h.size === 1 ? [...h][0] : "changed"} ${t}`)];
|
|
93
|
+
const label = parts.length > 3 ? `${parts.slice(0, 3).join("; ")} and ${parts.length - 3} more` : parts.join("; ");
|
|
94
|
+
return reason ? `${label}: ${reason.split("\n")[0]}` : label;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const textOf = (m: any) => typeof m.content === "string" ? m.content
|
|
98
|
+
: (m.content ?? []).filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
|
|
99
|
+
|
|
100
|
+
export default function (pi: ExtensionAPI) {
|
|
101
|
+
// the card extension's words for each card change, kept per agent while its turn runs (the user's: not an agent's)
|
|
102
|
+
pi.on("session_start", () => host.api.card?.watch("version", (_id: string, by?: string, what?: string) => {
|
|
103
|
+
if (what && by) shared.cards[by]?.push(what);
|
|
104
|
+
}));
|
|
105
|
+
pi.on("agent_start", (_e, ctx) => {
|
|
106
|
+
const agent = pi.getSessionName()!;
|
|
107
|
+
shared.turnFrom[agent] = ctx.sessionManager.getEntries().length;
|
|
108
|
+
shared.cards[agent] = [];
|
|
109
|
+
});
|
|
110
|
+
pi.on("agent_settled", (_e, ctx) => {
|
|
111
|
+
const agent = pi.getSessionName()!;
|
|
112
|
+
const turn = ctx.sessionManager.getEntries().slice(shared.turnFrom[agent] ?? 0).filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
113
|
+
const reason = host.shared.get(`reload-reason ${agent}`);
|
|
114
|
+
host.shared.delete(`reload-reason ${agent}`);
|
|
115
|
+
const cards = shared.cards[agent] ?? [];
|
|
116
|
+
delete shared.cards[agent];
|
|
117
|
+
const asked = turn.filter((m: any) => m.role === "user").map(textOf)
|
|
118
|
+
.flatMap((t: string) => t.split("\n").map((l) => l.trim())).find((l: string) => l && !/^\[\w+ (from|to) /.test(l)) ?? "";
|
|
119
|
+
return queued(async () => {
|
|
120
|
+
const changed = await commit(agent, async (files) => {
|
|
121
|
+
const before = new Set(await git.listFiles({ ...repo, ref: "HEAD" }));
|
|
122
|
+
const label = describe(files.map((p) => ({ path: p, how: !fs.existsSync(path.join(host.home, p)) ? "removed" : before.has(p) ? "changed" : "new" })), cards, reason);
|
|
123
|
+
return [label || `changes by ${agent}`, asked.slice(0, 200)].filter(Boolean).join("\n\n");
|
|
124
|
+
});
|
|
125
|
+
// files in agent/ that aren't part of an agent, there now (a deleted one is the agent moving it: no new warning)
|
|
126
|
+
const stray = changed.filter((p) => p.startsWith("agent/") && !host.isAgentPart(p.slice("agent/".length)) && fs.existsSync(path.join(host.home, p)));
|
|
127
|
+
if (stray.length && host.agents.has(agent))
|
|
128
|
+
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(", ")}. `
|
|
129
|
+
+ `${host.agent} is you (extensions, skills, SYSTEM.md, mcp.json, settings.json). Move what you make for the user to `
|
|
130
|
+
+ `${host.built}, and project work to ${host.workspace}.`, true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
host.routes.set("/version/update", (_req: any, res: any) => {
|
|
135
|
+
const u = host.update && !host.update.told ? host.update : null;
|
|
136
|
+
if (u) u.told = true;
|
|
137
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(u));
|
|
138
|
+
});
|
|
139
|
+
host.routes.set("/version", async (_req: any, res: any) => {
|
|
140
|
+
const now = await head(); // waits for queued work, so the log is complete
|
|
141
|
+
const out = (await versions()).map(({ oid, commit: c }) => ({ sha: oid, short: oid.slice(0, 7), author: c.author.name,
|
|
142
|
+
date: new Date(c.author.timestamp * 1000).toISOString(), message: c.message.split("\n")[0], current: oid === now,
|
|
143
|
+
shot: fs.existsSync(path.join(SHOTS, `${oid}.png`)) })); // the page takes the current one's picture if it has none
|
|
144
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
host.routes.set("/version/", async (req: any, res: any) => { // /version/<sha>/shot, /version/<sha>/rollback
|
|
148
|
+
const [, , given, what] = new URL(req.url, "http://x").pathname.split("/");
|
|
149
|
+
if (!/^[0-9a-f]{7,40}$/.test(given ?? "")) return res.writeHead(400).end();
|
|
150
|
+
let sha: string;
|
|
151
|
+
try { sha = await git.expandOid({ ...repo, oid: given }); } catch { return res.writeHead(404).end(); }
|
|
152
|
+
const shot = path.join(SHOTS, `${sha}.png`);
|
|
153
|
+
if (what === "shot" && (req.method === "GET" || req.method === "HEAD")) {
|
|
154
|
+
if (!fs.existsSync(shot)) return res.writeHead(404).end();
|
|
155
|
+
if (req.method === "HEAD") return res.writeHead(200, { "content-type": "image/png" }).end();
|
|
156
|
+
return fs.createReadStream(shot).pipe(res.writeHead(200, { "content-type": "image/png" }));
|
|
157
|
+
}
|
|
158
|
+
if (what === "shot" && req.method === "PUT") { // a page's picture of this version
|
|
159
|
+
if (fs.existsSync(shot)) // another page was first: not an error (a 409 would be logged by the browser)
|
|
160
|
+
return res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ saved: false }));
|
|
161
|
+
const chunks: Buffer[] = [];
|
|
162
|
+
let size = 0;
|
|
163
|
+
req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_SHOT) chunks.push(c); });
|
|
164
|
+
req.on("end", () => {
|
|
165
|
+
const png = Buffer.concat(chunks);
|
|
166
|
+
if (size > MAX_SHOT || !png.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return res.writeHead(400).end();
|
|
167
|
+
fs.mkdirSync(SHOTS, { recursive: true });
|
|
168
|
+
fs.writeFileSync(shot + ".tmp", png);
|
|
169
|
+
fs.renameSync(shot + ".tmp", shot);
|
|
170
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ saved: true }));
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (what !== "rollback" || req.method !== "POST") return res.writeHead(404).end();
|
|
175
|
+
const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...repo, oid: sha })).commit.message.split("\n")[0]}`;
|
|
176
|
+
await host.api.team?.stop();
|
|
177
|
+
let files: string[];
|
|
178
|
+
try { files = await queued(async () => {
|
|
179
|
+
await commit("user", "uncommitted changes before rollback"); // nothing is ever lost
|
|
180
|
+
await restore(sha); // that commit's agent/ and built/ (server.ts)
|
|
181
|
+
const files = await stage();
|
|
182
|
+
// git keeps files, not folders: a folder whose files the rollback removed stays, empty (an extension folder
|
|
183
|
+
// without index.ts would show in the map). Remove those, up to the first one that isn't empty.
|
|
184
|
+
for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) {
|
|
185
|
+
try { fs.rmdirSync(path.join(host.home, d)); } catch { break; }
|
|
186
|
+
}
|
|
187
|
+
await git.commit({ ...repo, message: `rollback to ${subject}`, author: who("user") });
|
|
188
|
+
return files;
|
|
189
|
+
}); } catch (e: any) { // an answer, not a request left hanging
|
|
190
|
+
return res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: String(e?.message ?? e) }));
|
|
191
|
+
}
|
|
192
|
+
for (const name of host.agents.keys()) await host.reloadAgent(name);
|
|
193
|
+
if (files.includes("built/card.json")) { host.shared.get("card")?.load(); host.api.history?.add("user", `rolled back to ${subject}`); }
|
|
194
|
+
for (const name of host.agents.keys())
|
|
195
|
+
host.api.team?.fyi("system", name, `The user rolled ${host.agent} and ${host.built} back to version ${subject}. Later changes are undone `
|
|
196
|
+
+ `(still in the history: GET http://127.0.0.1:${host.port}/version). Check before relying on them.`);
|
|
197
|
+
const now = await head();
|
|
198
|
+
host.broadcast("version", { agent: "user", sha: now.slice(0, 7), message: `rollback to ${subject}`, files, restart: false });
|
|
199
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
host.routes.set("/version/dom-to-image-more.js", (_req: any, res: any) =>
|
|
203
|
+
res.writeHead(200, { "content-type": "text/javascript" })
|
|
204
|
+
.end(fs.readFileSync(path.join(host.modules, "dom-to-image-more/dist/dom-to-image-more.min.js"))));
|
|
205
|
+
}
|
|
@@ -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.
|