feat: vendor official hippo memory extension

This commit is contained in:
云服务部-叶林立
2026-08-19 21:07:23 +08:00
parent 9b2ec36a2d
commit 3cdcc3510b
21 changed files with 975 additions and 106 deletions
+19 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,9 +11,26 @@ const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0;
test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
dependencies: Record<string, string>;
pi: { extensions: string[] };
};
assert.ok(
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
"the bundle must load the vendored official Hippo Memory extension",
);
assert.equal(
packageJson.pi.extensions.some((entry) => entry.includes("pi-hermes-memory") || entry.includes("the-forge-flow")),
false,
"retired Hermes and third-party Hippo extensions must not be loaded",
);
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined);
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
const mockBin = join(home, "bin");
await mkdir(mockBin, { recursive: true });
const hippo = join(mockBin, "hippo");
await writeFile(hippo, "#!/bin/sh\nprintf '%s\\n' 'Hippo Memory status'\n", "utf8");
await chmod(hippo, 0o755);
const args = ["--no-extensions"];
for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry));
args.push("--help");
@@ -27,6 +44,7 @@ test("all package extensions load together without global registration conflicts
XDG_CONFIG_HOME: join(home, ".config"),
PI_CODING_AGENT_DIR: join(home, ".pi-agent"),
MY_PI_SEARCH_CONFIG: join(home, "missing-search.env"),
PATH: `${mockBin}:${process.env.PATH ?? ""}`,
PI_OFFLINE: "1",
TAVILY_API_KEY: "",
EXA_API_KEY: "",
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import hippoExtension from "../hippo-memory-pi/index.ts";
test("official Hippo extension registers five tools and runs lifecycle CLI calls", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-hippo-extension-"));
const bin = join(root, "bin");
const project = join(root, "project");
const log = join(root, "hippo.log");
await mkdir(bin, { recursive: true });
await mkdir(join(project, ".hippo"), { recursive: true });
const hippo = join(bin, "hippo");
await writeFile(
hippo,
`#!/bin/sh\nprintf '%s\\n' "$*" >> "$HIPPO_TEST_LOG"\ncase "$1" in\n status) printf '%s\\n' 'Hippo status ok' ;;\n context) printf '%s\\n' 'Remembered project context for testing' ;;\n recall) printf '%s\\n' 'Relevant project memory' ;;\n remember) printf '%s\\n' 'Remembered [test]' ;;\n outcome) printf '%s\\n' 'Outcome recorded' ;;\n sleep) printf '%s\\n' 'Sleep complete' ;;\nesac\n`,
"utf8",
);
await chmod(hippo, 0o755);
const previousPath = process.env.PATH;
const previousLog = process.env.HIPPO_TEST_LOG;
process.env.PATH = `${bin}:${previousPath ?? ""}`;
process.env.HIPPO_TEST_LOG = log;
try {
const handlers = new Map<string, Array<(event: any, ctx: any) => Promise<any>>>();
const tools = new Map<string, any>();
hippoExtension({
on(name: string, handler: (event: any, ctx: any) => Promise<any>) {
const entries = handlers.get(name) ?? [];
entries.push(handler);
handlers.set(name, entries);
},
registerTool(tool: any) {
tools.set(tool.name, tool);
},
});
assert.deepEqual(
[...tools.keys()].sort(),
["hippo_context", "hippo_outcome", "hippo_recall", "hippo_remember", "hippo_status"],
);
const ctx = { cwd: project };
const startResult = await handlers.get("session_start")?.[0]?.({}, ctx);
assert.match(startResult.systemPromptAppend, /Remembered project context/);
const rememberResult = await tools.get("hippo_remember").execute("call-1", { text: "A durable lesson" }, undefined, undefined, ctx);
assert.match(rememberResult.content[0].text, /Remembered \[test\]/);
await handlers.get("tool_result")?.[0]?.(
{ isError: true, toolName: "build", content: "Compilation failed because the generated symbol was missing" },
ctx,
);
await handlers.get("session_shutdown")?.[0]?.({}, ctx);
const calls = await readFile(log, "utf8");
assert.match(calls, /context --auto/);
assert.match(calls, /remember A durable lesson/);
assert.match(calls, /remember Tool 'build' failed:/);
assert.match(calls, /sleep/);
} finally {
process.env.PATH = previousPath;
if (previousLog === undefined) delete process.env.HIPPO_TEST_LOG;
else process.env.HIPPO_TEST_LOG = previousLog;
}
});
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
async function executable(path: string, content: string): Promise<void> {
await writeFile(path, `#!/bin/sh\n${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function fixture(initialHippoVersion?: string) {
const root = await mkdtemp(join(tmpdir(), "my-pi-hippo-scripts-"));
const home = join(root, "home");
const bin = join(root, "bin");
const project = join(root, "project");
const log = join(root, "commands.log");
const versionState = join(root, "hippo-version");
await mkdir(home, { recursive: true });
await mkdir(bin, { recursive: true });
await mkdir(project, { recursive: true });
await writeFile(log, "", "utf8");
if (initialHippoVersion) await writeFile(versionState, `${initialHippoVersion}\n`, "utf8");
await executable(join(bin, "pi"), 'printf "pi %s\\n" "$*" >> "$MOCK_LOG"');
await executable(
join(bin, "npm"),
String.raw`printf 'npm %s\n' "$*" >> "$MOCK_LOG"
if [ "$1" = "view" ]; then
printf '%s\n' '1.33.0'
elif [ "$1" = "install" ] && [ "$2" = "-g" ]; then
printf '%s\n' '1.33.0' > "$HIPPO_VERSION_STATE"
cat > "$MOCK_BIN/hippo" <<'HIPPO'
#!/bin/sh
if [ "$1" = "--version" ]; then cat "$HIPPO_VERSION_STATE"; exit 0; fi
if [ "$1" = "init" ]; then mkdir -p .hippo; printf 'hippo init\n' >> "$MOCK_LOG"; exit 0; fi
exit 0
HIPPO
chmod +x "$MOCK_BIN/hippo"
fi`,
);
if (initialHippoVersion) {
await executable(
join(bin, "hippo"),
String.raw`if [ "$1" = "--version" ]; then cat "$HIPPO_VERSION_STATE"; exit 0; fi
if [ "$1" = "init" ]; then mkdir -p .hippo; printf 'hippo init\n' >> "$MOCK_LOG"; exit 0; fi
exit 0`,
);
}
const env = {
...process.env,
HOME: home,
PATH: `${bin}:/usr/bin:/bin`,
MOCK_BIN: bin,
MOCK_LOG: log,
HIPPO_VERSION_STATE: versionState,
PI_PACKAGE_SOURCE: repositoryRoot,
ZSH: "",
ZSH_CUSTOM: "",
ZDOTDIR: home,
};
return { root, home, bin, project, log, versionState, env };
}
async function logText(path: string): Promise<string> {
return readFile(path, "utf8");
}
test("install.sh leaves Hippo absent when installation is declined", async () => {
const f = await fixture();
const result = spawnSync("sh", [join(repositoryRoot, "install.sh")], {
cwd: f.project,
env: f.env,
input: "n\n".repeat(14),
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.doesNotMatch(await logText(f.log), /npm install -g hippo-memory/);
assert.match(result.stdout, /已跳过 Hippo Memory CLI/);
});
test("install.sh installs the pinned Hippo CLI without choosing an init directory", async () => {
const f = await fixture();
const input = `y\n${"n\n".repeat(12)}`;
const result = spawnSync("sh", [join(repositoryRoot, "install.sh")], {
cwd: f.project,
env: f.env,
input,
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const log = await logText(f.log);
assert.match(log, /npm install -g hippo-memory@1\.33\.0/);
assert.doesNotMatch(log, /hippo init/);
assert.equal(await readFile(f.versionState, "utf8"), "1.33.0\n");
assert.match(result.stdout, /未自动运行 hippo init/);
});
test("update.sh leaves an already matching Hippo CLI untouched", async () => {
const f = await fixture("1.33.0");
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
cwd: f.project,
env: f.env,
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const log = await logText(f.log);
assert.match(log, /npm view hippo-memory@1\.33\.0 version/);
assert.doesNotMatch(log, /npm install -g/);
assert.match(result.stdout, /已是组合包目标版本/);
});
test("update.sh upgrades an npm-managed older Hippo CLI exactly once", async () => {
const f = await fixture("1.32.0");
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
cwd: f.project,
env: f.env,
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const log = await logText(f.log);
assert.match(log, /npm list -g --depth=0 hippo-memory/);
assert.equal((log.match(/npm install -g hippo-memory@1\.33\.0/g) ?? []).length, 1);
assert.equal(await readFile(f.versionState, "utf8"), "1.33.0\n");
});
test("update.sh preserves a Hippo executable that is not npm globally managed", async () => {
const f = await fixture("1.32.0");
await executable(
join(f.bin, "npm"),
String.raw`printf 'npm %s\n' "$*" >> "$MOCK_LOG"
if [ "$1" = "view" ]; then printf '%s\n' '1.33.0'; exit 0; fi
if [ "$1" = "list" ]; then exit 1; fi
exit 0`,
);
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
cwd: f.project,
env: f.env,
encoding: "utf8",
timeout: 30_000,
});
assert.equal(result.status, 1);
const log = await logText(f.log);
assert.doesNotMatch(log, /npm install -g/);
assert.match(result.stderr, /不属于 npm 全局/);
});
test("uninstall.sh keeps Hippo by default and removes only after explicit approval", async () => {
const keep = await fixture("1.33.0");
await mkdir(join(keep.project, ".hippo"), { recursive: true });
await writeFile(join(keep.project, ".hippo", "sentinel"), "keep\n", "utf8");
const keepResult = spawnSync("sh", [join(repositoryRoot, "uninstall.sh")], {
cwd: keep.project,
env: keep.env,
input: "n\n",
encoding: "utf8",
timeout: 30_000,
});
assert.equal(keepResult.status, 0, keepResult.stderr || keepResult.stdout);
assert.doesNotMatch(await logText(keep.log), /npm uninstall/);
assert.equal(await readFile(join(keep.project, ".hippo", "sentinel"), "utf8"), "keep\n");
const remove = await fixture("1.33.0");
await mkdir(join(remove.project, ".hippo"), { recursive: true });
await writeFile(join(remove.project, ".hippo", "sentinel"), "keep\n", "utf8");
const removeResult = spawnSync("sh", [join(repositoryRoot, "uninstall.sh")], {
cwd: remove.project,
env: remove.env,
input: "y\n",
encoding: "utf8",
timeout: 30_000,
});
assert.equal(removeResult.status, 0, removeResult.stderr || removeResult.stdout);
assert.match(await logText(remove.log), /npm uninstall -g hippo-memory/);
assert.equal(await readFile(join(remove.project, ".hippo", "sentinel"), "utf8"), "keep\n");
});