diff --git a/src/cli/install-pi.ts b/src/cli/install-pi.ts index bcfe6acd..051b29c8 100644 --- a/src/cli/install-pi.ts +++ b/src/cli/install-pi.ts @@ -1,6 +1,17 @@ -import { existsSync, writeFileSync, rmSync, readFileSync, copyFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + lstatSync, + readFileSync, + readdirSync, + rmSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; import { join } from "node:path"; -import { HOME, pkgRoot, ensureDir, syncDir, reportPruned, writeVersionStamp, log } from "./util.js"; +import { HOME, pkgRoot, ensureDir, syncDir, reportPruned, writeVersionStamp, log, warn } from "./util.js"; import { getVersion } from "./version.js"; import { upsertMarkedBlock, @@ -38,6 +49,14 @@ const LEGACY_SKILL_DIR = join(PI_AGENT_DIR, "skills", "hivemind-memory"); const EXTENSIONS_DIR = join(PI_AGENT_DIR, "extensions"); const EXTENSION_PATH = join(EXTENSIONS_DIR, "hivemind.ts"); const VERSION_DIR = join(PI_AGENT_DIR, ".hivemind"); +const LEGACY_SKILL_PATH = join(LEGACY_SKILL_DIR, "SKILL.md"); +// Exact SHA-256 digests of the two SKILL.md bodies the historical Pi +// installer shipped (3b7d8a01 and 5ba761c0). The Pi-wide version stamp is +// not provenance for this particular path. +const LEGACY_SKILL_DIGESTS = new Set([ + "124e4881db8e8c7e78073c69fb7cde6969990792f20f436ee13eb6ef54fa8282", + "a3b8ffe6769096ff2cfc3d89049e66cd948de80ba2b2f0ba57da268ec6b3b5c2", +]); // Worker bundles the extension spawns (wiki-worker shells `pi --print` for // the AI summary; skillify / autopull / skillopt / notifications workers are // the shared modules pi cannot import as raw .ts). CC/codex/cursor/hermes @@ -45,6 +64,73 @@ const VERSION_DIR = join(PI_AGENT_DIR, ".hivemind"); // they are installed as a sibling dir of the extension. const WIKI_WORKER_DIR = join(PI_AGENT_DIR, "hivemind"); +type LegacySkillInspection = + | { state: "absent" } + | { state: "generated" } + | { state: "preserve"; reason: string }; + +function inspectLegacySkill(): LegacySkillInspection { + let dirStat; + try { + dirStat = lstatSync(LEGACY_SKILL_DIR); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { state: "absent" }; + return { state: "preserve", reason: "the path could not be inspected" }; + } + + if (dirStat.isSymbolicLink()) { + return { state: "preserve", reason: "the path is a symlink" }; + } + if (!dirStat.isDirectory()) { + return { state: "preserve", reason: "the path is not a directory" }; + } + + let entries; + try { + entries = readdirSync(LEGACY_SKILL_DIR, { withFileTypes: true }); + } catch { + return { state: "preserve", reason: "the directory could not be inspected" }; + } + if (entries.length !== 1 || entries[0].name !== "SKILL.md" || !entries[0].isFile()) { + return { state: "preserve", reason: "the directory has unverified contents" }; + } + + try { + const digest = createHash("sha256").update(readFileSync(LEGACY_SKILL_PATH)).digest("hex"); + return LEGACY_SKILL_DIGESTS.has(digest) + ? { state: "generated" } + : { state: "preserve", reason: "SKILL.md does not match a shipped legacy artifact" }; + } catch { + return { state: "preserve", reason: "SKILL.md could not be inspected" }; + } +} + +function removeGeneratedLegacySkill(): boolean { + const inspection = inspectLegacySkill(); + if (inspection.state === "absent") return false; + if (inspection.state === "preserve") { + warn( + ` pi preserving legacy skill at ${LEGACY_SKILL_DIR}: ${inspection.reason}; ` + + "remove it manually if it is obsolete", + ); + return false; + } + + try { + // Avoid recursive deletion: the verified historical directory contained + // only this file, and rmdir will refuse to remove a now-nonempty directory. + unlinkSync(LEGACY_SKILL_PATH); + rmdirSync(LEGACY_SKILL_DIR); + return true; + } catch { + warn( + ` pi legacy skill cleanup stopped at ${LEGACY_SKILL_DIR}; ` + + "inspect the path and remove it manually if it is obsolete", + ); + return false; + } +} + const HIVEMIND_BLOCK_BODY = `${HIVEMIND_BLOCK_START} ## Hivemind Memory @@ -77,9 +163,7 @@ export function installPi(): void { // Clean up any per-agent SKILL.md left by an older installer — pi reads // skills from both ~/.pi/agent/skills/ and ~/.agents/skills/, so a local // drop collides with the codex installer's shared agentskills.io symlink. - if (existsSync(LEGACY_SKILL_DIR)) { - rmSync(LEGACY_SKILL_DIR, { recursive: true, force: true }); - } + removeGeneratedLegacySkill(); // 1. AGENTS.md hivemind block (idempotent upsert). Pi auto-loads this every turn. const prior = existsSync(AGENTS_MD) ? readFileSync(AGENTS_MD, "utf-8") : null; @@ -114,8 +198,7 @@ export function installPi(): void { } export function uninstallPi(): void { - if (existsSync(LEGACY_SKILL_DIR)) { - rmSync(LEGACY_SKILL_DIR, { recursive: true, force: true }); + if (removeGeneratedLegacySkill()) { log(` pi removed ${LEGACY_SKILL_DIR}`); } if (existsSync(EXTENSION_PATH)) { diff --git a/tests/cli/cli-install-pi-fs.test.ts b/tests/cli/cli-install-pi-fs.test.ts index 3bb6ed79..250cd361 100644 --- a/tests/cli/cli-install-pi-fs.test.ts +++ b/tests/cli/cli-install-pi-fs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setFakeHome, clearFakeHome } from "../shared/fake-home.js"; @@ -53,6 +53,53 @@ async function importInstaller(): Promise.md\` +3. **If you need raw data**: Read the session JSONL at \`~/.deeplake/memory/sessions//.jsonl\` +4. **Keyword search**: \`grep -r "keyword" ~/.deeplake/memory/\` + +Do NOT jump straight to reading raw JSONL files. Always start with index.md and summaries. + +## Important Constraints + +- Use \`grep\` (NOT \`rg\`/ripgrep) for keyword search — \`rg\` may not be installed on the host system. +- Only use these bash builtins to interact with \`~/.deeplake/memory/\`: \`cat\`, \`ls\`, \`grep\`, \`echo\`, \`jq\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, \`wc\`, \`sort\`, \`find\`. The memory filesystem does NOT support \`rg\`, \`python\`, \`python3\`, \`node\`, or \`curl\`. +- If a file returns empty after 2 attempts, skip it and move on. Report what you found rather than retrying exhaustively. +`; + +const LEGACY_SKILL_V1 = LEGACY_SKILL_V2.replace( + "- Use `grep` (NOT `rg`/ripgrep) for keyword search — `rg` may not be installed on the host system.\n- Only use these bash builtins to interact with `~/.deeplake/memory/`: `cat`, `ls`, `grep`, `echo`, `jq`, `head`, `tail`, `sed`, `awk`, `wc`, `sort`, `find`. The memory filesystem does NOT support `rg`, `python`, `python3`, `node`, or `curl`.", + "- Only use bash builtins (`cat`, `ls`, `grep`, `echo`, `jq`, `head`, `tail`, `sed`, `awk`, `wc`, `sort`, `find`) to interact with `~/.deeplake/memory/`. The memory filesystem does NOT support `python`, `python3`, `node`, or `curl`.", +); + describe("installPi — cold install", () => { it("creates AGENTS.md with exactly one hivemind marker pair", async () => { const { installPi } = await importInstaller(); @@ -105,16 +152,84 @@ describe("installPi — re-install / cleanup", () => { expect((agents.match(new RegExp(END, "g")) ?? []).length).toBe(1); }); - it("cleans up the legacy per-agent SKILL.md drop on install", async () => { - // Older installer dropped a SKILL.md under skills/hivemind-memory/ — - // now removed because pi reads the shared agentskills location too, - // creating a collision with the codex installer. + it.each([LEGACY_SKILL_V1, LEGACY_SKILL_V2])( + "cleans up an unmodified legacy per-agent SKILL.md drop on install", + async (legacySkillBody) => { + // Older installer dropped a SKILL.md under skills/hivemind-memory/ — + // now removed because pi reads the shared agentskills location too, + // creating a collision with the codex installer. + const legacy = join(tmpHome, ".pi", "agent", "skills", "hivemind-memory"); + mkdirSync(legacy, { recursive: true }); + writeFileSync(join(legacy, "SKILL.md"), legacySkillBody); + const { installPi } = await importInstaller(); + installPi(); + expect(existsSync(legacy)).toBe(false); + }, + ); + + it("preserves all user files despite an old global Hivemind version stamp", async () => { + const legacy = join(tmpHome, ".pi", "agent", "skills", "hivemind-memory"); + mkdirSync(legacy, { recursive: true }); + writeFileSync(join(legacy, "SKILL.md"), "user-authored skill"); + writeFileSync(join(legacy, "notes.txt"), "user notes"); + mkdirSync(join(tmpHome, ".pi", "agent", ".hivemind"), { recursive: true }); + writeFileSync(join(tmpHome, ".pi", "agent", ".hivemind", ".hivemind_version"), "6.0.0\n"); + const { installPi, uninstallPi } = await importInstaller(); + installPi(); + expect(readFileSync(join(legacy, "SKILL.md"), "utf-8")).toBe("user-authored skill"); + expect(readFileSync(join(legacy, "notes.txt"), "utf-8")).toBe("user notes"); + + uninstallPi(); + expect(readFileSync(join(legacy, "SKILL.md"), "utf-8")).toBe("user-authored skill"); + expect(readFileSync(join(legacy, "notes.txt"), "utf-8")).toBe("user notes"); + expect(process.stderr.write).toHaveBeenCalledWith(expect.stringContaining("remove it manually")); + }); + + it("preserves a modified historical skill instead of adopting it", async () => { const legacy = join(tmpHome, ".pi", "agent", "skills", "hivemind-memory"); mkdirSync(legacy, { recursive: true }); - writeFileSync(join(legacy, "SKILL.md"), "stale skill body"); + writeFileSync(join(legacy, "SKILL.md"), `${LEGACY_SKILL_V2}\n# My customization\n`); const { installPi } = await importInstaller(); installPi(); - expect(existsSync(legacy)).toBe(false); + expect(readFileSync(join(legacy, "SKILL.md"), "utf-8")).toContain("# My customization"); + }); + + it("preserves a legacy skill directory containing any extra file", async () => { + const legacy = join(tmpHome, ".pi", "agent", "skills", "hivemind-memory"); + mkdirSync(legacy, { recursive: true }); + writeFileSync(join(legacy, "SKILL.md"), LEGACY_SKILL_V2); + writeFileSync(join(legacy, "user.txt"), "keep me"); + const { installPi } = await importInstaller(); + installPi(); + expect(readFileSync(join(legacy, "SKILL.md"), "utf-8")).toBe(LEGACY_SKILL_V2); + expect(readFileSync(join(legacy, "user.txt"), "utf-8")).toBe("keep me"); + }); + + it("preserves symlinks and file/directory mismatches at the legacy path", async () => { + const skills = join(tmpHome, ".pi", "agent", "skills"); + const legacy = join(skills, "hivemind-memory"); + const userSkill = join(tmpHome, "user-skill"); + mkdirSync(userSkill, { recursive: true }); + writeFileSync(join(userSkill, "SKILL.md"), LEGACY_SKILL_V2); + mkdirSync(skills, { recursive: true }); + symlinkSync(userSkill, legacy); + + const { installPi, uninstallPi } = await importInstaller(); + installPi(); + expect(lstatSync(legacy).isSymbolicLink()).toBe(true); + expect(readFileSync(join(userSkill, "SKILL.md"), "utf-8")).toBe(LEGACY_SKILL_V2); + uninstallPi(); + expect(lstatSync(legacy).isSymbolicLink()).toBe(true); + expect(readFileSync(join(userSkill, "SKILL.md"), "utf-8")).toBe(LEGACY_SKILL_V2); + + rmSync(legacy); + writeFileSync(legacy, LEGACY_SKILL_V2); + installPi(); + expect(lstatSync(legacy).isFile()).toBe(true); + expect(readFileSync(legacy, "utf-8")).toBe(LEGACY_SKILL_V2); + uninstallPi(); + expect(lstatSync(legacy).isFile()).toBe(true); + expect(readFileSync(legacy, "utf-8")).toBe(LEGACY_SKILL_V2); }); }); @@ -150,10 +265,10 @@ describe("uninstallPi", () => { expect(existsSync(join(tmpHome, ".pi", "agent", ".hivemind"))).toBe(false); }); - it("removes the legacy SKILL.md drop if it survived from an older installer", async () => { + it("removes an unmodified legacy SKILL.md drop if it survived from an older installer", async () => { const legacy = join(tmpHome, ".pi", "agent", "skills", "hivemind-memory"); mkdirSync(legacy, { recursive: true }); - writeFileSync(join(legacy, "SKILL.md"), "stale"); + writeFileSync(join(legacy, "SKILL.md"), LEGACY_SKILL_V2); const { uninstallPi } = await importInstaller(); uninstallPi(); expect(existsSync(legacy)).toBe(false);