mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-hermes-memory): 接入持久记忆扩展并移除 Hippo
用仓库内 pi-hermes-memory 替换 hippo-memory-pi,清理 Hippo 在组合包中的
全部注入点。
移除 Hippo:
- 删除 hippo-memory-pi/ 与 config/hippo-memory-version。
- extensions/plugin-init.ts 只保留 CodeGraph 初始化目标与预检。
- install.sh / update.sh / uninstall.sh 移除 Hippo CLI 的安装、升级、卸载
与版本校验逻辑;uninstall.sh 同时删除仅服务于该提示的死代码 ask_yes_no。
- 删除 tests/hippo-extension.test.ts 与 tests/hippo-scripts.test.ts。
- tests/extension-load.test.ts 反转“不得加载 Hermes”的断言为必须加载,
并断言其排在 pi-compaction 之前。
接入 pi-hermes-memory:
- package.json 增加 file:./pi-hermes-memory 依赖、files 条目与加载入口;
加载顺序位于 pi-compaction 之前,否则 flushOnCompact 会被 pi-compaction
的 { cancel: true } 静默跳过。
- 六个记忆工具在 Tool Search 中常驻:memory_search 与 session_search 连同
context_tree_query 归入 memory-recall,memory_add / memory_replace /
memory_remove / skill_manage 归入 memory-management。常驻是必需的——注入
的 memory policy 会在普通工作中指示模型调用它们,schema 不可见会让模型
静默放弃而不是去 tool_search,同时常驻也保持请求前缀缓存稳定。
- bundleDefaultsVersion 由 2 升到 3;migrate-tool-search-settings.mjs 新增
上一版快照到当前版本的迁移路径,仍只迁移完整匹配的默认快照并保留自定义
配置。
- config/pi-permission-policy.jsonc 为六个工具设置 allow:它们只写扩展自己的
agent 目录数据、不触碰工作区,且 memory_search 每轮都可能被调用,落入
默认 ask 会产生每轮 AutoReview 往返。
依赖:
- pi-hermes-memory 的 devDependencies 由上游 ^0.80.2 等范围改为与根包一致的
精确版本。上游范围与根包 0.84.2 不兼容,会让 npm 在本目录嵌套安装第二套
Pi runtime(实测 229 MB),违反“不得在组合包内再安装一套 Pi runtime”的
纪律。
- 根 allowScripts 保持只有 better-sqlite3@12.11.1。hermes 的
better-sqlite3@13 运行时 dlopen 的是包内 N-API 预编译二进制
prebuilds/<platform>.node,npm 因 binding.gyp 推断出的 node-gyp rebuild
无需执行;实测删除 build/ 目录后仍正常工作。
文档:AGENTS.md 与 README.md 改写记忆章节,新增 pi-hermes-memory 开发约定,
替换文末的 Hippo 项目记忆块。
验证:根 12 个测试文件、pi-tool-search 的 typecheck/test/build、
pi-permission-system 的 typecheck/2569 测试、pi-hermes-memory 自带的 59 个
测试文件全部通过;隔离 Pi 环境联合加载无错误并生成 bundleDefaultsVersion 3;
npm install 幂等;迁移 helper 对 v2 快照迁移、对自定义配置保留。
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -29,14 +29,15 @@ test("all package extensions load together without global registration conflicts
|
||||
dependencies: Record<string, string>;
|
||||
files: string[];
|
||||
pi: { extensions: string[] };
|
||||
allowScripts: Record<string, boolean>;
|
||||
};
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./extensions/plugin-init.ts"),
|
||||
"the bundle must expose the project plugin initializer command",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
|
||||
"the bundle must load the vendored official Hippo Memory extension",
|
||||
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||
"the bundle must load the vendored Hermes memory extension",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./pi-minimal-footer/index.ts"),
|
||||
@@ -145,19 +146,37 @@ test("all package extensions load together without global registration conflicts
|
||||
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
|
||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
|
||||
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||
"the bundle must load the locally maintained Hermes memory extension",
|
||||
);
|
||||
assert.equal(packageJson.dependencies["pi-hermes-memory"], "file:./pi-hermes-memory");
|
||||
assert.ok(
|
||||
packageJson.files.includes("pi-hermes-memory"),
|
||||
"the packed bundle must include the locally maintained Hermes memory source",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.indexOf("./pi-hermes-memory/src/index.ts") <
|
||||
packageJson.pi.extensions.indexOf("./pi-compaction/index.ts"),
|
||||
"the memory extension must register its session_before_compact flush before the compaction authority can cancel the event",
|
||||
);
|
||||
assert.equal(
|
||||
packageJson.pi.extensions.some((entry) => entry.includes("pi-hermes-memory") || entry.includes("the-forge-flow")),
|
||||
packageJson.pi.extensions.some((entry) => entry.includes("hippo-memory-pi") || entry.includes("the-forge-flow")),
|
||||
false,
|
||||
"retired Hermes and third-party Hippo extensions must not be loaded",
|
||||
"retired Hippo and third-party memory extensions must not be loaded",
|
||||
);
|
||||
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
|
||||
assert.equal(packageJson.dependencies["better-sqlite3"], undefined);
|
||||
assert.equal(packageJson.allowScripts["better-sqlite3@12.11.1"], true);
|
||||
assert.equal(
|
||||
packageJson.allowScripts["better-sqlite3@13.0.3"],
|
||||
undefined,
|
||||
"Hermes uses better-sqlite3 13 N-API prebuilds, so no extra install-script approval is required",
|
||||
);
|
||||
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("--mode", "rpc");
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
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");
|
||||
});
|
||||
+13
-23
@@ -11,12 +11,12 @@ import pluginInitExtension, {
|
||||
validatePluginInitCwd,
|
||||
} from "../extensions/plugin-init.ts";
|
||||
|
||||
test("initializes missing CodeGraph and Hippo state after preflighting both CLIs", async () => {
|
||||
test("initializes missing CodeGraph state after preflighting the CLI", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: Array<{ command: string; args: string[]; cwd: string }> = [];
|
||||
try {
|
||||
const targets = await findMissingPluginInitializers(cwd);
|
||||
assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph", "Hippo"]);
|
||||
assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph"]);
|
||||
|
||||
await initializeMissingPlugins(cwd, targets, async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
@@ -25,30 +25,25 @@ test("initializes missing CodeGraph and Hippo state after preflighting both CLIs
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ command: "codegraph", args: ["--version"], cwd },
|
||||
{ command: "hippo", args: ["--version"], cwd },
|
||||
{ command: "codegraph", args: ["init", cwd], cwd },
|
||||
{ command: "hippo", args: ["init"], cwd },
|
||||
]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the CLIs' database files as authoritative initialization markers", async () => {
|
||||
test("uses the CLI's database file as the authoritative initialization marker", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
await mkdir(join(cwd, ".codegraph"));
|
||||
await mkdir(join(cwd, ".hippo"));
|
||||
assert.deepEqual(
|
||||
(await findMissingPluginInitializers(cwd)).map(({ name }) => name),
|
||||
["CodeGraph", "Hippo"],
|
||||
"bare state directories must not count as initialized",
|
||||
["CodeGraph"],
|
||||
"a bare state directory must not count as initialized",
|
||||
);
|
||||
|
||||
await writeFile(join(cwd, ".codegraph", "codegraph.db"), "");
|
||||
assert.deepEqual((await findMissingPluginInitializers(cwd)).map(({ name }) => name), ["Hippo"]);
|
||||
await writeFile(join(cwd, ".hippo", "hippo.db"), "");
|
||||
const targets = await findMissingPluginInitializers(cwd);
|
||||
assert.deepEqual(targets, []);
|
||||
|
||||
@@ -56,7 +51,7 @@ test("uses the CLIs' database files as authoritative initialization markers", as
|
||||
calls.push(`${command} ${args.join(" ")}`);
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
});
|
||||
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
|
||||
assert.deepEqual(calls, ["codegraph --version"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -67,7 +62,7 @@ test("rejects unsafe project roots", () => {
|
||||
assert.throws(() => validatePluginInitCwd(homedir()), /用户主目录/);
|
||||
});
|
||||
|
||||
test("preflights both CLIs before creating project state", async () => {
|
||||
test("preflights the CLI before creating project state", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
@@ -75,13 +70,11 @@ test("preflights both CLIs before creating project state", async () => {
|
||||
await assert.rejects(
|
||||
initializeMissingPlugins(cwd, targets, async (command, args) => {
|
||||
calls.push(`${command} ${args.join(" ")}`);
|
||||
return command === "hippo"
|
||||
? { code: 1, stdout: "", stderr: "not installed" }
|
||||
: { code: 0, stdout: "ok", stderr: "" };
|
||||
return { code: 1, stdout: "", stderr: "not installed" };
|
||||
}),
|
||||
/Hippo 检测失败:not installed/,
|
||||
/CodeGraph 检测失败:not installed/,
|
||||
);
|
||||
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
|
||||
assert.deepEqual(calls, ["codegraph --version"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -125,9 +118,7 @@ test("plugin_init confirms the target and hot-reloads after initialization", asy
|
||||
assert.match(confirmation, new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.deepEqual(calls, [
|
||||
"codegraph --version",
|
||||
"hippo --version",
|
||||
`codegraph init ${cwd}`,
|
||||
"hippo init",
|
||||
]);
|
||||
assert.equal(reloaded, true);
|
||||
} finally {
|
||||
@@ -135,14 +126,14 @@ test("plugin_init confirms the target and hot-reloads after initialization", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin_init reports retained partial state and does not reload when a later initializer fails", async () => {
|
||||
test("plugin_init reports the failure and does not reload when initialization fails", async () => {
|
||||
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||
const pi = {
|
||||
registerCommand(_name: string, definition: typeof command) {
|
||||
command = definition;
|
||||
},
|
||||
exec: async (program: string, args: string[]) => ({
|
||||
code: program === "hippo" && args[0] === "init" ? 1 : 0,
|
||||
code: program === "codegraph" && args[0] === "init" ? 1 : 0,
|
||||
stdout: "",
|
||||
stderr: "mock failure",
|
||||
}),
|
||||
@@ -168,8 +159,7 @@ test("plugin_init reports retained partial state and does not reload when a late
|
||||
reloaded = true;
|
||||
},
|
||||
});
|
||||
assert.match(error, /Hippo 初始化失败:mock failure/);
|
||||
assert.match(error, /已完成的 CodeGraph 初始化会保留,不会自动回滚/);
|
||||
assert.match(error, /CodeGraph 初始化失败:mock failure/);
|
||||
assert.equal(reloaded, false);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CURRENT_TOOL_SEARCH_DEFAULTS,
|
||||
LEGACY_TOOL_SEARCH_DEFAULTS,
|
||||
PREVIOUS_TOOL_SEARCH_DEFAULTS,
|
||||
migrateToolSearchSettings,
|
||||
} from "../scripts/migrate-tool-search-settings.mjs";
|
||||
|
||||
@@ -89,6 +90,36 @@ test("recognizes current defaults and skips files without a Tool Search section"
|
||||
assert.deepEqual(await migrateToolSearchSettings(absent.settingsPath), { status: "missing-section" });
|
||||
});
|
||||
|
||||
test("migrates the previous versioned default snapshot and pins the Hermes memory tools", async (t) => {
|
||||
const { settingsPath } = await fixture(t);
|
||||
await writeFile(settingsPath, JSON.stringify({ toolSearch: PREVIOUS_TOOL_SEARCH_DEFAULTS }), "utf8");
|
||||
|
||||
const result = await migrateToolSearchSettings(settingsPath);
|
||||
assert.equal(result.status, "migrated");
|
||||
const migrated = JSON.parse(await readFile(settingsPath, "utf8")).toolSearch;
|
||||
assert.deepEqual(migrated, CURRENT_TOOL_SEARCH_DEFAULTS);
|
||||
assert.equal(migrated.bundleDefaultsVersion, 3);
|
||||
for (const name of ["memory_search", "session_search", "memory_add", "memory_replace", "memory_remove", "skill_manage"]) {
|
||||
assert.ok(migrated.alwaysEnabled.includes(name), `${name} must be pinned after migration`);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves custom settings that keep the previous capacities but pin extra tools", async (t) => {
|
||||
const { root, settingsPath } = await fixture(t);
|
||||
const custom = {
|
||||
toolSearch: {
|
||||
...PREVIOUS_TOOL_SEARCH_DEFAULTS,
|
||||
alwaysEnabled: [...PREVIOUS_TOOL_SEARCH_DEFAULTS.alwaysEnabled, "custom_tool"],
|
||||
},
|
||||
};
|
||||
const original = `${JSON.stringify(custom, null, 2)}\n`;
|
||||
await writeFile(settingsPath, original, "utf8");
|
||||
|
||||
assert.deepEqual(await migrateToolSearchSettings(settingsPath), { status: "custom" });
|
||||
assert.equal(await readFile(settingsPath, "utf8"), original);
|
||||
assert.deepEqual(await readdir(root), ["settings.json"]);
|
||||
});
|
||||
|
||||
test("fails safely for invalid JSON and reports a missing settings file", async (t) => {
|
||||
const invalid = await fixture(t);
|
||||
await writeFile(invalid.settingsPath, "{ invalid\n", "utf8");
|
||||
@@ -130,8 +161,8 @@ async function updateFixture(t: test.TestContext, piExitStatus = 0) {
|
||||
await mkdir(join(updatedBundle, "scripts"), { recursive: true });
|
||||
await copyFile(join(repositoryRoot, "config", "p10k.zsh"), join(updatedBundle, "config", "p10k.zsh"));
|
||||
await copyFile(
|
||||
join(repositoryRoot, "config", "hippo-memory-version"),
|
||||
join(updatedBundle, "config", "hippo-memory-version"),
|
||||
join(repositoryRoot, "config", "pi-hashline-edit.json"),
|
||||
join(updatedBundle, "config", "pi-hashline-edit.json"),
|
||||
);
|
||||
await copyFile(
|
||||
join(repositoryRoot, "scripts", "migrate-tool-search-settings.mjs"),
|
||||
|
||||
@@ -29,8 +29,8 @@ async function fixture(
|
||||
await mkdir(join(target, "config"), { recursive: true });
|
||||
await copyFile(join(repositoryRoot, "config", "p10k.zsh"), join(target, "config", "p10k.zsh"));
|
||||
await copyFile(
|
||||
join(repositoryRoot, "config", "hippo-memory-version"),
|
||||
join(target, "config", "hippo-memory-version"),
|
||||
join(repositoryRoot, "config", "pi-hashline-edit.json"),
|
||||
join(target, "config", "pi-hashline-edit.json"),
|
||||
);
|
||||
await mkdir(bin, { recursive: true });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user