@chalky/chalky 0.1.5 → 0.1.7

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/server.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // - keeps one pi AgentSession per agent (session name = agent name; saved under data/sessions/<agent>);
6
6
  // - keeps the rule for the version history (host.history) and serves /recover, going back to a working version
7
7
  // when an agent broke the page (the safety net);
8
+ // - brings a new release of this package into the home's agent/, merged with what the agents changed (host.update);
8
9
  // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
9
10
  // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
10
11
  // Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
@@ -17,6 +18,7 @@ import path from "node:path";
17
18
  import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, SessionManager,
18
19
  SettingsManager } from "@earendil-works/pi-coding-agent";
19
20
  import git from "isomorphic-git";
21
+ import diff3Merge from "diff3";
20
22
  import { WebSocketServer } from "ws";
21
23
 
22
24
  const ROOT = import.meta.dirname; // this package: server.ts and the starting agent/
@@ -97,9 +99,9 @@ function migrate() {
97
99
  .replaceAll("/ui/agents.js", "/app/agents.js").replaceAll("/ui/voice.js", "/voice/voice.js")
98
100
  .replaceAll("/ui-api/app-data/", "/app/data/").replaceAll("/ui/apps/", "/built/apps/"));
99
101
  }
100
- migrate();
101
- if (!fs.existsSync(AGENT)) startAgent(); // first run: the starting agent
102
+ migrate(); // (a new home's agent/ comes from the update below: the first release, merged into nothing)
102
103
  if (HOME !== ROOT) { // HOME/agent's extensions import our dependencies: Node looks for a node_modules above them
104
+ fs.mkdirSync(HOME, { recursive: true }); // a new home: nothing made it yet
103
105
  const link = path.join(HOME, "node_modules");
104
106
  if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
105
107
  if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
@@ -266,19 +268,174 @@ function file(res: http.ServerResponse, p: string) {
266
268
  // The version extension commits and rolls back with this; /recover too, so it works when that extension is broken.
267
269
  // repo isomorphic-git's {fs, dir: HOME, gitdir}
268
270
  // stage() git add -A for what inHistory keeps (.gitignore files play no part); returns the changed paths
271
+ // restore(oid) that version's agent/ and built/ into the folders and the index, removing files it doesn't have
272
+ // (the branch stays where it is; what the history doesn't keep, logins and endpoints, isn't touched)
273
+ // versions() the user's versions, newest first, as git.log gives them ({oid, commit}): main's own line, without
274
+ // the releases an update merged in (see "updates" below)
269
275
  const REPO = { fs, dir: HOME, gitdir: path.join(DATA, "version", "git") };
270
276
  const KEPT = { filepaths: ["agent", "built"], ignored: true, filter: inHistory }; // statusMatrix's walk: only these
277
+ // isomorphic-git 1.44.2 takes a file whose size and mtime (whole seconds) match the index as unchanged: a same-size
278
+ // change in the second after it was staged would be missed (and an update would write over it). Real git checks
279
+ // those "racy" files; here every file that looks unchanged is checked by its content. Its status also misses a change
280
+ // of the x bit alone: checked too.
271
281
  async function stage() {
272
- const changed = [];
282
+ const changed = [], staged = new Map<string, string>(); // the index: path -> "oid mode"
283
+ await git.walk({ ...REPO, trees: [git.STAGE()], map: async (f, [e]) => {
284
+ if (e && f !== "." && (await e.type()) === "blob") staged.set(f, `${await e.oid()} ${await e.mode()}`);
285
+ return true;
286
+ } });
287
+ const now = async (f: string) => { const p = path.join(HOME, f); // the file as git would stage it
288
+ return `${(await git.hashBlob({ object: fs.readFileSync(p) })).oid} ${fs.statSync(p).mode & 0o111 ? 0o100755 : 0o100644}`; };
273
289
  for (const [filepath, head, workdir, index] of await git.statusMatrix({ ...REPO, ...KEPT })) {
274
- if (head === 1 && workdir === 1 && index === 1) continue; // unchanged
290
+ if (head === 1 && workdir === 1 && index === 1 && await now(filepath) === staged.get(filepath)) continue; // unchanged
275
291
  if (workdir === 0) { if (index !== 0) await git.remove({ ...REPO, filepath }); }
276
292
  else await git.add({ ...REPO, filepath, force: true });
277
293
  changed.push(filepath);
278
294
  }
279
295
  return changed;
280
296
  }
281
- Object.assign(host, { history: { repo: REPO, kept: KEPT, stage } });
297
+ // Only the folders that either version has files in: for a folder neither has (built/ before anything was made, still
298
+ // on disk as built/apps/), isomorphic-git 1.44.2's checkout takes it for a file to delete and throws EISDIR.
299
+ async function restore(oid: string) {
300
+ const has = async (ref: string, dir: string) => (await git.listFiles({ ...REPO, ref })).some((f) => f.startsWith(`${dir}/`));
301
+ const head = await git.resolveRef({ ...REPO, ref: "HEAD" });
302
+ const filepaths = [];
303
+ for (const dir of KEPT.filepaths) if (await has(oid, dir) || await has(head, dir)) filepaths.push(dir);
304
+ if (filepaths.length) await git.checkout({ ...REPO, ref: oid, noUpdateHead: true, force: true, filepaths });
305
+ }
306
+ Object.assign(host, { history: { repo: REPO, kept: KEPT, stage, restore } });
307
+
308
+ // ---- updates: each release of this package is a commit on the history's "package" branch, merged into main ----
309
+ // The package's agent/ is a release; the home's agent/ is what runs, and the agents change it. At start, a release
310
+ // not merged yet is committed on "package" (its agent/ only) and merged into main, file by file. The base is the
311
+ // release merged last (git's merge base), so for each file:
312
+ // only the release changed it -> the release's only the home changed it -> the home's
313
+ // both, in different lines -> both changes (diff3)
314
+ // both, in the same lines, or one side deleted it -> the home's stays; the release's goes next to it as
315
+ // <file>.new, and agent1 is asked to merge them (host.update.conflicts)
316
+ // First a "Before update" commit of unsaved changes, then one commit with both parents: the version panel undoes an
317
+ // update like any change. A new home is the first release merged into nothing; a home from before this has no
318
+ // "package" yet: its base is its "original" version (the release it started from). If the update fails, the home
319
+ // runs as it was and the page says why (host.update.error). Not when the home is this folder (node server.ts here).
320
+ // agent/mcp.json is the user's own list of MCP servers: the package never ships it, so no release brings or removes it.
321
+ const RELEASE = `Chalky ${JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version}`;
322
+ const CHALKY = { name: "chalky", email: "chalky@chalky" };
323
+ const OWN = ["agent/mcp.json"];
324
+ const ref = (r: string) => git.resolveRef({ ...REPO, ref: r }).catch(() => undefined);
325
+ const blob = async (oid: string) => Buffer.from((await git.readBlob({ ...REPO, oid })).blob);
326
+
327
+ // The user's versions: from HEAD by first parents only (an update's first parent is the version before it; its
328
+ // second, the release), stopping at a release (a new home's first version comes after one). Not git.log: it walks
329
+ // both parents, so the releases would be listed as versions (going back to one would take the agents' changes and
330
+ // built/ away), the first release twice, and versions made in the same second out of order.
331
+ async function versions() {
332
+ const releases = new Set<string>(), original = await ref("refs/tags/original"); // a home from before: its "original" isn't one
333
+ for (let oid = await ref("package"); oid && oid !== original; oid = (await git.readCommit({ ...REPO, oid })).commit.parent[0])
334
+ releases.add(oid);
335
+ const out = [];
336
+ for (let oid = await ref("HEAD"); oid && !releases.has(oid); ) {
337
+ const c = await git.readCommit({ ...REPO, oid });
338
+ out.push(c);
339
+ oid = c.commit.parent[0];
340
+ }
341
+ return out;
342
+ }
343
+ Object.assign(host.history, { versions });
344
+
345
+ async function releaseTree(dir: string): Promise<string | null> { // the package's agent/ (what startAgent copies) as a git tree
346
+ const tree = [];
347
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
348
+ const p = path.join(dir, e.name);
349
+ const rel = path.relative(path.join(ROOT, "agent"), p);
350
+ if (!isAgentPart(rel) || OWN.includes(`agent/${rel}`)) continue;
351
+ if (e.isFile()) tree.push({ mode: fs.statSync(p).mode & 0o111 ? "100755" : "100644", path: e.name, type: "blob" as const,
352
+ oid: await git.writeBlob({ ...REPO, blob: fs.readFileSync(p) }) });
353
+ else if (e.isDirectory()) { const oid = await releaseTree(p); if (oid) tree.push({ mode: "040000", path: e.name, type: "tree" as const, oid }); }
354
+ }
355
+ return tree.length ? git.writeTree({ ...REPO, tree }) : null;
356
+ }
357
+
358
+ async function filesOf(commit?: string) { // agent/'s files in a commit: path -> {oid, mode}
359
+ const files = new Map<string, { oid: string; mode: number }>();
360
+ if (commit) await git.walk({ ...REPO, trees: [git.TREE({ ref: commit })], map: async (p, [e]) => {
361
+ if (p !== "." && p !== "agent" && !p.startsWith("agent/")) return null; // built/: not the release's
362
+ if (e && (await e.type()) === "blob") files.set(p, { oid: await e.oid(), mode: await e.mode() });
363
+ return true;
364
+ } });
365
+ return files;
366
+ }
367
+
368
+ function merge3(base: Buffer, ours: Buffer, theirs: Buffer) { // both changes, or null if they touch the same lines
369
+ if ([base, ours, theirs].some((b) => b.includes(0))) return null; // not text
370
+ const lines = (b: Buffer) => b.toString("utf8").match(/^.*(\r?\n|$)/gm) ?? [];
371
+ const parts = diff3Merge(lines(ours), lines(base), lines(theirs));
372
+ return parts.some((p: any) => p.conflict) ? null : parts.flatMap((p: any) => p.ok).join("");
373
+ }
374
+
375
+ async function update() {
376
+ let main = await ref("main"), pkg = await ref("package");
377
+ const last = pkg && (await git.readCommit({ ...REPO, oid: pkg })).commit.message.trim();
378
+ if (last === RELEASE && main && (await git.findMergeBase({ ...REPO, oids: [main, pkg!] }))[0] === pkg) return; // merged
379
+ await git.init({ ...REPO, defaultBranch: "main" }); // (does nothing if there is one)
380
+ if ((await stage()).length) main = await git.commit({ ...REPO, message: `Before update to ${RELEASE}`, author: CHALKY });
381
+ if (last !== RELEASE) {
382
+ const agent = await releaseTree(path.join(ROOT, "agent"));
383
+ const tree = await git.writeTree({ ...REPO, tree: agent ? [{ mode: "040000", path: "agent", type: "tree", oid: agent }] : [] });
384
+ const parent = pkg ?? (main && await ref("refs/tags/original")); // a home from before this: the release it started from
385
+ pkg = await git.commit({ ...REPO, ref: "refs/heads/package", tree, parent: parent ? [parent] : [], message: RELEASE, author: CHALKY });
386
+ }
387
+ const [base] = main ? await git.findMergeBase({ ...REPO, oids: [main, pkg!] }) : [];
388
+ const [B, O, T] = await Promise.all([base, main, pkg].map(filesOf));
389
+ const writes: [string, Buffer | null, number?][] = [], conflicts: string[] = []; // [path in HOME, content (null: delete), mode]
390
+ for (const f of new Set([...O.keys(), ...T.keys()])) {
391
+ if (OWN.includes(f)) continue;
392
+ const [b, o, t] = [B.get(f), O.get(f), T.get(f)], same = (x?: typeof b, y?: typeof b) => x?.oid === y?.oid && x?.mode === y?.mode;
393
+ if (same(o, t) || same(b, t)) continue; // the same, or the release didn't change it: the home's
394
+ if (same(b, o)) { writes.push([f, t ? await blob(t.oid) : null, t?.mode]); continue; } // only the release changed it
395
+ const merged = b && o && t && merge3(await blob(b.oid), await blob(o.oid), await blob(t.oid)); // (and the x bit: whose changed)
396
+ if (merged != null) { writes.push([f, Buffer.from(merged), b!.mode === t!.mode ? o!.mode : t!.mode]); continue; }
397
+ if (t) writes.push([`${f}.new`, await blob(t.oid), t.mode]);
398
+ conflicts.push(!o ? `${f}: you had deleted it; this release changed it (its version: ${f}.new)`
399
+ : !t ? `${f}: this release removed it; you had changed it (yours is kept)`
400
+ : `${f}: you and this release changed the same lines (its version: ${f}.new)`);
401
+ }
402
+ try { // (then saved: main moves only when every file is written)
403
+ for (const [f, content, mode] of writes) {
404
+ const p = path.join(HOME, f);
405
+ if (!content) { // and the folders that leaves empty
406
+ fs.rmSync(p, { force: true });
407
+ for (let d = path.dirname(p); d !== AGENT && fs.existsSync(d) && !fs.readdirSync(d).length; d = path.dirname(d)) fs.rmdirSync(d);
408
+ continue;
409
+ }
410
+ fs.mkdirSync(path.dirname(p), { recursive: true });
411
+ fs.writeFileSync(p, content);
412
+ if (mode) fs.chmodSync(p, mode & 0o777);
413
+ }
414
+ await stage();
415
+ await git.commit({ ...REPO, message: main ? `Updated to ${RELEASE}` : `Started with ${RELEASE}`, author: CHALKY,
416
+ parent: main ? [main, pkg!] : [pkg!] });
417
+ } catch (e) { // each file written goes back as main has it (contents and x bit), or away (.new files too)
418
+ for (const [f] of writes) {
419
+ const p = path.join(HOME, f), o = O.get(f);
420
+ try {
421
+ if (!o) { fs.rmSync(p, { force: true }); continue; }
422
+ fs.mkdirSync(path.dirname(p), { recursive: true });
423
+ fs.writeFileSync(p, await blob(o.oid));
424
+ fs.chmodSync(p, o.mode & 0o777);
425
+ } catch (e2) { console.error(`could not put ${f} back:`, e2); }
426
+ }
427
+ throw e;
428
+ }
429
+ const files = writes.length - writes.filter(([f]) => f.endsWith(".new")).length;
430
+ if (main) return { release: RELEASE, files, conflicts }; // (a new home: nothing to tell)
431
+ }
432
+
433
+ if (HOME !== ROOT) try { Object.assign(host, { update: await update() }); } catch (e: any) { // run as it was: main didn't move
434
+ console.error(`update to ${RELEASE} failed:`, e);
435
+ const main = await ref("main");
436
+ if (main) await restore(main).catch((e) => console.error("could not put agent/ and built/ back as main has them:", e)); // and the index
437
+ Object.assign(host, { update: { release: RELEASE, error: String(e?.message ?? e) } });
438
+ }
282
439
 
283
440
  // ---- recovery: the safety net for when an agent breaks the page (or the version extension, so its panel) ----
284
441
  // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever agent/ holds.
@@ -292,7 +449,7 @@ const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
292
449
 
293
450
  async function recoverPage(res: http.ServerResponse) {
294
451
  // the history's folder comes before its first commit: right after a start there may be no versions yet
295
- const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO).catch((e) => e.code === "NotFoundError" ? [] : Promise.reject(e)) : [];
452
+ const log = await versions(); // (none yet: [])
296
453
  const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
297
454
  <td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
298
455
  <td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
@@ -315,7 +472,7 @@ async function recover(sha: string, res: http.ServerResponse) {
315
472
  const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
316
473
  await stage();
317
474
  try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
318
- await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true, filepaths: KEPT.filepaths });
475
+ await restore(sha);
319
476
  const files = await stage();
320
477
  for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) { // folders it emptied
321
478
  try { fs.rmdirSync(path.join(HOME, d)); } catch { break; }
@@ -366,6 +523,11 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
366
523
 
367
524
  // agent1, then every agent saved by an earlier run (their conversations come back from their session files)
368
525
  await host.createAgent("agent1", "the user's main agent");
526
+ const conflicts: string[] = (host as any).update?.conflicts ?? []; // the update's files to merge: agent1's task
527
+ if (conflicts.length) host.send("chalky", "agent1", `Chalky was updated to ${(host as any).update.release}. These files `
528
+ + `were changed both by you (or another agent) and by the update, so yours were kept:\n${conflicts.map((c) => `- ${c}`).join("\n")}\n`
529
+ + "For each, read both versions and make one that keeps what the user had and adds what the update brings, then "
530
+ + "delete the .new file. If you can't tell what the user would want, keep theirs and tell them what the update had.");
369
531
  for (const name of fs.readdirSync(path.join(DATA, "sessions")).filter((n) => n !== "agent1")) {
370
532
  const { role, app } = JSON.parse(fs.readFileSync(path.join(DATA, "sessions", name, "role.json"), "utf8"));
371
533
  await host.createAgent(name, role, app);