@chalky/chalky 0.0.0-stage → 0.1.3
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 +46 -2
- package/app/SYSTEM.md +30 -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/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +388 -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 +163 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +76 -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 +245 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +97 -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 +363 -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 +271 -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/mcp.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 +275 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Peer-to-peer swarm (port of the Python swarm plugin, on pi's pieces): no manager; anyone (the user or an agent)
|
|
2
|
+
// can talk to any agent. Every agent is its own pi session (host.createAgent). A message starts a turn, and the
|
|
3
|
+
// turn's final answer goes back to whoever is owed it. Nothing is said "to no one".
|
|
4
|
+
// - Kinds of item: message from the user or the harness; the answer is shown in the chat
|
|
5
|
+
// question ask_agent: the asker waits; the answer is its tool result
|
|
6
|
+
// note tell: the sender goes on; the answer comes back to it as a reply
|
|
7
|
+
// reply the answer to a note (or to a question that timed out)
|
|
8
|
+
// fyi a 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 note from agent1 (working for the user) has
|
|
10
|
+
// chain [agent1, user]; agent2's answer goes to agent1 as a reply with chain [user], and agent1's answer to that
|
|
11
|
+
// reply goes to the user. So replies never bounce back and forth.
|
|
12
|
+
// - Notes, replies and fyis for an agent in the middle of a turn: pi's steer() (it sees them between its tool calls;
|
|
13
|
+
// no turn of their own). An fyi for an idle agent: pi's sendCustomMessage() (filed; the model sees it next turn).
|
|
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, notes per minute, identical notes dropped, ask
|
|
16
|
+
// timeout. Asking an agent that is waiting on you is refused (use tell).
|
|
17
|
+
// Shared state is plain data on the host (host.shared "swarm"); the code that acts on it is this file's newest load
|
|
18
|
+
// (host.api.swarm), so an edited swarm/index.ts takes effect after a reload. Agents: host.agents (role, app, session).
|
|
19
|
+
// Pictures with a message (doodle's steps): send(..., images) takes png paths; they go to pi with the text (prompt
|
|
20
|
+
// or steer), read when the agent gets the message.
|
|
21
|
+
// Not ported: the tasks plugin's "blocked" state.
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { Type } from "typebox";
|
|
26
|
+
|
|
27
|
+
const host = (globalThis as any).chalky;
|
|
28
|
+
const MAX_MESSAGES = Number(process.env.SWARM_MAX_MESSAGES || 50); // ask_agent + tell calls per user message
|
|
29
|
+
const MAX_AGENTS = Number(process.env.SWARM_MAX_AGENTS || 8);
|
|
30
|
+
const ASK_TIMEOUT = Number(process.env.SWARM_ASK_TIMEOUT || 600); // seconds; then the answer comes as a reply
|
|
31
|
+
const INBOX_CAP = 50; // items waiting for one agent
|
|
32
|
+
const NOTES_PER_MINUTE = 20; // per sender
|
|
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"));
|
|
36
|
+
|
|
37
|
+
type Waiter = { resolve: (answer: string) => void } | null;
|
|
38
|
+
type Item = { kind: string; sender: string; text: string; chain: string[]; ts: number; waiter: Waiter; images?: string[] };
|
|
39
|
+
|
|
40
|
+
if (!host.shared.has("swarm"))
|
|
41
|
+
host.shared.set("swarm", { inbox: {}, running: new Set(), chain: {}, waiting: {}, doing: {}, sent: 0, recent: {},
|
|
42
|
+
lastNote: {}, appSent: [] });
|
|
43
|
+
const S = host.shared.get("swarm");
|
|
44
|
+
const inbox = (name: string): Item[] => (S.inbox[name] ??= []);
|
|
45
|
+
const isAgent = (name: string) => host.agents.has(name);
|
|
46
|
+
const target = (chain: string[]) => (chain[0] && isAgent(chain[0]) ? chain[0] : "user");
|
|
47
|
+
|
|
48
|
+
function header(item: Item, to?: string) {
|
|
49
|
+
let h = `[${item.kind} from ${item.sender} · ${new Date(item.ts).toTimeString().slice(0, 8)}]`;
|
|
50
|
+
if (item.kind === "question") h += " (they are waiting for your answer)";
|
|
51
|
+
if (item.kind === "reply" && to) h += ` (your final answer goes to ${to})`;
|
|
52
|
+
return h;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function changed(name?: string, doing?: string) {
|
|
56
|
+
if (name && doing !== undefined) S.doing[name] = [doing, Date.now() / 1000];
|
|
57
|
+
host.broadcast("status", { agents: status() });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// png paths -> pi's image content (a file gone since is left out)
|
|
61
|
+
const pictures = (paths: string[] = []) => paths.filter((p) => fs.existsSync(p))
|
|
62
|
+
.map((p) => ({ type: "image" as const, data: fs.readFileSync(p).toString("base64"), mimeType: "image/png" }));
|
|
63
|
+
|
|
64
|
+
function put(to: string, kind: string, sender: string, text: string, chain: string[], waiter: Waiter = null, images?: string[]) {
|
|
65
|
+
const item: Item = { kind, sender, text, chain, ts: Date.now(), waiter, images };
|
|
66
|
+
const session = host.agents.get(to).session;
|
|
67
|
+
if (["note", "reply", "fyi"].includes(kind) && session.isStreaming) void session.steer(`${header(item)}\n${text}`, pictures(images));
|
|
68
|
+
else if (kind === "fyi") // its turn ended before it was handed over: file it, don't start a turn
|
|
69
|
+
void session.sendCustomMessage({ customType: "inbox", content: `${header(item)}\n${text}`, display: true });
|
|
70
|
+
else {
|
|
71
|
+
inbox(to).push(item);
|
|
72
|
+
if (!S.running.has(to)) void work(to);
|
|
73
|
+
}
|
|
74
|
+
changed();
|
|
75
|
+
return item;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One item -> one turn -> one answer, in order, while the inbox has items
|
|
79
|
+
async function work(name: string) {
|
|
80
|
+
S.running.add(name);
|
|
81
|
+
changed(name, "thinking");
|
|
82
|
+
try {
|
|
83
|
+
while (inbox(name).length) {
|
|
84
|
+
const item = inbox(name).shift()!;
|
|
85
|
+
const to = target(item.chain);
|
|
86
|
+
S.chain[name] = item.chain;
|
|
87
|
+
const answer = await host.prompt(name, `${header(item, to)}\n${item.text}`, to, pictures(item.images));
|
|
88
|
+
delete S.chain[name];
|
|
89
|
+
if (item.waiter) item.waiter.resolve(answer); // a question: the asker is waiting
|
|
90
|
+
else if (to !== "user" && to !== name && answer.trim()) put(to, "reply", name, answer, item.chain.slice(1));
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
S.running.delete(name);
|
|
94
|
+
changed(name, "idle");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function status() {
|
|
99
|
+
const out: Record<string, any> = {};
|
|
100
|
+
for (const a of host.agents.values()) {
|
|
101
|
+
const [doing, since] = S.doing[a.name] ?? ["idle", Date.now() / 1000];
|
|
102
|
+
const waiting = [...(S.waiting[a.name] ?? [])].sort();
|
|
103
|
+
const busy = S.running.has(a.name);
|
|
104
|
+
out[a.name] = { role: a.role, app: a.app ?? null, state: waiting.length ? `waiting on ${waiting.join(", ")}` : busy ? "working" : "idle",
|
|
105
|
+
doing: busy ? doing : "", since, queued: inbox(a.name).length };
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const owed = (name: string) => { const c = S.chain[name]; return c?.[0] && isAgent(c[0]) ? c[0] : null; };
|
|
111
|
+
|
|
112
|
+
function waitsOn(a: string, b: string) { // is agent a waiting on agent b, directly or through others?
|
|
113
|
+
const seen = new Set<string>(), todo = [a];
|
|
114
|
+
while (todo.length)
|
|
115
|
+
for (const x of S.waiting[todo.pop()!] ?? []) {
|
|
116
|
+
if (x === b) return true;
|
|
117
|
+
if (!seen.has(x)) { seen.add(x); todo.push(x); }
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ask(asker: string, to: string, text: string, signal?: AbortSignal) {
|
|
123
|
+
(S.waiting[asker] ??= new Set()).add(to);
|
|
124
|
+
changed();
|
|
125
|
+
return new Promise<string>((resolve) => {
|
|
126
|
+
const done = (answer: string) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
S.waiting[asker]?.delete(to);
|
|
129
|
+
changed();
|
|
130
|
+
resolve(answer);
|
|
131
|
+
};
|
|
132
|
+
const item = put(to, "question", asker, text, [asker, ...(S.chain[asker] ?? [])], { resolve: done });
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
item.waiter = null; // the answer will come as a reply instead
|
|
135
|
+
done(`error: no answer from ${to} within ${ASK_TIMEOUT} s. It is still working on it; its answer will come to you `
|
|
136
|
+
+ "as a reply. Continue with something else.");
|
|
137
|
+
}, ASK_TIMEOUT * 1000);
|
|
138
|
+
signal?.addEventListener("abort", () => { item.waiter = null; done("error: stopped"); });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const api = {
|
|
143
|
+
status,
|
|
144
|
+
// From the user or the harness (the page's chat, apps, ui actions). The answer is shown in the chat.
|
|
145
|
+
// Returns the item (for withdraw). images: png paths sent with it.
|
|
146
|
+
send(sender: string, to: string, text: string, note = false, images?: string[]) {
|
|
147
|
+
if (sender === "user") S.sent = 0; // a new user message gives the team a fresh budget
|
|
148
|
+
return put(to, note ? "note" : "message", sender, text, [sender], null, images);
|
|
149
|
+
},
|
|
150
|
+
// An update for an agent that doesn't start a turn (filed if idle, between its tool calls if busy)
|
|
151
|
+
fyi(sender: string, to: string, text: string) { return put(to, "fyi", sender, text, [sender]); },
|
|
152
|
+
// Take back an item that is still waiting in an inbox: true if it was there (its agent never saw it)
|
|
153
|
+
withdraw(item: Item) {
|
|
154
|
+
for (const items of Object.values(S.inbox) as Item[][]) {
|
|
155
|
+
const i = items.indexOf(item);
|
|
156
|
+
if (i >= 0) { items.splice(i, 1); changed(); return true; }
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
},
|
|
160
|
+
// Drop all queued items and stop every running turn; agents stay, idle
|
|
161
|
+
async stop() {
|
|
162
|
+
for (const name of Object.keys(S.inbox))
|
|
163
|
+
for (const item of S.inbox[name].splice(0)) item.waiter?.resolve(`error: ${name} was stopped before replying`);
|
|
164
|
+
await Promise.all([...host.agents.keys()].map((n: string) => host.abort(n)));
|
|
165
|
+
for (const k of Object.keys(S.waiting)) delete S.waiting[k];
|
|
166
|
+
changed();
|
|
167
|
+
host.broadcast("stopped", { agent: "all" });
|
|
168
|
+
},
|
|
169
|
+
// Empty these agents' memories (roles kept): a new pi session each. An error string, or null when done.
|
|
170
|
+
async reset(names: string[]) {
|
|
171
|
+
const busy = names.filter((n) => S.running.has(n) || S.waiting[n]?.size || inbox(n).length);
|
|
172
|
+
if (busy.length) return `error: ${busy.join(", ")} still working or with messages waiting. Reset when they are idle.`;
|
|
173
|
+
for (const n of names) { const a = host.agents.get(n); await host.createAgent(n, a.role, a.app, true); }
|
|
174
|
+
return null;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
host.api.swarm = api;
|
|
178
|
+
|
|
179
|
+
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
|
|
180
|
+
|
|
181
|
+
export default function (pi: ExtensionAPI) {
|
|
182
|
+
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
|
+
// What each agent is doing right now, for status
|
|
189
|
+
pi.on("tool_execution_start", (ev) => {
|
|
190
|
+
const args = ev.args ?? {};
|
|
191
|
+
changed(me(), `${ev.toolName} ${String(args.command ?? args.to ?? args.surface ?? args.path ?? "").slice(0, 60)}`.trim());
|
|
192
|
+
});
|
|
193
|
+
pi.on("turn_start", () => changed(me(), "thinking"));
|
|
194
|
+
|
|
195
|
+
const checkTo = (agent: string, to: string) => {
|
|
196
|
+
if (!isAgent(to)) return `error: no agent '${to}'. Agents: ${[...host.agents.keys()].join(", ")}`;
|
|
197
|
+
if (to === agent) return "error: that is you";
|
|
198
|
+
if (S.sent >= MAX_MESSAGES) return `error: message budget (${MAX_MESSAGES} asks/tells per user message) used up. Finish with what you have.`;
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
pi.registerTool({
|
|
203
|
+
name: "spawn_agent", label: "Spawn agent",
|
|
204
|
+
description: "Create a new peer agent with a role. It starts idle. Agents are numbered in creation order (agent1, agent2, ...); "
|
|
205
|
+
+ "the result tells you the new name.",
|
|
206
|
+
parameters: Type.Object({
|
|
207
|
+
role: Type.String(),
|
|
208
|
+
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 surface of the app this agent is part of (a simulation's member, a "
|
|
210
|
+
+ "character). Leave out for agents that work with you." })),
|
|
211
|
+
}),
|
|
212
|
+
async execute(_id, a) {
|
|
213
|
+
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 (list_agents).`);
|
|
215
|
+
let name = a.name, n = host.agents.size + 1;
|
|
216
|
+
while (!name || isAgent(name)) name = `agent${n++}`; // next free number
|
|
217
|
+
await host.createAgent(name, a.role, a.app);
|
|
218
|
+
changed(name, "idle");
|
|
219
|
+
return result(`created agent '${name}'` + (a.app ? `, part of app '${a.app}'` : "") + ". It is idle until someone messages it.");
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.registerTool({
|
|
224
|
+
name: "reset_agent", label: "Reset agent",
|
|
225
|
+
description: "Empty an agent's memory, keeping its role (for a new round that starts fresh). Give a name, or app to reset "
|
|
226
|
+
+ "all agents of that app. Only when they are idle.",
|
|
227
|
+
parameters: Type.Object({ name: Type.Optional(Type.String()), app: Type.Optional(Type.String()) }),
|
|
228
|
+
async execute(_id, a) {
|
|
229
|
+
const names = [...host.agents.values()].filter((x: any) => x.name === a.name || (a.app && x.app === a.app)).map((x: any) => x.name);
|
|
230
|
+
if (!names.length) return result(a.name ? `error: no agent '${a.name}'` : `error: no agents in app '${a.app}'`);
|
|
231
|
+
if (names.includes(me())) return result("error: you can't reset yourself");
|
|
232
|
+
const error = await api.reset(names);
|
|
233
|
+
return result(error ?? `reset ${names.join(", ")}: each starts its next turn with an empty memory and the same role.`);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.registerTool({
|
|
238
|
+
name: "tell", label: "Tell",
|
|
239
|
+
description: "Send a note to another agent and keep working (don't wait). Its answer comes back to you later as a reply.",
|
|
240
|
+
parameters: Type.Object({ to: Type.String(), text: Type.String() }),
|
|
241
|
+
async execute(_id, a) {
|
|
242
|
+
const agent = me(), now = Date.now();
|
|
243
|
+
const error = checkTo(agent, a.to);
|
|
244
|
+
if (error) return result(error);
|
|
245
|
+
const recent = (S.recent[agent] ?? []).filter((t: number) => now - t < 60000);
|
|
246
|
+
if (S.lastNote[`${agent}>${a.to}`] === a.text) return result(`error: dropped, same as your last note to ${a.to}. It already has it.`);
|
|
247
|
+
if (recent.length >= NOTES_PER_MINUTE) return result(`error: slow down: at most ${NOTES_PER_MINUTE} notes a minute. Wait for replies instead.`);
|
|
248
|
+
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
|
+
S.recent[agent] = [...recent, now];
|
|
250
|
+
S.lastNote[`${agent}>${a.to}`] = a.text;
|
|
251
|
+
S.sent++;
|
|
252
|
+
if (owed(agent) === a.to) { // it's owed your final answer anyway: an update, no answer back
|
|
253
|
+
put(a.to, "fyi", agent, a.text, []);
|
|
254
|
+
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 ask_agent.`);
|
|
256
|
+
}
|
|
257
|
+
put(a.to, "note", agent, a.text, [agent, ...(S.chain[agent] ?? [])]);
|
|
258
|
+
return result(`sent. ${a.to} is ${status()[a.to].state}; its answer will come to you as a reply. `
|
|
259
|
+
+ "If all you have left is waiting, end your turn: the reply wakes you.");
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
pi.registerTool({
|
|
264
|
+
name: "ask_agent", label: "Ask agent",
|
|
265
|
+
description: "Ask another agent and wait for its answer (the result). For quick questions; use tell for longer work. "
|
|
266
|
+
+ "Several ask_agent calls in one step run in parallel.",
|
|
267
|
+
parameters: Type.Object({ to: Type.String(), text: Type.String() }),
|
|
268
|
+
async execute(_id, a, signal) {
|
|
269
|
+
const agent = me();
|
|
270
|
+
const error = checkTo(agent, a.to);
|
|
271
|
+
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 tell, or answer from what you know.`);
|
|
273
|
+
S.sent++;
|
|
274
|
+
return result(await ask(agent, a.to, a.text, signal));
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
pi.registerTool({
|
|
279
|
+
name: "list_agents", label: "List agents", description: "List all agents: role, and what each is doing right now.",
|
|
280
|
+
parameters: Type.Object({}),
|
|
281
|
+
async execute() {
|
|
282
|
+
const now = Date.now() / 1000;
|
|
283
|
+
return result(Object.entries(status()).map(([n, s]: any) => `${n} (${s.role}): ${s.state}`
|
|
284
|
+
+ (s.doing ? `, doing: ${s.doing} for ${Math.round(now - s.since)} s` : "")
|
|
285
|
+
+ (s.queued ? `, ${s.queued} waiting in its inbox` : "")).join("\n"));
|
|
286
|
+
},
|
|
287
|
+
});
|
|
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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Showing UI to the user (the ui extension)
|
|
2
|
+
Show forms, tables, dashboards or games as UI surfaces: cards in the page's main space, described as JSON.
|
|
3
|
+
Prefer a surface over editing the page ({code}/extensions/page/page.html). Aim for one screen; make it scroll only if the user asks.
|
|
4
|
+
A surface shows only what you build, with no frame: when asked for a thing (a smiley, a chart), show just
|
|
5
|
+
that thing. Add a background, border or Card only if the thing itself needs one. An app's page should keep
|
|
6
|
+
a transparent background, so it sits on the workspace.
|
|
7
|
+
Name what you build (a clear surface id, ids or data-name on elements): the user points at things by name.
|
|
8
|
+
Compose from existing components; write a new component only if none fits.
|
|
9
|
+
Before building or restyling what the user sees, load the ui-design skill (how it should look and read).
|
|
10
|
+
- ui_catalog: lists the components and their props. Read it first.
|
|
11
|
+
- ui_render(surface, components, data): draw or replace a surface. components is a flat list; each has
|
|
12
|
+
"id", "component" (a catalog name), its props, and "children": [ids] or "child": id. One has id "root".
|
|
13
|
+
A prop can be {"path": "/x"} to bind it to this surface's data (inputs write to it), or
|
|
14
|
+
{"ref": "other-surface", "path": "/x"} to read another surface's data live (no copy: it follows changes).
|
|
15
|
+
Errors come back to you; fix and retry.
|
|
16
|
+
- about="...": one or two sentences on what the surface is for and why it is built this way. The user reads
|
|
17
|
+
it on the surface's back side; you read it in {code}/ui/surfaces.json. Keep it true when you change things.
|
|
18
|
+
- options=[{"label", "components", "data"}, ...] instead of components: when you are not sure what the user
|
|
19
|
+
meant, offer 2-3 versions; they flip through them and keep one.
|
|
20
|
+
- Pinned surfaces (the user held their label): your ui_render, ui_update and ui_delete on them fail. Don't
|
|
21
|
+
work around it: make a new surface (made_from=[the pinned one]) or ask the user.
|
|
22
|
+
- Surfaces stack in the main space and are as wide as their content. place={"x", "y", "width"?, "height"?}
|
|
23
|
+
(px from the main space's top-left) pins one at a spot instead: use it when the user points at a spot
|
|
24
|
+
("here") or draws an area (then fill that area: its x, y, width and height).
|
|
25
|
+
- Everything you show is draggable by default: the user moves a surface by its label (shown on hover), and
|
|
26
|
+
it stays where they put it (ui_render without place keeps it there; where things are now: the "place" of
|
|
27
|
+
each surface in {code}/ui/surfaces.json). Pass draggable=false only when the
|
|
28
|
+
user wants something fixed. In an app you build, make separate pieces draggable too, unless told otherwise.
|
|
29
|
+
- Everything you show is resizable by default too: the user drags its bottom-right corner (shown on hover),
|
|
30
|
+
and the surface's root component fills the new size. So build things that fill their space (sizes in %,
|
|
31
|
+
flex, or scaled to their box, e.g. an SVG with a viewBox), not fixed px. Pass resizable=false only when the
|
|
32
|
+
user wants a fixed size.
|
|
33
|
+
- ui_update(surface, path, value): change data only (fast; components keep their state). ui_delete(surface).
|
|
34
|
+
- Data (what users typed, rows, scores) is kept in {data}/ui-data.json (not versioned); the rest of each
|
|
35
|
+
surface in {code}/ui/surfaces.json. The user can undo any change (theirs or yours), so don't keep backups.
|
|
36
|
+
- When the user clicks a Button you get "[ui action] <name> ..." with the surface's data.
|
|
37
|
+
- Voice input (dictation, meeting notes): import /ui/voice.js in the app (read its header), never the
|
|
38
|
+
browser's speech API or a provider directly: it uses the sandbox's speech-to-text API, or the browser's.
|
|
39
|
+
- A new component: {code}/extensions/ui/components/<Name>.js. Start with a comment line "// Name: what it does" (more
|
|
40
|
+
comment lines if needed), then its about block, then
|
|
41
|
+
`export default class extends HTMLElement { update(props, ui) {...} }`.
|
|
42
|
+
The about block of a component (strict JSON after `export const about = `):
|
|
43
|
+
"version": 1 the shape of its props; raise it when they change, and export
|
|
44
|
+
upgrade(props, fromVersion) -> props, so surfaces saved earlier still draw
|
|
45
|
+
"props": {"name": {"type": "text" | "number" | "boolean" | "choice" | "list" | "rows" | "object" | "action",
|
|
46
|
+
"options": [...] (for choice), "default": ..., "doc": "what it is"}}
|
|
47
|
+
"children": "none" | "one" | "many"
|
|
48
|
+
"levers": ["Shown as {variant}."] the back side: plain sentences; each {prop} becomes a control the user
|
|
49
|
+
can change there (choice and boolean cycle, text and number are typed)
|
|
50
|
+
"accepts": "rows with a number column" optional: what it can take when something is dropped on it
|
|
51
|
+
"example": {...props} one sample, for the catalog and for checks
|
|
52
|
+
When you change a component's props in a way old surfaces would break on, raise "version" and export
|
|
53
|
+
upgrade(props, fromVersion) returning props in the new shape.
|
|
54
|
+
update() runs on every change, on the same element: keep internal state there (e.g. a game loop).
|
|
55
|
+
ui.children: child elements, placed with ui.mount(container); ui.set(prop, value) writes a bound prop;
|
|
56
|
+
ui.action(name, context) sends an action to you. Make it general so it can be reused. New components work
|
|
57
|
+
immediately; edited components and App files show when your turn ends (the page refreshes itself). Big multi-file apps: {code}/ui/apps/<name>/, shown with App.
|
|
58
|
+
Pointing inside a canvas: give the element (or an App page's window) a partAt(x, y) function returning
|
|
59
|
+
{"name", ...} for what is at that spot, so the user can point at parts (an enemy, a cell).
|
|
60
|
+
- Agents inside what you build (a simulation's members, a debate, characters): load the agent-apps
|
|
61
|
+
skill first. They are real agents that belong to the app, not workers helping you build it.
|
|
62
|
+
- Libraries: components import them by name (import { marked } from "marked"), as mapped in the import map
|
|
63
|
+
at the top of {code}/extensions/page/page.html; installed ones are in {code}/extensions/ui/package.json. To add
|
|
64
|
+
one, download its ES-module build into {code}/extensions/ui/node_modules/<pkg>/ and add a line to the import map.
|
|
65
|
+
- Component pitfalls (you can't see the browser, so avoid them up front):
|
|
66
|
+
keep your own state in one object, this.state = {...}: fields like this.dir, this.title, this.hidden,
|
|
67
|
+
this.id, this.style, this.lang are built-in HTMLElement properties and silently break;
|
|
68
|
+
set a canvas's width/height BEFORE drawing (resizing clears it); don't create things twice in update();
|
|
69
|
+
remove document-level listeners/timers when the element is no longer connected.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Bridge from an app ({code}/ui/apps/<name>/, shown by the App component) to the agents. An app imports it:
|
|
2
|
+
// import agents from "/ui/agents.js";
|
|
3
|
+
// agents.send(to, text) a message to an agent, from "app"; its answer comes as a "message" event
|
|
4
|
+
// (role "assistant", no tool_calls). Each one is a model call: limited per minute.
|
|
5
|
+
// agents.action(name, context) an action for the agent that owns this app's surface, like a Button
|
|
6
|
+
// agents.on(type, fn) events as the page gets them, past ones first (replayed on connect), then live:
|
|
7
|
+
// "message" {agent, role, content, tool_calls, ts}: every message of every agent,
|
|
8
|
+
// incl. its ask_agent / tell calls (who talks to whom); "status" {agents}: who is busy
|
|
9
|
+
// agents.list() {name: {role, app, state, doing, queued}}, from the latest "status" event
|
|
10
|
+
// agents.reset() empty the memories of this app's agents (spawned with app=<this surface>),
|
|
11
|
+
// roles kept: a fresh round. Refused (an "error" event) while one is busy.
|
|
12
|
+
// agents.load() this app's saved data ({} at first), from the appdata plugin (on the server)
|
|
13
|
+
// agents.save(data) replace it; every page and app frame then gets "app_data" {app, data}, so
|
|
14
|
+
// the same app in other surfaces updates (check ev.app === agents.app)
|
|
15
|
+
// agents.app this app's name: its folder in {code}/ui/apps
|
|
16
|
+
// It opens its own WebSocket to /ws, the page's protocol (the page extension).
|
|
17
|
+
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
|
|
18
|
+
const open = new Promise((resolve) => ws.addEventListener("open", resolve));
|
|
19
|
+
const listeners = {};
|
|
20
|
+
let status = {};
|
|
21
|
+
|
|
22
|
+
ws.onmessage = (e) => {
|
|
23
|
+
const ev = JSON.parse(e.data);
|
|
24
|
+
if (ev.type === "status") status = ev.agents;
|
|
25
|
+
for (const fn of listeners[ev.type] || []) fn(ev);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// In the versions plugin's screenshot (/?shot) the app is only looked at: send nothing, no model calls.
|
|
29
|
+
const shot = new URLSearchParams(window.top.location.search).has("shot");
|
|
30
|
+
const post = async (obj) => { if (shot) return; await open; ws.send(JSON.stringify(obj)); };
|
|
31
|
+
const app = location.pathname.split("/")[3]; // /ui/apps/<name>/...
|
|
32
|
+
const store = `/ui-api/app-data/${app}`;
|
|
33
|
+
// the surface this app is shown in (the App component's frame sits inside it on the page)
|
|
34
|
+
const surface = () => window.frameElement?.closest("[data-surface]")?.dataset.surface;
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
send: (to, text) => post({ type: "app_message", to, text }),
|
|
38
|
+
action: (name, context = {}) => post({ type: "ui_action", surface: surface(), component: "app", name, context }),
|
|
39
|
+
on: (type, fn) => (listeners[type] ??= []).push(fn),
|
|
40
|
+
list: () => status,
|
|
41
|
+
reset: () => post({ type: "app_reset", surface: surface() }),
|
|
42
|
+
app,
|
|
43
|
+
load: async () => (await fetch(store, { cache: "no-store" })).json(),
|
|
44
|
+
save: async (data) => { if (!shot) await fetch(store, { method: "PUT", body: JSON.stringify(data) }); },
|
|
45
|
+
};
|