@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.
- package/README.md +0 -1
- package/app/SYSTEM.md +3 -2
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/browser.js +11 -1
- package/app/extensions/doodle/index.ts +1 -1
- package/app/extensions/frames/browser.js +7 -4
- package/app/extensions/frames/index.ts +16 -7
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/point/browser.js +4 -2
- package/app/extensions/point/index.ts +18 -3
- package/app/extensions/ui/browser.js +1 -0
- package/app/extensions/ui/index.ts +23 -3
- package/app/extensions/versions/index.ts +2 -2
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/settings.json +3 -0
- package/bin/chalky.mjs +1 -1
- package/package.json +1 -1
- package/server.ts +14 -8
|
@@ -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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
+
}
|
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=...]
|
|
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
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=...]
|
|
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
|
|
123
|
-
// project files can't change it; app/ (versioned) is the only place
|
|
124
|
-
const settingsManager = SettingsManager.
|
|
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
|
-
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
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) => {
|