@chalky/chalky 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ // Login: connect models from the page and pick one (browser.js: /login, /logout, /model). pi's own login does the work, the call its
2
+ // terminal /login makes (modelRuntime.login): a sign-in (OAuth: pi opens the provider's page and waits on a callback
3
+ // server, or asks for the address to be pasted), or a custom endpoint (any OpenAI-, Anthropic- or Google-compatible
4
+ // URL, its model names, an optional key). pi renews sign-ins itself; an expired one shows as pi's own error.
5
+ // Where things go: tokens and keys in app/auth.json (pi's; never versioned), custom endpoints in app/models.json
6
+ // (pi's; no secrets), this install's device id (OpenAI's sign-in asks for one) and the model choices in state/login.json:
7
+ // {deviceId, default: {provider, id} (all agents), agents: {name: {provider, id}} (one agent's own; it wins)}.
8
+ // server.ts asks host.api.login.modelFor(name, session) when it starts an agent: its own choice, else the all-agents
9
+ // one, else nothing (then the MODEL_BASE_URL endpoint, extensions/model); a choice whose login is gone is skipped.
10
+ // CHALKY_LOGIN=off (a cloud deployment, where the backend fixes the endpoint): none of this loads, and server.ts doesn't ask.
11
+ // GET /login/on 204 (so the page knows login is on)
12
+ // GET /login/models {groups: [{provider, name, models: [{id, name, context}]}] (usable now), default, agents: [{name, model, own}]}
13
+ // GET /login {providers: [{id, name, subscription, status}], custom: [{id, baseUrl, api, models, status}]}
14
+ // status: "connected" | "expired" | undefined; each call checks the stored sign-ins (pi renews one that is due)
15
+ // page messages login_start {provider}, login_answer {text}, login_cancel,
16
+ // login_custom {id, api, baseUrl, key?, models: [names]}, login_list_models {baseUrl, api, key?, id?},
17
+ // logout {provider}, model_set {for: "all" | <agent>, provider?, id?} (for an agent, no provider: back to all agents')
18
+ // event "login" {step: "notify", event} (pi: open this link / enter this code / waiting) | {step: "prompt", prompt}
19
+ // (pi asks: the page answers with login_answer) | {step: "prompt_done"} (pi stopped asking: the
20
+ // callback came first) | {step: "done", provider} | {step: "cancelled"} | {step: "error", text} | {step: "models", models}
21
+ // (login_list_models' answer) | {step: "changed"} (a login, endpoint or model changed: agents are synced)
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { randomUUID } from "node:crypto";
25
+ import { ModelRuntime, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+
27
+ const host = (globalThis as any).chalky;
28
+ const MODELS = path.join(host.app, "models.json");
29
+ const STATE = path.join(host.state, "login.json");
30
+ const APIS = ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"];
31
+
32
+ // one runtime for logins, on the same files as the agents' (each agent's session has its own: synced after a change)
33
+ if (!host.shared.has("login-runtime"))
34
+ host.shared.set("login-runtime", ModelRuntime.create({ authPath: path.join(host.app, "auth.json"), modelsPath: MODELS }));
35
+ const runtime = (): Promise<ModelRuntime> => host.shared.get("login-runtime");
36
+
37
+ const readModels = () => (fs.existsSync(MODELS) ? JSON.parse(fs.readFileSync(MODELS, "utf8")) : { providers: {} });
38
+ const readState = () => (fs.existsSync(STATE) ? JSON.parse(fs.readFileSync(STATE, "utf8")) : {});
39
+ const writeState = (s: any) => fs.writeFileSync(STATE, JSON.stringify(s, null, 2) + "\n");
40
+ function deviceId() {
41
+ const s = readState();
42
+ if (!s.deviceId) writeState({ ...s, deviceId: (s.deviceId = randomUUID()) });
43
+ return s.deviceId;
44
+ }
45
+
46
+ // the model an agent should use: its own choice, else the all-agents one; skipped when its login is gone
47
+ function modelFor(name: string, session: any) {
48
+ const s = readState(), rt = session.modelRuntime;
49
+ for (const c of [s.agents?.[name], s.default])
50
+ if (c && rt.hasConfiguredAuth(c.provider)) { const m = rt.getModel(c.provider, c.id); if (m) return m; }
51
+ return undefined;
52
+ }
53
+ const fallback = (session: any) => session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default"); // extensions/model
54
+ async function apply(a: any) { // put an agent on the model it should use now (after a choice, a login or a logout)
55
+ const m = modelFor(a.name, a.session) ?? fallback(a.session);
56
+ const cur = a.session.model;
57
+ if (m && !(cur && cur.provider === m.provider && cur.id === m.id)) await a.session.setModel(m);
58
+ }
59
+ host.api.login = { modelFor };
60
+
61
+ async function status(rt: ModelRuntime, id: string) {
62
+ if (!rt.getProviderAuthStatus(id).configured) return undefined;
63
+ try { return (await rt.getAuth(id)) ? "connected" : undefined; } catch { return "expired"; } // getAuth renews a sign-in that is due
64
+ }
65
+
66
+ async function synced() { // a login or endpoint changed: every runtime reads the files again (offline)
67
+ await (await runtime()).refresh();
68
+ for (const a of host.agents.values()) { await a.session.modelRuntime.refresh(); await apply(a); }
69
+ host.broadcast("login", { step: "changed" });
70
+ }
71
+
72
+ const fail = (e: any) => host.broadcast("login", { step: "error", text: e?.message ?? String(e) });
73
+
74
+ export default function (pi: ExtensionAPI) {
75
+ if (process.env.CHALKY_LOGIN === "off") return;
76
+
77
+ // at start, once: renew the sign-ins that are due, and say which failed (server log)
78
+ pi.on("session_start", async () => {
79
+ if (host.shared.has("login-checked")) return;
80
+ host.shared.set("login-checked", true);
81
+ const rt = await runtime();
82
+ for (const c of await rt.listCredentials())
83
+ if (c.type === "oauth" && (await status(rt, c.providerId)) === "expired") console.error(`login: the ${c.providerId} sign-in has expired (/login)`);
84
+ });
85
+
86
+ host.routes.set("/login/on", (_req: any, res: any) => res.writeHead(204).end()); // browser.js: add the commands only when this answers
87
+ host.routes.set("/login/models", (_req: any, res: any) => models(res).catch((e) =>
88
+ res.writeHead(500, { "content-type": "text/plain" }).end(String(e?.message ?? e))));
89
+ async function models(res: any) { // read through an agent's runtime: it also has the MODEL_BASE_URL endpoint ("chalky")
90
+ const agents = [...host.agents.values()], s = readState();
91
+ const rt = agents[0]?.session.modelRuntime;
92
+ const groups = new Map<string, any>();
93
+ for (const m of rt ? await rt.getAvailable() : []) {
94
+ if (!groups.has(m.provider)) groups.set(m.provider, { provider: m.provider, name: m.provider === "chalky" ? "Chalky LiteLLM (MODEL_BASE_URL)"
95
+ : (readModels().providers[m.provider] ? `${m.provider} (custom)` : rt.getProvider(m.provider)?.name ?? m.provider), models: [] });
96
+ groups.get(m.provider).models.push({ id: m.id, name: m.name, context: m.contextWindow });
97
+ }
98
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
99
+ groups: [...groups.values()], default: s.default ?? null,
100
+ agents: agents.filter((a) => !a.app).map((a) => ({ name: a.name, model: a.session.model ? { provider: a.session.model.provider, id: a.session.model.id } : null,
101
+ own: !!s.agents?.[a.name] })) }));
102
+ }
103
+
104
+ host.routes.set("/login", (_req: any, res: any) => list(res).catch((e) =>
105
+ res.writeHead(500, { "content-type": "text/plain" }).end(String(e?.message ?? e))));
106
+ async function list(res: any) {
107
+ const rt = await runtime();
108
+ await rt.refresh();
109
+ const custom = readModels().providers;
110
+ const providers = await Promise.all(rt.getProviders().filter((p) => p.auth.oauth && !(p.id in custom)).map(async (p) =>
111
+ ({ id: p.id, name: p.name, subscription: p.auth.oauth!.isSubscription === true, status: await status(rt, p.id) })));
112
+ const list = await Promise.all(Object.entries(custom).map(async ([id, p]: any) =>
113
+ ({ id, baseUrl: p.baseUrl, api: p.api, models: (p.models ?? []).map((m: any) => m.id), status: await status(rt, id) })));
114
+ res.writeHead(200, { "content-type": "application/json" })
115
+ .end(JSON.stringify({ providers: providers.sort((a, b) => a.name.localeCompare(b.name)), custom: list }));
116
+ }
117
+
118
+ host.pageHandlers.set("login", async (msg: any) => {
119
+ const flow = host.shared.get("login-flow"); // the sign-in under way: {abort, answer?}
120
+ if (msg.type === "login_answer") return flow?.answer?.(String(msg.text ?? ""));
121
+ if (msg.type === "login_cancel") return flow?.abort.abort();
122
+
123
+ if (msg.type === "login_start") {
124
+ flow?.abort.abort(); // one at a time
125
+ const abort = new AbortController(), me: any = { abort };
126
+ host.shared.set("login-flow", me);
127
+ try {
128
+ await (await runtime()).login(String(msg.provider), "oauth", {
129
+ signal: abort.signal,
130
+ notify: (event) => host.broadcast("login", { step: "notify", event }),
131
+ prompt: ({ signal, ...prompt }) => new Promise((resolve, reject) => {
132
+ const signals = [signal, abort.signal].filter((s) => s) as AbortSignal[];
133
+ const end = () => { me.answer = undefined; for (const s of signals) s.removeEventListener("abort", stop); };
134
+ me.answer = (text: string) => { end(); resolve(text); };
135
+ const stop = () => { end(); host.broadcast("login", { step: "prompt_done" }); reject(new Error("Login cancelled")); };
136
+ for (const s of signals) s.addEventListener("abort", stop, { once: true });
137
+ host.broadcast("login", { step: "prompt", prompt });
138
+ }),
139
+ }, { getDeviceId: deviceId });
140
+ await synced();
141
+ host.broadcast("login", { step: "done", provider: msg.provider });
142
+ } catch (e) { if (abort.signal.aborted) host.broadcast("login", { step: "cancelled" }); else fail(e); } // ✕: not an error
143
+ finally { if (host.shared.get("login-flow") === me) host.shared.delete("login-flow"); }
144
+ }
145
+
146
+ if (msg.type === "login_custom") {
147
+ const id = String(msg.id ?? "").trim(), baseUrl = String(msg.baseUrl ?? "").trim().replace(/\/+$/, "");
148
+ const models = [...new Set<string>((msg.models ?? []).map((m: any) => String(m).trim()).filter(Boolean))];
149
+ const rt = await runtime(), saved = readModels();
150
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return fail("The name can have letters, digits, '.', '_' and '-' only");
151
+ if (rt.getProvider(id) && !(id in saved.providers)) return fail(`'${id}' is one of pi's own providers: pick another name`);
152
+ if (!/^https?:\/\//.test(baseUrl)) return fail("The base URL starts with http:// or https://");
153
+ if (!APIS.includes(msg.api)) return fail(`Speaks: one of ${APIS.join(", ")}`);
154
+ if (!models.length) return fail("Add at least one model name");
155
+ const key = String(msg.key ?? "").trim();
156
+ const stored = (await rt.listCredentials()).some((c) => c.providerId === id);
157
+ // no key and none stored: a placeholder, so pi counts the endpoint usable (pi's docs: Ollama ignores it)
158
+ saved.providers[id] = { baseUrl, api: msg.api, ...(key || stored ? {} : { apiKey: "none" }), models: models.map((m) => ({ id: m })) };
159
+ fs.writeFileSync(MODELS, JSON.stringify(saved, null, 2) + "\n");
160
+ try {
161
+ await rt.refresh();
162
+ if (key) await rt.login(id, "api_key", { prompt: async () => key, notify() {} }); // pi saves it in auth.json
163
+ await synced();
164
+ host.broadcast("login", { step: "done", provider: id });
165
+ } catch (e) { fail(e); }
166
+ }
167
+
168
+ if (msg.type === "login_list_models") { // the endpoint's own list (GET <baseUrl>/models); the key never goes to the page
169
+ try {
170
+ const rt = await runtime(), baseUrl = String(msg.baseUrl ?? "").trim().replace(/\/+$/, "");
171
+ const key = String(msg.key ?? "").trim() || (msg.id && rt.getProviderAuthStatus(msg.id).configured ? (await rt.getAuth(msg.id))?.auth.apiKey : "");
172
+ const headers: Record<string, string> = msg.api === "anthropic-messages" ? { "x-api-key": key ?? "", "anthropic-version": "2023-06-01" }
173
+ : msg.api === "google-generative-ai" ? { "x-goog-api-key": key ?? "" } : key ? { authorization: `Bearer ${key}` } : {};
174
+ const r = await fetch(`${baseUrl}/models`, { headers, signal: AbortSignal.timeout(10_000) });
175
+ if (!r.ok) return fail(`${baseUrl}/models answered ${r.status} ${r.statusText}`);
176
+ const body = await r.json();
177
+ const models = (body.data ?? body.models ?? []).map((m: any) => m.id ?? m.name?.replace(/^models\//, "")).filter(Boolean);
178
+ host.broadcast("login", { step: "models", models });
179
+ } catch (e) { fail(e); }
180
+ }
181
+
182
+ if (msg.type === "model_set") {
183
+ const s = readState(), who = String(msg.for ?? "all"), rt = host.agents.values().next().value?.session.modelRuntime;
184
+ const choice = msg.provider ? { provider: String(msg.provider), id: String(msg.id) } : undefined;
185
+ if (choice && !(rt?.hasConfiguredAuth(choice.provider) && rt.getModel(choice.provider, choice.id)))
186
+ return fail(`${choice.provider}/${choice.id} isn't usable now: connect it with /login`);
187
+ if (who === "all") {
188
+ if (!choice) return fail("Pick a model");
189
+ s.default = choice;
190
+ } else {
191
+ if (!host.agents.has(who)) return fail(`No agent '${who}'`);
192
+ s.agents = { ...s.agents };
193
+ if (choice) s.agents[who] = choice; else delete s.agents[who]; // no model: back to the all-agents one
194
+ }
195
+ writeState(s);
196
+ try {
197
+ for (const a of host.agents.values()) await apply(a);
198
+ host.broadcast("login", { step: "changed" });
199
+ } catch (e) { fail(e); }
200
+ }
201
+
202
+ if (msg.type === "logout") {
203
+ const id = String(msg.provider ?? ""), rt = await runtime(), saved = readModels();
204
+ try {
205
+ if ((await rt.listCredentials()).some((c) => c.providerId === id)) await rt.logout(id);
206
+ if (id in saved.providers) {
207
+ delete saved.providers[id];
208
+ fs.writeFileSync(MODELS, JSON.stringify(saved, null, 2) + "\n");
209
+ }
210
+ await synced();
211
+ } catch (e) { fail(e); }
212
+ }
213
+ });
214
+ }
@@ -209,14 +209,16 @@ export default async function (page) {
209
209
  const text = input.value.trim() || current?.suggestion;
210
210
  if (e.key !== "Enter" || !text || !current) return;
211
211
  const { suggestion, ...note } = current;
212
- page.send({ ...note, text, to: note.to || document.getElementById("to")?.value }); // else the agent picked in the chat
212
+ page.send({ ...note, text, to: note.to || document.getElementById("to")?.value, // else the agent picked in the chat
213
+ board: Number(document.body.dataset.board) || 1 }); // the board shown (boards/): what it points at is on it
213
214
  setOn(false);
214
215
  };
215
216
 
216
217
  const pins = {}; // note id -> pin element: its label, and its drawing if it has one
217
- function pin({ id, text, to, x, y, stroke }) { // stays where the note was written (doesn't follow scrolling yet)
218
+ function pin({ id, text, to, x, y, stroke, board }) { // stays where the note was written (doesn't follow scrolling yet)
218
219
  if (pins[id]) return;
219
220
  const el = (pins[id] = document.createElement("div"));
221
+ el.dataset.board = board ?? 1; // shown only on its board (boards/)
220
222
  el.style.cssText = "position:fixed;inset:0;z-index:9997;pointer-events:none;transition:opacity 1.5s";
221
223
  if (stroke) el.append(strokeSvg(stroke));
222
224
  const label = document.createElement("div");
@@ -19,14 +19,15 @@ const host = (globalThis as any).chalky;
19
19
  const FILE = path.join(host.state, "notes.json");
20
20
  if (!host.shared.has("point"))
21
21
  host.shared.set("point", fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 0, open: {} });
22
- const notes = host.shared.get("point"); // {last, open: {id: {text, to, x, y, stroke}}}
22
+ const notes = host.shared.get("point"); // {last, open: {id: {text, to, x, y, stroke, board}}}
23
23
  if (!host.shared.has("point-items")) host.shared.set("point-items", new Map());
24
24
  const items: Map<string, any> = host.shared.get("point-items"); // note id -> its swarm inbox item (not saved)
25
25
  const save = () => fs.writeFileSync(FILE, JSON.stringify(notes));
26
26
 
27
27
  function pin(text: string, to: string, msg: any) { // a new open note: saved, pinned on every page
28
28
  const id = `n${++notes.last}`;
29
- notes.open[id] = { text, to, x: msg.x ?? 0, y: msg.y ?? 0, stroke: msg.stroke ?? null }; // with its drawing, if any
29
+ notes.open[id] = { text, to, x: msg.x ?? 0, y: msg.y ?? 0, stroke: msg.stroke ?? null, // with its drawing, if any
30
+ board: Number(msg.board) || 1 }; // the board it was written on (boards/): its pin shows there
30
31
  save();
31
32
  host.broadcast("point_pin", { id, ...notes.open[id] });
32
33
  return id;
@@ -42,6 +43,19 @@ export default function (pi: ExtensionAPI) {
42
43
  const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
43
44
  .replace("README.md", path.join(import.meta.dirname, "README.md"));
44
45
  pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.point = guide; });
46
+ // a removed board (boards/) takes its open notes with it; a note its agent hasn't seen yet is just taken back
47
+ pi.on("session_start", () => host.api.boards?.onRemove("point", (n: number) => {
48
+ const gone: { owner: string; what: string }[] = [];
49
+ for (const id of Object.keys(notes.open).filter((id) => (notes.open[id].board ?? 1) === n)) {
50
+ const note = notes.open[id], item = items.get(id);
51
+ delete notes.open[id];
52
+ items.delete(id);
53
+ host.broadcast("point_done", { id });
54
+ if (!(item && host.api.swarm?.withdraw(item))) gone.push({ owner: note.to, what: `note ${id} ("${note.text}")` });
55
+ }
56
+ save();
57
+ return gone;
58
+ }));
45
59
 
46
60
  pi.registerTool({
47
61
  name: "note_done", label: "Note done", description: "Mark a note the user pinned on the UI as dealt with; its pin fades.",
@@ -78,6 +92,7 @@ export default function (pi: ExtensionAPI) {
78
92
  const madeFrom = [...(host.shared.get("ui")?.surfaces[b]?.made_from ?? []).filter((m: string) => m !== a), a];
79
93
  const id = pin(`combine ${a} into ${b}: ${text}`, to, msg);
80
94
  return items.set(id, swarm.send("user", to, [`[combine on the UI] ${text}`, ` id: ${id} (call note_done("${id}") when dealt with)`,
95
+ ` board: ${notes.open[id].board}`,
81
96
  ` dragged: ${a} (keep it as it is)`, ` onto: ${b} (change this one)`,
82
97
  ` so: ui_render(surface="${b}", ..., made_from=${JSON.stringify(madeFrom)}, instruction=<their words>), reading ${a}'s data with {"ref": "${a}", "path": ...}`,
83
98
  ` ${a}: ${describe(a)}`, ` ${b}: ${describe(b)}`].join("\n")));
@@ -89,7 +104,7 @@ export default function (pi: ExtensionAPI) {
89
104
  + (t.surface ? "(the user drew around this part of the surface: the note is about it)" : "(the user drew around empty space: build inside it)");
90
105
  const id = pin(text, to, msg);
91
106
  const lines = [`[note on the UI] ${text}`, ` id: ${id} (call note_done("${id}") when dealt with)`,
92
- ...([["surface", t.surface], ["component", (t.components ?? []).join(" → ")], ["element", t.element],
107
+ ...([["board", notes.open[id].board], ["surface", t.surface], ["component", (t.components ?? []).join(" → ")], ["element", t.element],
93
108
  ["part", t.part && JSON.stringify(t.part)], ["at", at], ["text", t.text], ["file", t.file?.replace(/^\/app\//, host.app + "/")], ["position", pos],
94
109
  ["area", area], ["files", msg.files?.join(", ")]] as [string, any][]).filter(([, v]) => v).map(([k, v]) => ` ${k}: ${v}`)];
95
110
  items.set(id, swarm.send("user", to, lines.join("\n")));
@@ -125,6 +125,7 @@ export default async function (page) {
125
125
  }
126
126
  drawLabel(s, id);
127
127
  s.box.dataset.owner = s.owner; // who a note on this surface goes to (point.js)
128
+ s.box.dataset.board = s.board ?? 1; // its board: boards/browser.js shows it there
128
129
  s.box.querySelector(".surface-resize").hidden = s.resizable === false;
129
130
  s.box.toggleAttribute("data-pinned", !!s.pinned);
130
131
  s.box.toggleAttribute("data-flipped", !!s.flipped);
@@ -149,6 +149,16 @@ export default function (pi: ExtensionAPI) {
149
149
  .replace("README.md", path.join(import.meta.dirname, "README.md"));
150
150
  pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.ui = guide; });
151
151
  pi.on("resources_discover", () => ({ skillPaths: [path.join(import.meta.dirname, "skills")] }));
152
+ // a removed board (boards/) takes its surfaces with it: one step in the history (undo brings them back)
153
+ pi.on("session_start", () => host.api.boards?.onRemove("ui", (n: number) => {
154
+ const sids = Object.keys(ui.surfaces).filter((sid) => (ui.surfaces[sid].board ?? 1) === n);
155
+ const gone = sids.map((sid) => ({ owner: ui.surfaces[sid].owner, what: `surface ${sid}` }));
156
+ sids.forEach((sid, i) => {
157
+ ui.copyIntoUsers(sid); // surfaces elsewhere reading from it keep its values
158
+ ui.change(sid, null, "user", i === sids.length - 1 ? `you removed board ${n} (${sids.join(", ")})` : undefined);
159
+ });
160
+ return gone;
161
+ }));
152
162
 
153
163
  pi.registerTool({
154
164
  name: "ui_catalog", label: "UI catalog", description: "List the UI components you can use in surfaces, with their props.",
@@ -172,6 +182,8 @@ export default function (pi: ExtensionAPI) {
172
182
  about: Type.Optional(Type.String({ description: "what it is for and why it is built this way (its back side)" })),
173
183
  options: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()), { description: 'alternatives instead of components: [{"label", "components", "data"}]' })),
174
184
  made_from: Type.Optional(Type.Array(Type.String())), instruction: Type.Optional(Type.String()),
185
+ board: Type.Optional(Type.Union([Type.Number(), Type.Literal("new")], { description:
186
+ 'a board number, or "new" for a new board; leave out: where the surface is, else the board the user is on' })),
175
187
  }),
176
188
  async execute(_id, a) {
177
189
  const agent = pi.getSessionName() ?? "agent";
@@ -183,7 +195,9 @@ export default function (pi: ExtensionAPI) {
183
195
  const errors = problems(o.components, a.made_from);
184
196
  if (errors.length) return result("error: " + (o.label ? `${o.label}: ` : "") + errors.join("; "));
185
197
  }
186
- const old = ui.surfaces[a.surface] ?? {}, catalog = readCatalog();
198
+ const old = ui.surfaces[a.surface] ?? {}, catalog = readCatalog(), boards = host.api.boards;
199
+ if (typeof a.board === "number" && boards && !boards.has(a.board))
200
+ return result(`error: no board ${a.board}. Boards: ${boards.list().join(", ")} (or board "new")`);
187
201
  const first = options[0] ?? { components: a.components, data: a.data ?? {} };
188
202
  const record: any = { owner: agent, components: first.components, data: first.data, place: a.place ?? old.place ?? null,
189
203
  draggable: a.draggable ?? true, resizable: a.resizable ?? true, about: a.about ?? old.about ?? "",
@@ -191,9 +205,12 @@ export default function (pi: ExtensionAPI) {
191
205
  .map((c: any) => [c.component, catalog[c.component].about?.version ?? 1])) };
192
206
  for (const [k, v] of [["made_from", a.made_from ?? old.made_from], ["instruction", a.instruction ?? old.instruction],
193
207
  ["options", options.length ? options : null]] as const) if (v) record[k] = v;
208
+ // its board (boards/): the one asked for, a new one, where it already is, or the one the user is on
209
+ if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
194
210
  ui.change(a.surface, record, agent, `${agent} drew ${a.surface}` + (options.length ? ` (${options.length} options)` : ""));
195
- return result(options.length ? `surface '${a.surface}' shows ${options.length} options; the user flips through them and keeps one`
196
- : `surface '${a.surface}' is on screen`);
211
+ const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
212
+ return result(options.length ? `surface '${a.surface}' shows ${options.length} options ${where}; the user flips through them and keeps one`
213
+ : `surface '${a.surface}' is ${where}`);
197
214
  },
198
215
  });
199
216
 
@@ -252,6 +269,9 @@ export default function (pi: ExtensionAPI) {
252
269
  else if (type === "ui_move") {
253
270
  if (r.pinned) return host.broadcast("ui", { op: "render", surface: sid, ...r });
254
271
  r.place = msg.place; ui.change(sid, r, "user", `you moved ${sid}`);
272
+ } else if (type === "ui_board" && host.api.boards?.has(msg.board)) { // dropped on another board's thumbnail
273
+ if (r.pinned) return host.broadcast("ui", { op: "render", surface: sid, ...r });
274
+ r.board = msg.board; r.place = msg.place ?? null; ui.change(sid, r, "user", `you moved ${sid} to board ${msg.board}`);
255
275
  } else if (type === "ui_set") { setPath(r.data, msg.path ?? "/", msg.value); ui.change(sid, r, "user", `you changed ${msg.path} in ${sid}`); }
256
276
  else if (type === "ui_prop") {
257
277
  for (const c of r.components) if (c.id === msg.component && !["id", "component", "children", "child"].includes(msg.prop)) c[msg.prop] = msg.value;
@@ -28,7 +28,7 @@ const host = (globalThis as any).chalky;
28
28
  const GIT_DIR = path.join(host.state, "versions.git");
29
29
  const SHOTS = path.join(host.state, "shots");
30
30
  const MAX_SHOT = 10 * 1024 * 1024;
31
- const CODE = ["extensions/", "skills/", "ui/", "SYSTEM.md", "mcp.json"];
31
+ const CODE = ["extensions/", "skills/", "ui/", "SYSTEM.md", "mcp.json", "settings.json"];
32
32
  const IGNORE = ["auth.json", "models-store.json", "mcp-auth.json", "node_modules/"];
33
33
  const repo = { fs, dir: host.app, gitdir: GIT_DIR };
34
34
  const who = (name: string) => ({ name, email: `${name}@chalky` });
@@ -96,7 +96,7 @@ export default function (pi: ExtensionAPI) {
96
96
  const stray = changed.filter((p) => !CODE.some((c) => p.startsWith(c)) && fs.existsSync(path.join(host.app, p)));
97
97
  if (stray.length && host.agents.has(agent))
98
98
  host.api.swarm?.send("system", agent, `Your last turn changed files in ${host.app} that aren't your code: ${stray.slice(0, 5).join(", ")}. `
99
- + `${host.app} is your own code (extensions, skills, ui, SYSTEM.md, mcp.json): it is versioned, and a rollback would undo these files. `
99
+ + `${host.app} is your own code (extensions, skills, ui, SYSTEM.md, mcp.json, settings.json): it is versioned, and a rollback would undo these files. `
100
100
  + `Move project work to ${host.work} and use it from there.`, true);
101
101
  });
102
102
  });
@@ -0,0 +1,71 @@
1
+ // Browser half of the voice extension: a microphone button left of the send button. Click to dictate, click again
2
+ // to stop; each finished piece of speech is added to the input (to read, fix and send). /ui/voice.js does the
3
+ // listening: the server's speech to text when it has STT_BASE_URL, else the browser's own recognition.
4
+ // While dictating, the icon is a sound wave whose bars follow the microphone's loudness (its own AnalyserNode, so
5
+ // it works in both modes).
6
+ import voice from "/ui/voice.js";
7
+
8
+ const SVG = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" ' +
9
+ 'stroke-linejoin="round">';
10
+ const MIC = SVG + '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>';
11
+ const WAVE = SVG + [4, 8, 12, 16, 20].map((x) => `<line x1="${x}" x2="${x}" y1="10" y2="14"/>`).join("") + "</svg>";
12
+ const SHAPE = [0.45, 0.8, 1, 0.75, 0.5]; // the middle bars reach higher
13
+
14
+ export default function (page) {
15
+ const input = document.getElementById("text");
16
+ const button = document.createElement("button");
17
+ button.type = "button"; // in the form: not a send
18
+ button.className = "mic";
19
+ button.title = "Dictate";
20
+ button.innerHTML = MIC;
21
+ input.after(button);
22
+ const style = document.createElement("style");
23
+ style.textContent = "#f button.mic.on { color: #fff; background: #e5484d; border-color: #e5484d; }";
24
+ document.head.append(style);
25
+
26
+ let on = false, stopWave = null;
27
+
28
+ // the wave: loudness (RMS of the mic's samples) sets the bars' height, each wobbling a little on its own
29
+ async function startWave() {
30
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
31
+ const ctx = new AudioContext();
32
+ const analyser = ctx.createAnalyser();
33
+ ctx.createMediaStreamSource(stream).connect(analyser);
34
+ const samples = new Float32Array(analyser.fftSize);
35
+ button.innerHTML = WAVE;
36
+ const bars = button.querySelectorAll("line");
37
+ let level = 0, frame;
38
+ const draw = (t) => {
39
+ analyser.getFloatTimeDomainData(samples);
40
+ const rms = Math.sqrt(samples.reduce((s, x) => s + x * x, 0) / samples.length);
41
+ level += (Math.min(1, rms * 8) - level) * 0.3; // smoothed: rises and falls over a few frames
42
+ bars.forEach((bar, i) => {
43
+ const half = 1 + 8 * level * SHAPE[i] * (0.7 + 0.3 * Math.sin(t / 90 + i * 1.7));
44
+ bar.setAttribute("y1", 12 - half);
45
+ bar.setAttribute("y2", 12 + half);
46
+ });
47
+ frame = requestAnimationFrame(draw);
48
+ };
49
+ frame = requestAnimationFrame(draw);
50
+ return () => { cancelAnimationFrame(frame); stream.getTracks().forEach((tr) => tr.stop()); ctx.close(); button.innerHTML = MIC; };
51
+ }
52
+
53
+ const set = (state) => {
54
+ on = state;
55
+ button.classList.toggle("on", on);
56
+ button.title = on ? "Stop dictating" : "Dictate";
57
+ if (!on && stopWave) { stopWave(); stopWave = null; }
58
+ };
59
+ button.addEventListener("click", async () => {
60
+ if (on) return voice.stop();
61
+ set(true);
62
+ startWave().then((stop) => { if (on) stopWave = stop; else stop(); }).catch(() => {}); // no wave: still dictates
63
+ await voice.start((text) => {
64
+ input.value = input.value.trim() ? `${input.value.trimEnd()} ${text}` : text;
65
+ input.focus();
66
+ }, { onState: (state, detail) => {
67
+ if (state === "error") { page.note(`voice: ${detail}`); voice.stop(); set(false); }
68
+ if (state === "off") set(false);
69
+ } });
70
+ });
71
+ }
@@ -0,0 +1,37 @@
1
+ // Voice input: speech to text (port of the Python voice plugin). The browser half: a microphone button by the send
2
+ // button (browser.js) and /ui/voice.js, which apps can import too.
3
+ // Settings (environment, like MODEL_*): STT_BASE_URL, STT_MODEL, STT_API_KEY. With STT_BASE_URL set, audio goes to
4
+ // POST {STT_BASE_URL}/audio/transcriptions, OpenAI's endpoint, which most providers speak (OpenAI, Groq, Mistral,
5
+ // vLLM; others through LiteLLM). Without it, voice.js uses the browser's own speech recognition.
6
+ // The key stays here: apps are agent-written code, so they post audio to this server, never to the provider.
7
+ // GET /voice-api/config {"api": true | false}: whether audio can go to the API
8
+ // POST /voice-api/transcribe multipart: file (a complete audio file, e.g. chunk.webm), language?, prompt?
9
+ // -> {"text"} or {"error"} (with the provider's status)
10
+ import { Readable } from "node:stream";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+
13
+ const host = (globalThis as any).chalky;
14
+
15
+ export default function (_pi: ExtensionAPI) {
16
+ const base = (process.env.STT_BASE_URL || "").replace(/\/$/, "");
17
+ const json = (res: any, status: number, data: object) =>
18
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(data));
19
+
20
+ host.routes.set("/voice-api/config", (_req: any, res: any) => json(res, 200, { api: !!base }));
21
+
22
+ host.routes.set("/voice-api/transcribe", async (req: any, res: any) => {
23
+ if (!base) return json(res, 400, { error: "no STT_BASE_URL: use the browser's speech recognition" });
24
+ try {
25
+ // the page's form as is, plus the model (Node reads multipart itself: Request.formData)
26
+ const form = await new Request("http://local", { method: "POST", headers: req.headers,
27
+ body: Readable.toWeb(req) as any, duplex: "half" } as any).formData();
28
+ if (!(form.get("file") instanceof Blob)) return json(res, 400, { error: "no audio file" });
29
+ form.set("model", process.env.STT_MODEL || "whisper-1");
30
+ const r = await fetch(`${base}/audio/transcriptions`, { method: "POST", body: form,
31
+ headers: process.env.STT_API_KEY ? { authorization: `Bearer ${process.env.STT_API_KEY}` } : {} });
32
+ const data: any = await r.json().catch(() => ({}));
33
+ if (!r.ok) return json(res, 502, { error: `speech to text: ${r.status} ${data.error?.message ?? ""}`.trim() });
34
+ json(res, 200, { text: data.text ?? "" });
35
+ } catch (e: any) { json(res, 502, { error: `speech to text: ${e.message}` }); }
36
+ });
37
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "compaction": { "keepRecentTokens": 20000 }
3
+ }
package/bin/chalky.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // chalky: starts Chalky from the installed package. Its home (app/, state/, workspace/) is CHALKY_HOME, else
3
3
  // ~/.chalky; the first run copies the package's app/ there. Node won't run TypeScript under node_modules
4
4
  // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so server.ts loads through jiti, as pi loads extensions.
5
- // MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] [MODEL_THINKING=...] chalky
5
+ // MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] chalky
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { createJiti } from "jiti";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chalky/chalky",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Chalky on pi: agents build what you see in the browser, and you point at it and mark it up",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
7
7
  // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
8
8
  // Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
9
- // Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] [MODEL_THINKING=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
9
+ // Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
10
+ // CHALKY_LOGIN=off: no /login, /logout, /model on the page; agents always use MODEL_BASE_URL (a cloud deployment)
10
11
  import fs from "node:fs";
11
12
  import { createRequire } from "node:module";
12
13
  import http from "node:http";
@@ -119,9 +120,9 @@ const host = {
119
120
  if (old) { old.unsubscribe(); old.session.dispose(); }
120
121
  const dir = path.join(STATE, "sessions", name);
121
122
  fs.mkdirSync(dir, { recursive: true });
122
- // projectTrusted false: pi ignores workspace/.pi/ (its SYSTEM.md, extensions, skills, settings), so an agent's
123
- // project files can't change it; app/ (versioned) is the only place
124
- const settingsManager = SettingsManager.inMemory({}, { projectTrusted: false });
123
+ // pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
124
+ // SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
125
+ const settingsManager = SettingsManager.create(WORK, APP, { projectTrusted: false });
125
126
  // agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
126
127
  // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in app/mcp.json, with
127
128
  // codemode and tool_search for servers whose tools aren't direct. Sign-ins: app/mcp-auth.json (not versioned).
@@ -136,17 +137,22 @@ const host = {
136
137
  sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
137
138
  });
138
139
  if (session.sessionName !== name) session.setSessionName(name);
140
+ const empty = session.sessionManager.buildSessionContext().messages.length === 0;
139
141
  // starts the extensions' session (session_start). onError, besides showing extensions' errors: pi 1.4.0's reload()
140
142
  // sends session_start again only to a session with a binding, and without it pi's MCP doesn't reconnect after a reload
141
143
  await session.bindExtensions({ onError: (e) => {
142
144
  console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
143
145
  emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
144
146
  } });
145
- const model = session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
147
+ // the model: the one picked with /login or /model (extensions/login), else the MODEL_BASE_URL endpoint (extensions/model).
148
+ // CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
149
+ const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.login?.modelFor(name, session);
150
+ const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
146
151
  if (model) await session.setModel(model);
147
- // MODEL_THINKING: pi's thinking level (minimal, low, medium, high, ...), sent as reasoning_effort. Unset: none is
148
- // sent and the model uses its own default (pi then says "off", but the model may still think).
149
- if (process.env.MODEL_THINKING) session.setThinkingLevel(process.env.MODEL_THINKING);
152
+ // thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
153
+ // conversation starts at "medium". Set here: pi restores it only when it knows the model then, and ours (the
154
+ // model extension) comes later, so pi would say "off"
155
+ session.setThinkingLevel(empty ? "medium" : session.sessionManager.buildSessionContext().thinkingLevel);
150
156
  const a: Agent = { name, role, app, session, unsubscribe: () => {} };
151
157
  host.agents.set(name, a);
152
158
  a.unsubscribe = session.subscribe((ev: any) => {