feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照

从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10
(df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。
本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略
均未改动。

- 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、
  AGENTS 与 LICENSE;该快照共 156 个上游文件。
- 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、
  覆盖率与构建产物。
- 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项:
  在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts
  对齐、strip-ansi 解析确认。
- 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至
  24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为
  对应一个已发布、可复现的版本。

尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载
Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。

验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份;
已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
This commit is contained in:
云服务部-叶林立
2026-10-13 23:19:35 -07:00
parent 879fc5d37f
commit 312bb2ba5a
157 changed files with 52287 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,671 @@
/**
* Unit tests for correction detection — isCorrection() pattern matching
* and handler behavior (rate limiting, pi.exec trigger).
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseManager } from "../../src/store/db.js";
import { getMemories } from "../../src/store/sqlite-memory-store.js";
import { MemoryStore } from "../../src/store/memory-store.js";
import { registerMemoryTool } from "../../src/tools/memory-tool.js";
import { isCorrection, setupCorrectionDetector } from "../../src/handlers/correction-detector.js";
import { resolveWatchedChildPiInvocation } from "../../src/handlers/pi-child-process.js";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { MemoryConfig } from "../../src/types.js";
// ─── Pattern matching tests ───
describe("isCorrection", () => {
// ── Strong patterns (always trigger) ──
describe("strong patterns (always trigger)", () => {
it("matches 'don't do that'", () => {
assert.strictEqual(isCorrection("don't do that"), true);
});
it("matches 'not like that'", () => {
assert.strictEqual(isCorrection("not like that"), true);
});
it("matches 'I said use yarn'", () => {
assert.strictEqual(isCorrection("I said use yarn"), true);
});
it("matches 'I told you already'", () => {
assert.strictEqual(isCorrection("I told you already"), true);
});
it("matches 'we already discussed this'", () => {
assert.strictEqual(isCorrection("we already discussed this"), true);
});
it("matches 'please don't commit yet'", () => {
assert.strictEqual(isCorrection("please don't commit yet"), true);
});
it("matches \"that's not what I asked for\"", () => {
assert.strictEqual(isCorrection("that's not what I asked for"), true);
});
});
// ── Weak patterns (need directive clause) ──
describe("weak patterns (need directive clause)", () => {
it("matches 'no, use yarn instead' (has directive 'use')", () => {
assert.strictEqual(isCorrection("no, use yarn instead"), true);
});
it("matches 'wrong, the file is in src/' (has directive 'the')", () => {
assert.strictEqual(isCorrection("wrong, the file is in src/"), true);
});
it("matches 'actually, don't use that' (has directive 'don't')", () => {
assert.strictEqual(isCorrection("actually, don't use that"), true);
});
it("matches 'stop, fix the test first' (has directive 'fix')", () => {
assert.strictEqual(isCorrection("stop, fix the test first"), true);
});
it("matches 'no! delete that file' (has directive 'delete')", () => {
assert.strictEqual(isCorrection("no! delete that file"), true);
});
it("does NOT match 'no just kidding' (no directive clause)", () => {
assert.strictEqual(isCorrection("no just kidding"), false);
});
});
// ── Negative patterns (suppress even if positive matches) ──
describe("negative patterns (suppress false positives)", () => {
it("suppresses 'no worries, I'll handle it'", () => {
assert.strictEqual(isCorrection("no worries, I'll handle it"), false);
});
it("suppresses 'no problem'", () => {
assert.strictEqual(isCorrection("no problem"), false);
});
it("suppresses 'no thanks'", () => {
assert.strictEqual(isCorrection("no thanks"), false);
});
it("suppresses 'no need to change that'", () => {
assert.strictEqual(isCorrection("no need to change that"), false);
});
it("suppresses 'actually, that looks great'", () => {
assert.strictEqual(isCorrection("actually, that looks great"), false);
});
it("suppresses 'actually, perfect'", () => {
assert.strictEqual(isCorrection("actually, perfect"), false);
});
it("suppresses 'actually, that's correct'", () => {
assert.strictEqual(isCorrection("actually, that's correct"), false);
});
it("suppresses 'stop there'", () => {
assert.strictEqual(isCorrection("stop there"), false);
});
it("suppresses 'stop here'", () => {
assert.strictEqual(isCorrection("stop here"), false);
});
it("suppresses 'stop for now'", () => {
assert.strictEqual(isCorrection("stop for now"), false);
});
});
// ── Non-corrections (should NOT trigger) ──
describe("non-corrections (should NOT trigger)", () => {
it("does NOT match 'yes, do that'", () => {
assert.strictEqual(isCorrection("yes, do that"), false);
});
it("does NOT match 'looks good'", () => {
assert.strictEqual(isCorrection("looks good"), false);
});
it("does NOT match 'can you also check the tests?'", () => {
assert.strictEqual(isCorrection("can you also check the tests?"), false);
});
it("does NOT match empty string", () => {
assert.strictEqual(isCorrection(""), false);
});
it("does NOT match 'thanks'", () => {
assert.strictEqual(isCorrection("thanks"), false);
});
it("does NOT match 'great, that works'", () => {
assert.strictEqual(isCorrection("great, that works"), false);
});
it("does NOT match 'please continue'", () => {
assert.strictEqual(isCorrection("please continue"), false);
});
});
// ── Case insensitivity ──
describe("case insensitivity", () => {
it("matches 'DON'T DO THAT' (uppercase)", () => {
assert.strictEqual(isCorrection("DON'T DO THAT"), true);
});
it("matches 'I Told You Already' (mixed case)", () => {
assert.strictEqual(isCorrection("I Told You Already"), true);
});
it("suppresses 'No Worries' (uppercase negative)", () => {
assert.strictEqual(isCorrection("No Worries"), false);
});
});
describe("custom pattern config", () => {
it("matches custom strong patterns", () => {
assert.strictEqual(
isCorrection("custom correction", { correctionStrongPatterns: ["^custom correction$"] }),
true,
);
});
it("uses custom negative patterns to suppress matches", () => {
assert.strictEqual(
isCorrection("custom correction", {
correctionStrongPatterns: ["^custom"],
correctionNegativePatterns: ["^custom correction$"],
}),
false,
);
});
it("uses custom directive words for weak patterns", () => {
assert.strictEqual(
isCorrection("no, shipit now", { correctionDirectiveWords: ["shipit"] }),
true,
);
assert.strictEqual(
isCorrection("no, use yarn", { correctionDirectiveWords: ["shipit"] }),
false,
);
});
it("ignores invalid custom regex entries and keeps valid entries", () => {
assert.strictEqual(
isCorrection("custom correction", { correctionStrongPatterns: ["bad(", "^custom"] }),
true,
);
});
it("treats explicit empty or all-invalid pattern arrays as empty", () => {
assert.strictEqual(
isCorrection("don't do that", { correctionStrongPatterns: [] }),
false,
);
assert.strictEqual(
isCorrection("don't do that", { correctionStrongPatterns: ["bad("] }),
false,
);
});
});
});
// ─── Handler behavior tests ───
describe("setupCorrectionDetector handler", () => {
let handlers: Record<string, Function[]>;
let execCalls: any[];
let notifyCalls: any[];
let tmpDir: string;
let dbManager: DatabaseManager;
function createMockPi(execReturn?: { code: number; stdout: string; stderr: string }) {
const ret = execReturn ?? { code: 0, stdout: "Saved correction", stderr: "" };
return {
on: (event: string, handler: Function) => {
handlers[event] = handlers[event] || [];
handlers[event].push(handler);
},
exec: async (...args: any[]) => {
execCalls.push(args);
return ret;
},
registerTool: () => {},
registerCommand: () => {},
} as any;
}
let directCalls: unknown[][];
function makeDirectDeps(
result: { ok: boolean; appliedCount: number } | "throw",
): { runDirectMemoryCompletion: (...args: unknown[]) => Promise<{ ok: boolean; appliedCount: number }> } {
return {
runDirectMemoryCompletion: async (...args: unknown[]) => {
directCalls.push(args);
if (result === "throw") throw new Error("injected direct correction failure");
return result;
},
};
}
function correctionBranch(userText = "don't do that") {
return [
{ type: "message", message: { role: "user", content: [{ type: "text", text: userText }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "I used npm" }] } },
];
}
function storeWithFailureTracking() {
let failureCount = 0;
return {
store: {
getMemoryEntries: () => ["existing entry"],
getUserEntries: () => [],
addFailure: async () => {
failureCount += 1;
},
getFailureCount: () => failureCount,
},
};
}
function logicalChildArgs(call: any[]): string[] {
const [cmd, args] = call;
const underlying = { command: args[3], args: args.slice(4) };
const expected = resolveWatchedChildPiInvocation(underlying, Number(args[1]), args[2]);
assert.strictEqual(cmd, expected.command);
assert.deepStrictEqual(args, expected.args);
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
}
const mockStore = {
getMemoryEntries: () => ["existing entry"],
getUserEntries: () => [],
} as any;
const config = {
correctionDetection: true,
nudgeInterval: 10,
reviewEnabled: false,
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 6,
nudgeToolCalls: 15,
consolidationTimeoutMs: 60_000,
};
const directTransportConfig: MemoryConfig = { ...config, reviewTransport: "direct" };
function makeCtx(branch: any[] = []) {
return {
sessionManager: { getBranch: () => branch },
signal: undefined,
cwd: "/tmp/active-project",
model: { provider: "local-llama", id: "local-9b" },
modelRegistry: {},
ui: {
notify: (msg: string, level: string) => {
notifyCalls.push({ msg, level });
},
},
};
}
function fireMessageEnd(role: string, text: string) {
const h = handlers["message_end"];
if (!h) throw new Error("No message_end handler registered");
for (const fn of h) {
fn({ message: { role, content: [{ type: "text", text }] } }, makeCtx());
}
}
function fireTurnEnd(branch: any[] = []): Promise<unknown> {
const h = handlers["turn_end"];
if (!h) throw new Error("No turn_end handler registered");
const ctx = makeCtx(branch);
return Promise.all(h.map((fn) => fn({}, ctx)));
}
beforeEach(() => {
handlers = {};
execCalls = [];
notifyCalls = [];
directCalls = [];
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "correction-detector-test-"));
dbManager = new DatabaseManager(tmpDir);
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("triggers pi.exec when correction detected", async () => {
const pi = createMockPi();
setupCorrectionDetector(pi, mockStore, null, config);
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "don't do that" }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
];
fireMessageEnd("user", "don't do that");
await fireTurnEnd(branch);
assert.ok(execCalls.length >= 1, "pi.exec should be called on correction");
const options = execCalls[0][2] as { cwd?: string };
assert.equal(options.cwd, "/tmp/active-project");
const args = logicalChildArgs(execCalls[0]);
assert.deepStrictEqual(args.slice(0, 4), [
"-p", "--no-session", "--model", "local-llama/local-9b",
]);
});
it("passes child LLM override args and defaults thinking to off when only a model override is set", async () => {
const pi = createMockPi();
setupCorrectionDetector(pi, mockStore, null, {
...config,
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
});
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "don't do that" }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
];
fireMessageEnd("user", "don't do that");
await fireTurnEnd(branch);
const cmdArgs = logicalChildArgs(execCalls[0]);
assert.deepStrictEqual(
cmdArgs.slice(0, 6),
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off"],
);
});
it("does NOT trigger on normal messages", async () => {
const pi = createMockPi();
setupCorrectionDetector(pi, mockStore, null, config);
fireMessageEnd("user", "looks good");
await fireTurnEnd([]);
assert.strictEqual(execCalls.length, 0, "pi.exec should NOT be called for normal messages");
});
it("rate limits: does not trigger on consecutive corrections within 3 turns", async () => {
const pi = createMockPi();
setupCorrectionDetector(pi, mockStore, null, config);
// First correction
fireMessageEnd("user", "don't do that");
await fireTurnEnd([]);
const firstCallCount = execCalls.length;
assert.ok(firstCallCount >= 1, "first correction should trigger");
// Second correction within 3 turns — should be rate-limited
fireMessageEnd("user", "not like that");
await fireTurnEnd([]);
assert.strictEqual(execCalls.length, firstCallCount, "second correction should be rate-limited");
});
it("syncs direct correction saves into SQLite", async () => {
const pi = createMockPi();
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
await correctionStore.loadFromDisk();
registerMemoryTool(pi, correctionStore, null, dbManager);
setupCorrectionDetector(pi, correctionStore, null, config, dbManager);
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use pnpm instead" }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
];
fireMessageEnd("user", "no, use pnpm instead");
await fireTurnEnd(branch);
const failures = getMemories(dbManager, { target: 'failure' });
assert.strictEqual(failures.length, 1);
assert.match(failures[0].content, /use pnpm instead/);
assert.strictEqual(failures[0].category, 'correction');
assert.strictEqual(failures[0].project, null);
});
it("saves the latest user message as the correction failure", async () => {
const pi = createMockPi();
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
await correctionStore.loadFromDisk();
setupCorrectionDetector(pi, correctionStore, null, config);
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "First, use Maven for the build." }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "I will use Maven." }] } },
{ type: "message", message: { role: "user", content: [{ type: "text", text: "Continue with the implementation." }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "Implemented." }] } },
{ type: "message", message: { role: "user", content: [{ type: "text", text: "No, that is incorrect. Use javac only, not Maven." }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "Understood." }] } },
];
fireMessageEnd("user", "No, that is incorrect. Use javac only, not Maven.");
await fireTurnEnd(branch);
const failures = correctionStore.getFailureEntries();
assert.strictEqual(failures.length, 1);
assert.match(failures[0], /use javac only, not Maven/i);
assert.doesNotMatch(failures[0], /First, use Maven/i);
});
it("syncs project correction saves into SQLite with project scope", async () => {
const pi = createMockPi();
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
await correctionStore.loadFromDisk();
const projectStore = {
getMemoryEntries: () => [],
} as any;
registerMemoryTool(pi, correctionStore, projectStore, dbManager, 'project-a');
setupCorrectionDetector(pi, correctionStore, projectStore, config, dbManager, 'project-a');
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use pnpm in this repo" }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
];
fireMessageEnd("user", "no, use pnpm in this repo");
await fireTurnEnd(branch);
const projectFailures = getMemories(dbManager, { target: 'failure', project: 'project-a' });
assert.strictEqual(projectFailures.length, 1);
assert.match(projectFailures[0].content, /use pnpm in this repo/);
assert.doesNotMatch(projectFailures[0].content, /Project: project-a/);
assert.strictEqual(projectFailures[0].category, 'correction');
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: null }).length, 0);
});
it("does not break correction handling when SQLite sync fails", async () => {
const pi = createMockPi();
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
await correctionStore.loadFromDisk();
const failingDbManager = {
getDb: () => {
throw new Error('sqlite unavailable');
},
} as unknown as DatabaseManager;
registerMemoryTool(pi, correctionStore, null, failingDbManager);
setupCorrectionDetector(pi, correctionStore, null, config, failingDbManager);
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use yarn instead" }] } },
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
];
fireMessageEnd("user", "no, use yarn instead");
await fireTurnEnd(branch);
assert.ok(execCalls.length >= 1, 'correction review should still run');
assert.strictEqual(correctionStore.getFailureEntries().length, 1, 'Markdown correction save should still happen');
});
describe("direct memory completion transport", () => {
it("skips subprocess and notifies when direct succeeds with applied memories", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
null,
directTransportConfig,
null,
null,
makeDirectDeps({ ok: true, appliedCount: 1 }),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
assert.strictEqual(directCalls.length, 1);
assert.strictEqual(execCalls.length, 0, "subprocess must not run on successful direct correction");
assert.deepStrictEqual(
notifyCalls.filter((n) => n.msg === "🔧 Correction detected — memory updated"),
[{ msg: "🔧 Correction detected — memory updated", level: "info" }],
);
});
it("includes project and failure target routing in direct correction prompts", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
const projectStore = { getMemoryEntries: () => ["existing project fact"] } as unknown as Parameters<typeof setupCorrectionDetector>[2];
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
projectStore,
directTransportConfig,
null,
"project-a",
makeDirectDeps({ ok: true, appliedCount: 1 }),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
const options = directCalls[0][3] as { systemPrompt: string; userPrompt: string };
assert.match(options.systemPrompt, /project-specific facts.*target "project"/i);
assert.match(options.systemPrompt, /failures, corrections.*target "failure"/i);
assert.match(options.userPrompt, /--- Current Project Memory ---/);
});
it("skips subprocess and omits memory-updated notify when direct ok with zero applied", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
null,
directTransportConfig,
null,
null,
makeDirectDeps({ ok: true, appliedCount: 0 }),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
assert.strictEqual(directCalls.length, 1);
assert.strictEqual(execCalls.length, 0, "ok:true with appliedCount 0 still completes via direct transport");
assert.strictEqual(
notifyCalls.some((n) => n.msg === "🔧 Correction detected — memory updated"),
false,
);
});
it("falls back to subprocess when direct returns ok false", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
null,
directTransportConfig,
null,
null,
makeDirectDeps({ ok: false, appliedCount: 0 }),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
assert.strictEqual(directCalls.length, 1);
assert.ok(execCalls.length >= 1, "failed direct result must fall back to subprocess");
});
it("falls back to subprocess when direct throws without propagating", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
null,
directTransportConfig,
null,
null,
makeDirectDeps("throw"),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
assert.strictEqual(directCalls.length, 1);
assert.ok(execCalls.length >= 1, "thrown direct error must fall back to subprocess");
});
it("uses subprocess only when reviewTransport is subprocess", async () => {
const pi = createMockPi();
const { store } = storeWithFailureTracking();
const subprocessConfig: MemoryConfig = { ...config, reviewTransport: "subprocess" };
setupCorrectionDetector(
pi,
store as unknown as MemoryStore,
null,
subprocessConfig,
null,
null,
makeDirectDeps({ ok: true, appliedCount: 1 }),
);
fireMessageEnd("user", "don't do that");
await fireTurnEnd(correctionBranch());
assert.strictEqual(directCalls.length, 0, "direct transport must be skipped for subprocess config");
assert.ok(execCalls.length >= 1, "subprocess must run when direct transport is disabled");
});
});
it("does not register handlers when correctionDetection is false", () => {
const pi = createMockPi();
const disabledConfig = { ...config, correctionDetection: false };
setupCorrectionDetector(pi, mockStore, null, disabledConfig);
assert.strictEqual(Object.keys(handlers).length, 0, "no handlers should be registered when disabled");
});
});
@@ -0,0 +1,213 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { registerInsightsCommand } from "../../src/handlers/insights.js";
import { DatabaseManager } from "../../src/store/db.js";
import { addMemory, recordSearchHits } from "../../src/store/sqlite-memory-store.js";
/**
* Create a mock store with given memory and user entries,
* then register the insights command and return the handler
* plus the captured notify calls.
*/
async function setupCommand(
memoryEntries: string[],
userEntries: string[],
dbManager: DatabaseManager | null = null,
): Promise<{
handler: Function;
notifyCalls: { message: string; severity: string }[];
}> {
const notifyCalls: { message: string; severity: string }[] = [];
const mockStore = {
getMemoryEntries: () => [...memoryEntries],
getUserEntries: () => [...userEntries],
};
const commands: { name: string; handler: Function }[] = [];
const mockPi = {
registerCommand: (name: string, opts: any) => {
commands.push({ name, handler: opts.handler });
},
};
registerInsightsCommand(mockPi as any, mockStore as any, null, "test-project", dbManager);
assert.ok(commands.length === 1, "Expected exactly one registered command");
return { handler: commands[0].handler, notifyCalls };
}
/** Invoke the handler with a fake context that captures notify calls. */
async function invoke(
handler: Function,
notifyCalls: { message: string; severity: string }[],
): Promise<string> {
await handler({}, {
ui: {
notify: (message: string, severity: string) => {
notifyCalls.push({ message, severity });
},
},
});
assert.ok(notifyCalls.length === 1, "Expected exactly one notify call");
return notifyCalls[0].message;
}
describe("registerInsightsCommand", () => {
it("command is registered", async () => {
const { handler } = await setupCommand([], []);
assert.ok(typeof handler === "function");
});
it("shows MEMORY section with numbered entries", async () => {
const { handler, notifyCalls } = await setupCommand(
["first entry", "second entry"],
[],
);
const output = await invoke(handler, notifyCalls);
assert.match(output, /1\.\s/);
assert.match(output, /2\.\s/);
assert.ok(output.includes("first entry"));
assert.ok(output.includes("second entry"));
});
it("shows USER PROFILE section", async () => {
const { handler, notifyCalls } = await setupCommand([], ["user fact"]);
const output = await invoke(handler, notifyCalls);
assert.ok(output.includes("USER PROFILE"));
assert.ok(output.includes("user fact"));
});
it('shows "(empty)" when no entries exist', async () => {
const { handler, notifyCalls } = await setupCommand([], []);
const output = await invoke(handler, notifyCalls);
const emptyMatches = output.match(/\(empty\)/g);
assert.ok(
emptyMatches && emptyMatches.length === 2,
"Expected (empty) in both MEMORY and USER sections",
);
});
it("entries truncated to 100 chars", async () => {
const longEntry = "x".repeat(200);
const { handler, notifyCalls } = await setupCommand([longEntry], []);
const output = await invoke(handler, notifyCalls);
// Should contain first 100 chars + "..."
assert.ok(output.includes("x".repeat(100) + "..."));
// Should NOT contain the full 200-char entry
assert.ok(!output.includes("x".repeat(200)));
});
it("box drawing characters in output", async () => {
const { handler, notifyCalls } = await setupCommand([], []);
const output = await invoke(handler, notifyCalls);
assert.ok(output.includes("╔"));
assert.ok(output.includes("═"));
assert.ok(output.includes("║"));
assert.ok(output.includes("╗"));
assert.ok(output.includes("╚"));
assert.ok(output.includes("╝"));
});
it("notification called with info severity", async () => {
const { handler, notifyCalls } = await setupCommand([], []);
await invoke(handler, notifyCalls);
assert.strictEqual(notifyCalls[0].severity, "info");
});
it("multiple entries displayed correctly", async () => {
const mem = ["mem1", "mem2", "mem3"];
const usr = ["usr1", "usr2"];
const { handler, notifyCalls } = await setupCommand(mem, usr);
const output = await invoke(handler, notifyCalls);
for (const m of mem) {
assert.ok(output.includes(m), `Missing memory entry: ${m}`);
}
for (const u of usr) {
assert.ok(output.includes(u), `Missing user entry: ${u}`);
}
});
it("entry numbering is sequential", async () => {
const { handler, notifyCalls } = await setupCommand(
["alpha", "beta", "gamma"],
[],
);
const output = await invoke(handler, notifyCalls);
assert.match(output, /1\.\s*alpha/);
assert.match(output, /2\.\s*beta/);
assert.match(output, /3\.\s*gamma/);
});
it("unicode content renders correctly", async () => {
const entries = ["user loves 🍕 pizza", "works on 🚀 rockets"];
const { handler, notifyCalls } = await setupCommand([], entries);
const output = await invoke(handler, notifyCalls);
assert.ok(output.includes("🍕"));
assert.ok(output.includes("🚀"));
assert.ok(output.includes("pizza"));
assert.ok(output.includes("rockets"));
});
});
// ─── Usage section (PR-2 promotion gate) ───
describe("registerInsightsCommand usage section", () => {
it("omits the usage section when no dbManager is provided", async () => {
const { handler, notifyCalls } = await setupCommand(["entry one"], []);
const output = await invoke(handler, notifyCalls);
assert.ok(!output.includes("USAGE"), "no usage section without a database");
});
it("renders recalled-entry stats from the database", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-insights-usage-"));
try {
const dbManager = new DatabaseManager(tmpDir);
const recalled = addMemory(dbManager, "often recalled insight", "memory");
const rare = addMemory(dbManager, "rarely recalled insight", "memory");
recordSearchHits(dbManager, [recalled.id, rare.id]);
recordSearchHits(dbManager, [recalled.id]);
const { handler, notifyCalls } = await setupCommand(["often recalled insight", "rarely recalled insight"], [], dbManager);
const output = await invoke(handler, notifyCalls);
assert.ok(output.includes("USAGE — global memory: 2 of 2 entries recalled"), output);
assert.match(output, /1\.\s*2x \(last \d{4}-\d{2}-\d{2}\) — often recalled insight/);
assert.match(output, /2\.\s*1x \(last \d{4}-\d{2}-\d{2}\) — rarely recalled insight/);
dbManager.close();
} finally {
// Windows keeps the SQLite dir warm for a beat after close — best effort.
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
it("renders a not-yet-tracked note when entries exist but nothing was recalled", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-insights-usage-"));
try {
const dbManager = new DatabaseManager(tmpDir);
addMemory(dbManager, "never recalled entry", "memory");
const { handler, notifyCalls } = await setupCommand(["never recalled entry"], [], dbManager);
const output = await invoke(handler, notifyCalls);
assert.ok(output.includes("USAGE — global memory: 0 of 1 entries recalled"), output);
assert.ok(output.includes("no recorded recalls yet"));
dbManager.close();
} finally {
// Windows keeps the SQLite dir warm for a beat after close — best effort.
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
@@ -0,0 +1,127 @@
/**
* Unit tests for /memory-interview command.
*/
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { registerInterviewCommand } from "../../src/handlers/interview.js";
// ─── Mock infrastructure ───
let handlers: Record<string, Function[]>;
let notifyCalls: Array<{ msg: string; level: string }>;
let sentMessages: string[];
function createMockPi() {
const pi = {
on: () => {},
registerTool: () => {},
registerCommand: (_name: string, opts: { description: string; handler: Function }) => {
handlers[_name] = [{ fn: opts.handler, desc: opts.description }];
},
sendUserMessage: (text: string) => {
sentMessages.push(text);
},
};
return pi;
}
function makeMockCtx() {
return {
sessionManager: { getBranch: () => [] },
signal: undefined as any,
ui: {
notify: (msg: string, level: string) => {
notifyCalls.push({ msg, level });
},
},
waitForIdle: async () => {},
} as any;
}
const mockStore = (entries: string[]) => ({
getUserEntries: () => entries,
getMemoryEntries: () => [],
} as any);
// ─── Tests ───
describe("registerInterviewCommand", () => {
beforeEach(() => {
handlers = {};
notifyCalls = [];
sentMessages = [];
});
it("registers the /memory-interview command", () => {
const pi = createMockPi();
registerInterviewCommand(pi as any, mockStore([]));
const cmd = handlers["memory-interview"];
assert.ok(cmd, "command should be registered");
assert.ok(cmd.length === 1, "should have one handler");
});
it("command description mentions onboarding", () => {
const pi = createMockPi();
registerInterviewCommand(pi as any, mockStore([]));
const cmd = handlers["memory-interview"][0];
assert.ok(cmd.desc.includes("user profile"), "description should mention user profile");
});
it("sends interview prompt as user message when USER.md is empty", async () => {
const pi = createMockPi();
registerInterviewCommand(pi as any, mockStore([]));
const ctx = makeMockCtx();
await handlers["memory-interview"][0].fn(undefined, ctx);
assert.strictEqual(sentMessages.length, 1, "should send one user message");
assert.ok(sentMessages[0].includes("onboarding interview"), "message should be the interview prompt");
assert.ok(sentMessages[0].includes("timezone"), "prompt should ask about timezone");
assert.ok(sentMessages[0].includes("work style"), "prompt should ask about work style");
});
it("shows notification when USER.md already has entries", async () => {
const pi = createMockPi();
const store = mockStore(["name: Chandrateja", "prefers concise answers"]);
registerInterviewCommand(pi as any, store);
const ctx = makeMockCtx();
await handlers["memory-interview"][0].fn(undefined, ctx);
assert.ok(notifyCalls.length >= 1, "should show notification for existing entries");
assert.ok(
notifyCalls[0].msg.includes("2 profile entries"),
"notification should mention entry count",
);
assert.ok(
notifyCalls[0].msg.includes("Chandrateja"),
"notification should show existing entry preview",
);
});
it("still sends interview prompt even when entries exist", async () => {
const pi = createMockPi();
const store = mockStore(["existing entry"]);
registerInterviewCommand(pi as any, store);
const ctx = makeMockCtx();
await handlers["memory-interview"][0].fn(undefined, ctx);
assert.strictEqual(sentMessages.length, 1, "should still send interview prompt");
});
it("uses correct message count grammar (1 entry)", async () => {
const pi = createMockPi();
const store = mockStore(["one entry only"]);
registerInterviewCommand(pi as any, store);
const ctx = makeMockCtx();
await handlers["memory-interview"][0].fn(undefined, ctx);
assert.ok(notifyCalls[0].msg.includes("1 profile entry"), "should use singular");
assert.ok(!notifyCalls[0].msg.includes("entries"), "should NOT use plural for 1 entry");
});
});
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { setupBackgroundReview } from "../../src/handlers/background-review.js";
import { setupCorrectionDetector } from "../../src/handlers/correction-detector.js";
import { setupSessionFlush } from "../../src/handlers/session-flush.js";
import { loadConfig } from "../../src/config.js";
function fixture() {
const config = { ...loadConfig("/nonexistent/pi-memory-hook-test.json"), nudgeInterval: 1, flushMinTurns: 2 };
const handlers: Record<string, Function[]> = {};
const pi = { on(name: string, fn: Function) { (handlers[name] ??= []).push(fn); } } as any;
let ready = false;
let loads = 0;
let calls = 0;
const store = {
getMemoryEntries() { assert.ok(ready); return ["existing fact"]; },
getUserEntries() { assert.ok(ready); return []; },
async addFailure() { assert.ok(ready); return { success: true }; },
} as any;
const ctx = {
cwd: "/project",
sessionManager: {
getBranch: () => Array.from({ length: 8 }, (_, i) => ({
type: "message", message: { role: i % 2 ? "assistant" : "user", content: "no, use pnpm" },
})),
},
ui: { notify() {} },
};
const ensureMemoryReady = async (context: { cwd: string }) => {
assert.equal(context.cwd, ctx.cwd);
loads++;
ready = true;
};
const runDirectMemoryCompletion = async () => {
assert.ok(ready);
calls++;
return { ok: true, appliedCount: 0 };
};
const emit = async (name: string, event: any = {}) => {
for (const handler of handlers[name] ?? []) await handler(event, ctx);
};
return { pi, config, store, emit, ensureMemoryReady, runDirectMemoryCompletion,
loads: () => loads, calls: () => calls };
}
describe("lazy memory automatic operations", () => {
it("initializes only after a background review actually becomes due", async () => {
const f = fixture();
const settled = Promise.withResolvers<void>();
setupBackgroundReview(f.pi, f.store, null, f.config, {
ensureMemoryReady: f.ensureMemoryReady,
deps: { runDirectReview: f.runDirectMemoryCompletion, onReviewSettled: settled.resolve },
});
await f.emit("message_end", { message: { role: "user" } });
await f.emit("turn_end");
assert.equal(f.loads(), 0);
await f.emit("message_end", { message: { role: "user" } });
await f.emit("message_end", { message: { role: "user" } });
await f.emit("turn_end");
await settled.promise;
assert.equal(f.loads(), 1);
assert.equal(f.calls(), 1);
await f.emit("session_shutdown");
});
it("initializes before correction capture, not on ordinary messages", async () => {
const f = fixture();
setupCorrectionDetector(f.pi, f.store, null, f.config, null, null, {
ensureMemoryReady: f.ensureMemoryReady, runDirectMemoryCompletion: f.runDirectMemoryCompletion,
});
await f.emit("message_end", { message: { role: "user", content: "hello" } });
await f.emit("turn_end");
assert.equal(f.loads(), 0);
await f.emit("message_end", { message: { role: "user", content: "no, use pnpm" } });
await f.emit("turn_end");
assert.equal(f.loads(), 1);
assert.equal(f.calls(), 1);
});
it("initializes for a qualifying flush, not a short session or reload", async () => {
const f = fixture();
setupSessionFlush(f.pi, f.store, null, f.config, null, null, {
ensureMemoryReady: f.ensureMemoryReady, runDirectMemoryCompletion: f.runDirectMemoryCompletion,
});
await f.emit("message_end", { message: { role: "user" } });
await f.emit("session_before_compact");
assert.equal(f.loads(), 0);
await f.emit("message_end", { message: { role: "user" } });
await f.emit("session_shutdown", { reason: "reload" });
assert.equal(f.loads(), 0);
await f.emit("session_before_compact");
assert.equal(f.loads(), 1);
assert.equal(f.calls(), 1);
});
it("does not flush against unloaded memory after an initialization error", async () => {
const f = fixture();
setupSessionFlush(f.pi, f.store, null, { ...f.config, flushMinTurns: 0 }, null, null, {
ensureMemoryReady: async () => { throw new Error("load failed"); },
runDirectMemoryCompletion: f.runDirectMemoryCompletion,
});
await f.emit("session_before_compact");
assert.equal(f.calls(), 0);
});
it("retries a pending correction without consuming its rate limit on initialization failure", async () => {
const f = fixture();
let attempts = 0;
setupCorrectionDetector(f.pi, f.store, null, f.config, null, null, {
ensureMemoryReady: async (ctx) => {
if (++attempts === 1) throw new Error("transient load failure");
await f.ensureMemoryReady(ctx);
},
runDirectMemoryCompletion: f.runDirectMemoryCompletion,
});
await f.emit("message_end", { message: { role: "user", content: "no, use pnpm" } });
await f.emit("turn_end");
assert.equal(f.calls(), 0);
let saved = "";
f.store.addFailure = async (text: string) => { saved = text; return { success: true }; };
await f.emit("message_end", { message: { role: "user", content: "continue with the work" } });
await f.emit("turn_end");
assert.equal(attempts, 2);
assert.equal(f.calls(), 1);
assert.match(saved, /use pnpm/);
assert.doesNotMatch(saved, /continue with the work/);
});
});
@@ -0,0 +1,905 @@
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
buildChildPiPromptArgs,
detectAuthAdapterExtensionPaths,
execChildPrompt,
inheritedExtensionArgs,
resolveChildPiInvocation,
resolveWatchedChildPiInvocation,
} from "../../src/handlers/pi-child-process.js";
function logicalChildArgs(call: { cmd: string; args: string[] }): string[] {
const underlying = { command: call.args[3], args: call.args.slice(4) };
const expected = resolveWatchedChildPiInvocation(underlying, 30000, call.args[2]);
assert.deepStrictEqual(call, { cmd: expected.command, args: expected.args });
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
}
// Compute the expected OWN_EXTENSION_PATH same logic as pi-child-process.ts
const OWN_EXTENSION_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../src/index.ts",
);
// Adapter detection scans the real sibling/agent node_modules trees, so a
// contributor with a provider auth adapter installed gets extra -e args in
// every child invocation. Derive them instead of assuming a bare environment.
const DETECTED_ADAPTER_ARGS = detectAuthAdapterExtensionPaths().flatMap((p) => ["-e", p]);
const EXT_ARGS = ["--no-extensions", "-e", OWN_EXTENSION_PATH, ...DETECTED_ADAPTER_ARGS];
describe("inheritedExtensionArgs", () => {
it("captures explicit -e and --extension parent args", () => {
assert.deepStrictEqual(
inheritedExtensionArgs(["-e", "src/index.ts", "--extension", "/tmp/other.ts"]),
["-e", "src/index.ts", "--extension", "/tmp/other.ts"],
);
});
it("captures --extension=... parent args", () => {
assert.deepStrictEqual(
inheritedExtensionArgs(["--extension=src/index.ts"]),
["--extension=src/index.ts"],
);
});
});
describe("detectAuthAdapterExtensionPaths", () => {
it("returns empty when no sibling packages exist under the root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-empty-"));
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("detects a convention-named adapter via its package.json manifest (xai-oauth-adapter)", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-xai-"));
const adapterDir = path.join(root, "xai-oauth-adapter");
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
await fs.writeFile(adapterPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({ name: "xai-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("detects a scoped convention-named adapter one level under @scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-scoped-"));
const adapterDir = path.join(root, "@xai", "pi-oauth-adapter");
const adapterPath = path.join(adapterDir, "entry.ts");
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
await fs.writeFile(adapterPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({ name: "@xai/pi-oauth-adapter", pi: { extensions: ["entry.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("ignores packages that do not match the auth-adapter naming convention", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nonmatch-"));
const pkgDir = path.join(root, "some-other-package");
const extPath = path.join(pkgDir, "extensions", "index.ts");
await fs.mkdir(path.dirname(extPath), { recursive: true });
await fs.writeFile(extPath, "export default () => {};");
await fs.writeFile(
path.join(pkgDir, "package.json"),
JSON.stringify({ name: "some-other-package", pi: { extensions: ["extensions/index.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("returns empty for a matching package name without package.json", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopkg-"));
await fs.mkdir(path.join(root, "my-oauth-adapter"), { recursive: true });
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("returns empty when package.json lacks pi.extensions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopi-"));
const adapterDir = path.join(root, "my-auth-adapter");
await fs.mkdir(adapterDir, { recursive: true });
await fs.writeFile(path.join(adapterDir, "package.json"), JSON.stringify({ name: "my-auth-adapter" }));
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("filters out manifest entries whose files do not exist on disk", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-missing-"));
const adapterDir = path.join(root, "pi-oauth-adapter");
const presentPath = path.join(adapterDir, "extensions", "present.ts");
await fs.mkdir(path.dirname(presentPath), { recursive: true });
await fs.writeFile(presentPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({
name: "pi-oauth-adapter",
pi: { extensions: ["extensions/missing.ts", "extensions/present.ts"] },
}),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [presentPath]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("collects extension entries from every matching package under one root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-multi-"));
const firstDir = path.join(root, "alpha-oauth-adapter");
const firstPath = path.join(firstDir, "a.ts");
const secondDir = path.join(root, "beta-auth-adapter");
const secondPath = path.join(secondDir, "b.ts");
await fs.mkdir(path.dirname(firstPath), { recursive: true });
await fs.writeFile(firstPath, "export default () => {};");
await fs.writeFile(
path.join(firstDir, "package.json"),
JSON.stringify({ name: "alpha-oauth-adapter", pi: { extensions: ["a.ts"] } }),
);
await fs.mkdir(path.dirname(secondPath), { recursive: true });
await fs.writeFile(secondPath, "export default () => {};");
await fs.writeFile(
path.join(secondDir, "package.json"),
JSON.stringify({ name: "beta-auth-adapter", pi: { extensions: ["b.ts"] } }),
);
try {
const detected = detectAuthAdapterExtensionPaths([root]);
assert.equal(detected.length, 2);
assert.ok(detected.includes(firstPath));
assert.ok(detected.includes(secondPath));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("merges matches from multiple roots passed in the roots array", async () => {
const rootA = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-roota-"));
const rootB = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-rootb-"));
const pathA = path.join(rootA, "one-oauth-adapter", "one.ts");
const pathB = path.join(rootB, "two-oauth-adapter", "two.ts");
await fs.mkdir(path.dirname(pathA), { recursive: true });
await fs.writeFile(pathA, "export default () => {};");
await fs.writeFile(
path.join(rootA, "one-oauth-adapter", "package.json"),
JSON.stringify({ name: "one-oauth-adapter", pi: { extensions: ["one.ts"] } }),
);
await fs.mkdir(path.dirname(pathB), { recursive: true });
await fs.writeFile(pathB, "export default () => {};");
await fs.writeFile(
path.join(rootB, "two-oauth-adapter", "package.json"),
JSON.stringify({ name: "two-oauth-adapter", pi: { extensions: ["two.ts"] } }),
);
try {
const detected = detectAuthAdapterExtensionPaths([rootA, rootB]);
assert.equal(detected.length, 2);
assert.ok(detected.includes(pathA));
assert.ok(detected.includes(pathB));
} finally {
await fs.rm(rootA, { recursive: true, force: true });
await fs.rm(rootB, { recursive: true, force: true });
}
});
it("detects the allowlisted pi-claude-auth package", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-claude-"));
const adapterDir = path.join(root, "pi-claude-auth");
const adapterPath = path.join(adapterDir, "src", "index.ts");
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
await fs.writeFile(adapterPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({ name: "pi-claude-auth", pi: { extensions: ["src/index.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("detects the allowlisted scoped @gotgenes/pi-anthropic-auth package", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-anthropic-"));
const adapterDir = path.join(root, "@gotgenes", "pi-anthropic-auth");
const adapterPath = path.join(adapterDir, "src", "index.ts");
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
await fs.writeFile(adapterPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({
name: "@gotgenes/pi-anthropic-auth",
pi: { extensions: ["src/index.ts"] },
}),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("ignores a suffixless auth package that is not allowlisted", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-untrusted-"));
const pkgDir = path.join(root, "pi-vault-auth");
const extPath = path.join(pkgDir, "index.ts");
await fs.mkdir(pkgDir, { recursive: true });
await fs.writeFile(extPath, "export default () => {};");
await fs.writeFile(
path.join(pkgDir, "package.json"),
JSON.stringify({ name: "pi-vault-auth", pi: { extensions: ["index.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("buildChildPiPromptArgs", () => {
it("uses --no-extensions and only passes hermes-memory extension", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", {}, []),
["-p", "--no-session", ...EXT_ARGS, "hello"],
);
});
it("adds a model override and defaults thinking to off", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", { llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" }, []),
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
);
});
it("inherits the active provider/model when no override is configured", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", {}, [], { provider: "local-llama", id: "local-9b" }),
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, "hello"],
);
});
it("prefers the configured model override over the active session model", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs(
"hello",
{ llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" },
[],
{ provider: "local-llama", id: "local-9b" },
),
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
);
});
it("allows thinking overrides without a model override", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", { llmThinkingOverride: "low" }, []),
["-p", "--no-session", "--thinking", "low", ...EXT_ARGS, "hello"],
);
});
it("ignores missing inherited extension paths", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", {}, ["-e", "src/index.ts"]),
["-p", "--no-session", ...EXT_ARGS, "hello"],
);
});
it("passes configured extension sources through Pi's -e resolver and excludes inherited extensions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-extensions-"));
const configured = path.join(dir, "configured.ts");
const inherited = path.join(dir, "inherited.ts");
await fs.writeFile(configured, "export default () => {};");
await fs.writeFile(inherited, "export default () => {};");
try {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", {
childExtensionPaths: [
configured,
configured,
"~/.pi/agent/extensions/provider.ts",
"./relative-provider.ts",
"git:github.com/example/provider-extension@v1",
"npm:@example/provider-extension@1.0.0",
OWN_EXTENSION_PATH,
],
}, ["-e", inherited, `--extension=${configured}`]),
[
"-p", "--no-session", "--no-extensions",
"-e", OWN_EXTENSION_PATH,
"-e", configured,
"-e", "~/.pi/agent/extensions/provider.ts",
"-e", "./relative-provider.ts",
"-e", "git:github.com/example/provider-extension@v1",
"-e", "npm:@example/provider-extension@1.0.0",
...DETECTED_ADAPTER_ARGS,
"hello",
],
);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it("detects a sibling pi-claude-oauth-adapter extension via its package.json manifest", async () => {
const nodeModules = await fs.mkdtemp(path.join(os.tmpdir(), "pi-node-modules-"));
const adapterDir = path.join(nodeModules, "pi-claude-oauth-adapter");
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
await fs.writeFile(adapterPath, "export default () => {};");
await fs.writeFile(
path.join(adapterDir, "package.json"),
JSON.stringify({ name: "pi-claude-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
);
try {
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([nodeModules]), [adapterPath]);
} finally {
await fs.rm(nodeModules, { recursive: true, force: true });
}
});
});
describe("resolveChildPiInvocation", () => {
it("keeps non-Windows child pi invocations unchanged", () => {
const args = ["-p", "--no-session", "hello"];
assert.deepStrictEqual(
resolveChildPiInvocation(args, { platform: "linux" }),
{ command: "pi", args },
);
});
it("runs node.exe with cli.js first on Windows when a Pi CLI path is available", () => {
assert.deepStrictEqual(
resolveChildPiInvocation(["-p", "--no-session", "hello"], {
platform: "win32",
execPath: "C:\\Program Files\\nodejs\\node.exe",
piCliPath: "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
}),
{
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
"-p",
"--no-session",
"hello",
],
},
);
});
it("resolves a standard Windows command shim to its adjacent cli.js", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-shim-"));
const binDirectory = path.join(directory, "bin");
const cliPath = path.join(
binDirectory,
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js",
);
const args = ["-p", "--no-session", "hello"];
const originalPath = process.env.PATH;
await fs.mkdir(path.dirname(cliPath), { recursive: true });
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
await fs.writeFile(cliPath, "");
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
try {
assert.deepStrictEqual(
resolveChildPiInvocation(args, {
platform: "win32",
execPath: "C:\\Program Files\\nodejs\\node.exe",
piCliPath: null,
}),
{
command: "C:\\Program Files\\nodejs\\node.exe",
args: [cliPath, ...args],
},
);
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
await fs.rm(directory, { recursive: true, force: true });
}
});
});
describe("execChildPrompt", () => {
it("runs child Pi behind a hard process-tree watchdog", async () => {
const calls: Array<{ cmd: string; args: string[]; timeout?: number }> = [];
await execChildPrompt({
exec: async (cmd: string, args: string[], options: { timeout?: number }) => {
calls.push({ cmd, args, timeout: options.timeout });
return { code: 0, stdout: "ok", stderr: "", killed: false };
},
} as any, "bounded child", {}, { timeoutMs: 30000 });
assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, process.execPath);
assert.match(calls[0].args[0].replaceAll("\\", "/"), /child-process-watchdog\.mjs$/);
assert.equal(calls[0].args[1], "30000");
assert.match(calls[0].args[2], /cancel$/);
assert.equal(calls[0].args[3], "pi");
assert.equal(calls[0].timeout, 35000);
});
it("uses the watchdog marker for cancellation without aborting its process", async () => {
const abortController = new AbortController();
let cancelPath = "";
const result = await execChildPrompt({
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
cancelPath = args[2];
assert.equal(options.signal, undefined);
abortController.abort();
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
try {
await fs.access(cancelPath);
return { code: 143, stderr: "child cancelled" };
} catch {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
assert.fail("abort did not notify the watchdog");
},
} as any, "cancel child", {}, {
signal: abortController.signal,
timeoutMs: 30000,
});
assert.equal(result.code, 143);
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
});
it("writes cancellation before launching an already-aborted child", async () => {
const abortController = new AbortController();
abortController.abort();
let cancelPath = "";
await execChildPrompt({
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
cancelPath = args[2];
assert.equal(options.signal, undefined);
await fs.access(cancelPath);
return { code: 143, stderr: "child cancelled" };
},
} as any, "cancel before launch", {}, {
signal: abortController.signal,
timeoutMs: 30000,
});
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
});
it("forwards explicit cwd and timeout while cancellation stays with the watchdog", async () => {
const signal = new AbortController().signal;
let received: { cwd?: string; signal?: AbortSignal; timeout?: number } | undefined;
await execChildPrompt({
exec: async (_cmd: string, _args: string[], options: typeof received) => {
received = options;
return { code: 0, stdout: "ok", stderr: "" };
},
} as any, "bounded child", {}, {
cwd: "/tmp/session",
signal,
timeoutMs: 30000,
});
assert.deepStrictEqual(received, { cwd: "/tmp/session", timeout: 35000 });
});
it("hard-kills a child that ignores graceful timeout termination", async () => {
const watchdogPath = fileURLToPath(
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
);
const startedAt = Date.now();
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
const child = spawn(process.execPath, [
watchdogPath,
"100",
"-",
process.execPath,
"-e",
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
child.on("close", (code) => resolve({ code, stderr }));
});
assert.equal(result.code, 124);
assert.match(result.stderr, /timed out after 100ms/i);
assert.ok(Date.now() - startedAt < 3000, "watchdog must enforce a bounded hard stop");
});
it("terminates the child tree when its cancellation marker appears", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-cancel-"));
const cancelPath = path.join(directory, "cancel");
const watchdogPath = fileURLToPath(
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
);
const startedAt = Date.now();
try {
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
const child = spawn(process.execPath, [
watchdogPath,
"10000",
cancelPath,
process.execPath,
"-e",
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
child.on("close", (code) => resolve({ code, stderr }));
setTimeout(() => void fs.writeFile(cancelPath, ""), 100);
});
assert.equal(result.code, 143);
assert.match(result.stderr, /child cancellation requested/i);
assert.ok(Date.now() - startedAt < 3000, "watchdog cancellation must be bounded");
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
});
it("hard-kills descendants in the timed-out child process group", {
skip: process.platform === "win32" ? "uses taskkill /T on Windows" : false,
}, async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-tree-"));
const pidPath = path.join(directory, "descendant.pid");
const watchdogPath = fileURLToPath(
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
);
const descendant = "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)";
const parent = [
"const {spawn}=require('node:child_process');",
"const fs=require('node:fs');",
`const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore'});`,
`fs.writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`,
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);",
].join("");
try {
const code = await new Promise<number | null>((resolve) => {
const child = spawn(process.execPath, [
watchdogPath,
"100",
"-",
process.execPath,
"-e",
parent,
], { stdio: "ignore" });
child.on("close", resolve);
});
assert.equal(code, 124);
const descendantPid = Number(await fs.readFile(pidPath, "utf-8"));
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
try {
process.kill(descendantPid, 0);
await new Promise((resolve) => setTimeout(resolve, 20));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
throw error;
}
}
assert.fail("watchdog left a descendant process alive");
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
});
it("keeps a sensitive prompt out of argv and removes its mode-0600 temporary file", async () => {
const secret = "PRIVATE-MEMORY-CONTENT";
let promptPath = "";
const pi = {
exec: async (_cmd: string, args: string[]) => {
assert.ok(args.every((arg) => !arg.includes(secret)), "child argv must not contain prompt content");
const promptArg = args.at(-1)!;
assert.match(promptArg, /^@/);
promptPath = promptArg.slice(1);
assert.equal(await fs.readFile(promptPath, "utf-8"), secret);
assert.equal((await fs.stat(promptPath)).mode & 0o777, 0o600);
return { code: 0, stdout: "ok", stderr: "" };
},
};
const result = await execChildPrompt(pi as any, secret, {}, { timeoutMs: 30000 });
assert.equal(result.code, 0);
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
await assert.rejects(fs.access(path.dirname(promptPath)), { code: "ENOENT" });
});
it("removes the temporary prompt file when child execution throws", async () => {
let promptPath = "";
const pi = {
exec: async (_cmd: string, args: string[]) => {
promptPath = args.at(-1)!.slice(1);
assert.equal(await fs.readFile(promptPath, "utf-8"), "secret failure prompt");
throw new Error("child failed");
},
};
await assert.rejects(
execChildPrompt(pi as any, "secret failure prompt", {}, { timeoutMs: 30000 }),
/child failed/,
);
assert.ok(promptPath.startsWith(os.tmpdir()));
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
});
it("returns a successful child result when temporary cleanup fails", async () => {
let cleanupCalls = 0;
let promptDirectory = "";
const pi = {
exec: async (_cmd: string, args: string[]) => {
promptDirectory = path.dirname(args.at(-1)!.slice(1));
return { code: 0, stdout: "completed", stderr: "" };
},
};
try {
const result = await execChildPrompt(
pi as any,
"cleanup failure prompt",
{},
{ timeoutMs: 30000 },
{
removeTemporaryDirectory: async () => {
cleanupCalls++;
throw new Error("cleanup denied");
},
},
);
assert.equal(result.code, 0);
assert.equal(result.stdout, "completed");
assert.equal(cleanupCalls, 1);
} finally {
if (promptDirectory) await fs.rm(promptDirectory, { recursive: true, force: true });
}
});
it("passes configured auth adapters to both override attempts", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-auth-"));
const adapterPath = path.join(dir, "adapter.ts");
await fs.writeFile(adapterPath, "export default () => {};");
const calls: string[][] = [];
const pi = {
exec: async (_cmd: string, args: string[]) => {
calls.push(args);
return calls.length === 1
? { code: 1, stdout: "", stderr: "model not found" }
: { code: 0, stdout: "ok", stderr: "" };
},
};
try {
await execChildPrompt(pi as any, "hello", {
llmModelOverride: "missing/model",
childExtensionPaths: [adapterPath],
}, { timeoutMs: 30000, retryWithoutOverrides: true });
assert.equal(calls.length, 2);
for (const args of calls) {
const index = args.indexOf(adapterPath);
assert.ok(index > 0);
assert.equal(args[index - 1], "-e");
}
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it("excludes unrelated inherited extensions from primary and retry attempts", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-untrusted-"));
const inherited = path.join(dir, "unrelated-extension.ts");
await fs.writeFile(inherited, "export default () => {};");
const originalArgv = process.argv;
const calls: string[][] = [];
process.argv = [originalArgv[0], originalArgv[1], "-e", inherited];
try {
await execChildPrompt({
exec: async (_cmd: string, args: string[]) => {
calls.push(args);
return calls.length === 1
? { code: 1, stderr: "model not found" }
: { code: 0, stdout: "ok" };
},
} as any, "private review", {
llmModelOverride: "missing/model",
}, { timeoutMs: 30000, retryWithoutOverrides: true });
assert.equal(calls.length, 2);
for (const args of calls) {
assert.equal(args.includes(inherited), false);
assert.ok(args.includes("--no-extensions"));
}
} finally {
process.argv = originalArgv;
await fs.rm(dir, { recursive: true, force: true });
}
});
it("retries once without overrides when requested and the override subprocess fails for model resolution reasons", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const pi = {
exec: async (cmd: string, args: string[]) => {
calls.push({ cmd, args });
if (calls.length === 1) {
return { code: 1, stdout: "", stderr: "model not found" };
}
return { code: 0, stdout: "ok", stderr: "" };
},
};
const result = await execChildPrompt(pi as any, "hello", {
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
}, {
timeoutMs: 30000,
retryWithoutOverrides: true,
model: { provider: "local-llama", id: "local-9b" },
});
assert.strictEqual(result.code, 0);
const logicalCalls = calls.map(logicalChildArgs);
const promptReference = logicalCalls[0].at(-1)!;
assert.match(promptReference, /^@/);
assert.deepStrictEqual(logicalCalls, [
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
// Retry drops configured overrides but preserves the active session model.
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, promptReference],
]);
});
it("uses the resolved Windows node.exe invocation for both override retry attempts", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-retry-"));
const binDirectory = path.join(directory, "bin");
const cliPath = path.join(
binDirectory,
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js",
);
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalPath = process.env.PATH;
await fs.mkdir(path.dirname(cliPath), { recursive: true });
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
await fs.writeFile(cliPath, "");
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
const calls: Array<{ cmd: string; args: string[] }> = [];
const pi = {
exec: async (cmd: string, args: string[]) => {
calls.push({ cmd, args });
if (calls.length === 1) {
return { code: 1, stdout: "", stderr: "model not found" };
}
return { code: 0, stdout: "ok", stderr: "" };
},
};
const result = await execChildPrompt(pi as any, "hello", {
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
}, {
timeoutMs: 30000,
retryWithoutOverrides: true,
});
assert.strictEqual(result.code, 0);
assert.strictEqual(calls.length, 2);
assert.strictEqual(calls[0].cmd, process.execPath);
assert.strictEqual(calls[1].cmd, process.execPath);
assert.match(calls[0].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
assert.match(calls[1].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
const promptReference = calls[0].args.at(-1)!;
assert.match(promptReference, /^@/);
assert.deepStrictEqual(calls.map(logicalChildArgs), [
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
["-p", "--no-session", ...EXT_ARGS, promptReference],
]);
} finally {
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
await fs.rm(directory, { recursive: true, force: true });
}
});
it("does not retry generic non-zero child failures that are unrelated to override resolution", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const pi = {
exec: async (cmd: string, args: string[]) => {
calls.push({ cmd, args });
return { code: 1, stdout: "", stderr: "memory tool returned no changes" };
},
};
const result = await execChildPrompt(pi as any, "hello", {
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
}, {
timeoutMs: 30000,
retryWithoutOverrides: true,
});
assert.strictEqual(result.code, 1);
assert.strictEqual(calls.length, 1);
});
it("does not retry generic thrown errors that are unrelated to override resolution", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const pi = {
exec: async (cmd: string, args: string[]) => {
calls.push({ cmd, args });
throw new Error("timed out waiting for child process");
},
};
await assert.rejects(
() => execChildPrompt(pi as any, "hello", {
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
}, {
timeoutMs: 30000,
retryWithoutOverrides: true,
}),
/timed out waiting for child process/,
);
assert.strictEqual(calls.length, 1);
});
it("does not retry when retryWithoutOverrides is false", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const pi = {
exec: async (cmd: string, args: string[]) => {
calls.push({ cmd, args });
return { code: 1, stdout: "", stderr: "model not found" };
},
};
const result = await execChildPrompt(pi as any, "hello", {
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
}, {
timeoutMs: 30000,
retryWithoutOverrides: false,
});
assert.strictEqual(result.code, 1);
assert.strictEqual(calls.length, 1);
});
});
@@ -0,0 +1,152 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { registerPreviewContextCommand } from "../../src/handlers/preview-context.js";
import { MEMORY_POLICY_PROMPT, MEMORY_POLICY_PROMPT_COMPACT } from "../../src/constants.js";
describe("registerPreviewContextCommand", () => {
function setup(opts: {
memoryBlock?: string;
projectBlock?: string;
projectName?: string;
withProjectStore?: boolean;
memoryMode?: "policy-only" | "legacy-inject";
memoryPolicyStyle?: "full" | "compact" | "custom" | "none";
memoryPolicyCustomText?: string;
}) {
const commands: { name: string; handler: Function }[] = [];
const notifyCalls: { message: string; severity: string }[] = [];
const mockPi = {
registerCommand: (name: string, conf: any) => {
commands.push({ name, handler: conf.handler });
},
} as any;
const store = {
formatForSystemPrompt: () => opts.memoryBlock ?? "",
} as any;
const projectStore = opts.withProjectStore
? ({ formatProjectBlock: () => opts.projectBlock ?? "" } as any)
: null;
registerPreviewContextCommand(
mockPi,
store,
projectStore,
opts.projectName ?? "demo-project",
{
memoryMode: opts.memoryMode ?? "policy-only",
memoryPolicyStyle: opts.memoryPolicyStyle,
memoryPolicyCustomText: opts.memoryPolicyCustomText,
},
);
return {
handler: commands[0].handler,
notifyCalls,
ctx: {
ui: {
notify: (message: string, severity: string) => {
notifyCalls.push({ message, severity });
},
},
},
};
}
it("registers /memory-preview-context", () => {
const { handler } = setup({});
assert.ok(typeof handler === "function");
});
it("shows policy-only context by default", async () => {
const { handler, ctx, notifyCalls } = setup({
memoryBlock: "<memory-context>MEM</memory-context>",
projectBlock: "<memory-context>PROJECT</memory-context>",
withProjectStore: true,
});
await handler({}, ctx);
assert.strictEqual(notifyCalls.length, 1);
const out = notifyCalls[0].message;
assert.match(out, /Mode: policy-only/);
assert.match(out, /Policy style: full/);
assert.match(out, /Full Markdown memories are NOT injected/);
assert.match(out, /memory_search/);
assert.match(out, /target="failure"/);
assert.ok(out.includes(MEMORY_POLICY_PROMPT));
assert.match(out, /Blocks shown: 1/);
});
it("shows compact policy context when configured", async () => {
const { handler, ctx, notifyCalls } = setup({
memoryPolicyStyle: "compact",
});
await handler({}, ctx);
const out = notifyCalls[0].message;
assert.match(out, /Policy style: compact/);
assert.ok(out.includes(MEMORY_POLICY_PROMPT_COMPACT));
assert.match(out, /Blocks shown: 1/);
});
it("shows custom policy context when configured", async () => {
const customText = "<memory-policy>Custom preview policy.</memory-policy>";
const { handler, ctx, notifyCalls } = setup({
memoryPolicyStyle: "custom",
memoryPolicyCustomText: customText,
});
await handler({}, ctx);
const out = notifyCalls[0].message;
assert.match(out, /Policy style: custom/);
assert.ok(out.includes(customText));
assert.match(out, /Blocks shown: 1/);
});
it("shows no injected policy context when policy style is none", async () => {
const { handler, ctx, notifyCalls } = setup({
memoryPolicyStyle: "none",
});
await handler({}, ctx);
const out = notifyCalls[0].message;
assert.match(out, /Policy style: none/);
assert.match(out, /No memory policy context is injected/);
assert.doesNotMatch(out, /<memory-policy>/);
assert.match(out, /Blocks shown: 0/);
});
it("shows all available blocks in legacy mode", async () => {
const { handler, ctx, notifyCalls } = setup({
memoryBlock: "<memory-context>MEM</memory-context>",
projectBlock: "<memory-context>PROJECT</memory-context>",
withProjectStore: true,
projectName: "pi-hermes-memory",
memoryMode: "legacy-inject",
});
await handler({}, ctx);
assert.strictEqual(notifyCalls.length, 1);
const out = notifyCalls[0].message;
assert.match(out, /Injected Context Preview/);
assert.match(out, /MEMORY \+ USER \+ RECENT FAILURES/);
assert.match(out, /PROJECT MEMORY \(pi-hermes-memory\)/);
assert.match(out, /Blocks shown: 2/);
});
it("shows empty-state guidance when no blocks exist", async () => {
const { handler, ctx, notifyCalls } = setup({
memoryBlock: "",
projectBlock: "",
withProjectStore: false,
memoryMode: "legacy-inject",
});
await handler({}, ctx);
const out = notifyCalls[0].message;
assert.match(out, /No memory context blocks are currently injected/);
assert.match(out, /Blocks shown: 0/);
});
});
@@ -0,0 +1,166 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildPromptContext } from "../../src/prompt-context.js";
import { MEMORY_POLICY_PROMPT, MEMORY_POLICY_PROMPT_COMPACT } from "../../src/constants.js";
describe("buildPromptContext", () => {
const store = {
formatForSystemPrompt: () => "<memory-context>MEMORY</memory-context>",
} as any;
const projectStore = {
formatProjectBlock: (projectName: string) => `<memory-context>PROJECT ${projectName}</memory-context>`,
} as any;
it("returns policy only in policy-only mode", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only" },
store,
projectStore,
"demo",
);
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
assert.match(result, /memory_search/);
assert.match(result, /Accepted memory categories/);
assert.match(result, /category filters categorized failure\/lesson memories only/);
assert.match(result, /Use category only for categorized failure\/lesson searches/);
assert.match(result, /session_search: search indexed past conversation messages/);
assert.match(result, /skill_manage: list, view, create, patch, update, and delete procedural skills/);
assert.match(result, /Always pass scope explicitly on create/);
assert.match(result, /Do not create skills for one-off task state/);
assert.doesNotMatch(result, /category="preference"/);
assert.doesNotMatch(result, /inspect, and update procedural skills/);
assert.doesNotMatch(result, /memory_search: search relevant user, project, session, failure, and skill memories/);
assert.doesNotMatch(result, /MEMORY<\/memory-context>/);
assert.doesNotMatch(result, /PROJECT demo/);
assert.doesNotMatch(result, /SKILLS/);
});
it("returns the full policy prompt when policy style is full", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "full" },
store,
projectStore,
"demo",
);
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
});
it("returns the compact policy prompt when policy style is compact", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "compact" },
store,
projectStore,
"demo",
);
assert.strictEqual(result, MEMORY_POLICY_PROMPT_COMPACT);
assert.match(result, /category filters categorized failure\/lesson memories only/);
assert.match(result, /scope is required: global for transferable workflows, project for repo-specific ones/);
assert.match(result, /Do not use memory_search for generic questions/);
assert.doesNotMatch(result, /MEMORY<\/memory-context>/);
assert.doesNotMatch(result, /PROJECT demo/);
assert.doesNotMatch(result, /SKILLS/);
});
it("returns custom policy text when policy style is custom", async () => {
const customText = "<memory-policy>Use local custom policy.</memory-policy>";
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "custom", memoryPolicyCustomText: customText },
store,
projectStore,
"demo",
);
assert.strictEqual(result, customText);
});
it("falls back to compact policy when custom policy text is blank", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "custom", memoryPolicyCustomText: " \n\t " },
store,
projectStore,
"demo",
);
assert.strictEqual(result, MEMORY_POLICY_PROMPT_COMPACT);
});
it("returns empty context when policy style is none", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "none" },
store,
projectStore,
"demo",
);
assert.strictEqual(result, "");
});
it("returns legacy memory blocks in legacy-inject mode", async () => {
const result = await buildPromptContext(
{ memoryMode: "legacy-inject", memoryPolicyStyle: "compact" },
store,
projectStore,
"demo",
);
assert.match(result, /MEMORY/);
assert.match(result, /PROJECT demo/);
assert.doesNotMatch(result, /<memory-policy>/);
});
const standing = {
formatForSystemPrompt: () => "<standing-instructions>\n1. never run find /\n</standing-instructions>",
} as any;
it("appends standing instructions after the policy in policy-only mode", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only" },
store,
projectStore,
"demo",
standing,
);
assert.ok(result.startsWith(MEMORY_POLICY_PROMPT), "the policy still leads");
assert.match(result, /<standing-instructions>/);
assert.match(result, /never run find \//);
assert.doesNotMatch(result, /MEMORY<\/memory-context>/, "stored memories stay out of policy-only");
});
it("injects standing instructions even when the policy style is none", async () => {
const result = await buildPromptContext(
{ memoryMode: "policy-only", memoryPolicyStyle: "none" },
store,
projectStore,
"demo",
standing,
);
assert.strictEqual(result, "<standing-instructions>\n1. never run find /\n</standing-instructions>");
});
it("puts standing instructions last in legacy-inject mode", async () => {
const result = await buildPromptContext(
{ memoryMode: "legacy-inject" },
store,
projectStore,
"demo",
standing,
);
assert.ok(
result.indexOf("<standing-instructions>") > result.indexOf("PROJECT demo"),
"user directives read last, closest to the task",
);
});
it("injects nothing extra when no standing store is wired", async () => {
const result = await buildPromptContext({ memoryMode: "policy-only" }, store, projectStore, "demo");
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
});
});
@@ -0,0 +1,71 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import * as os from "node:os";
import * as path from "node:path";
import { resolveProjectSkillDiscovery, registerProjectSkillDiscoveryHandler } from "../../src/index.js";
import { AGENT_ROOT } from "../../src/paths.js";
import { SkillStore } from "../../src/store/skill-store.js";
describe("resources_discover skill path resolution", () => {
it("registers resources_discover and returns skillPaths from handler", async () => {
const store = new SkillStore({
globalSkillsDir: "/tmp/global-skills",
projectSkillsDir: null,
projectName: null,
legacySkillsDir: "/tmp/legacy-skills",
migrationSentinelPath: "/tmp/.skills-migrated",
});
const handlers: Record<string, Function> = {};
const pi = {
on: (event: string, handler: Function) => {
handlers[event] = handler;
},
} as any;
registerProjectSkillDiscoveryHandler(pi, store, "projects-memory");
assert.ok(typeof handlers.resources_discover === "function");
const result = await handlers.resources_discover({ cwd: "/tmp/demo-repo" }, {});
const expectedPath = path.join(AGENT_ROOT, "projects-memory", "demo-repo", "skills");
assert.deepStrictEqual(result, { skillPaths: ["/tmp/global-skills", expectedPath] });
});
it("returns project skillPaths and updates skill store context", () => {
const store = new SkillStore({
globalSkillsDir: "/tmp/global-skills",
projectSkillsDir: null,
projectName: null,
legacySkillsDir: "/tmp/legacy-skills",
migrationSentinelPath: "/tmp/.skills-migrated",
});
const cwd = "/tmp/demo-repo";
const resource = resolveProjectSkillDiscovery(store, "projects-memory", cwd);
const expectedPath = path.join(AGENT_ROOT, "projects-memory", "demo-repo", "skills");
assert.deepStrictEqual(resource, { skillPaths: ["/tmp/global-skills", expectedPath] });
assert.strictEqual(store.getProjectName(), "demo-repo");
assert.strictEqual(store.getProjectSkillsDir(), expectedPath);
});
it("contributes no skill path when cwd is not a project", () => {
const store = new SkillStore({
globalSkillsDir: "/tmp/global-skills",
projectSkillsDir: "/tmp/old-project",
projectName: "old-project",
legacySkillsDir: "/tmp/legacy-skills",
migrationSentinelPath: "/tmp/.skills-migrated",
});
const resource = resolveProjectSkillDiscovery(store, "projects-memory", os.homedir());
// Outside a project only the extension's own global root is contributed.
// It is a directory of ours, kept apart from the skills a user installed
// themselves, so Pi has to be told about it (#126).
assert.deepStrictEqual(resource, { skillPaths: ["/tmp/global-skills"] });
assert.strictEqual(store.getProjectName(), null);
assert.strictEqual(store.getProjectSkillsDir(), null);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { DatabaseManager } from '../../src/store/db.js';
import {
scheduleSessionBackfill,
joinSessionBackfill,
waitForSessionBackfill,
type SessionBackfillState,
} from '../../src/handlers/session-backfill.js';
import { indexAllSessions, touchBackfillTimestamp } from '../../src/store/session-indexer.js';
function writeJsonlSession(sessionsDir: string, projectDir: string, sessionId: string, text = 'Hello from backfill'): void {
const projDir = path.join(sessionsDir, projectDir);
fs.mkdirSync(projDir, { recursive: true });
const lines = [
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/work/${projectDir}` }),
JSON.stringify({
type: 'message',
id: `${sessionId}-m1`,
parentId: null,
timestamp: '2026-05-03T00:01:00Z',
message: { role: 'user', content: [{ type: 'text', text }], timestamp: Date.now() },
}),
];
fs.writeFileSync(path.join(projDir, `${sessionId}.jsonl`), lines.join('\n'));
}
describe('session backfill handler', () => {
let tmpDir: string;
let sessionsDir: string;
let dbManager: DatabaseManager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-backfill-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
dbManager = new DatabaseManager(path.join(tmpDir, 'memory'));
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('schedules backfill without indexing synchronously, then indexes unindexed sessions', async () => {
writeJsonlSession(sessionsDir, 'project-a', 's1');
const callbacks: (() => void)[] = [];
const state: SessionBackfillState = { inProgress: false, promise: null };
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
setTimeoutFn: (callback) => {
callbacks.push(callback);
return 0;
},
});
assert.equal(scheduled, true);
assert.equal(callbacks.length, 1);
assert.equal(dbManager.getStats().sessions, 0, 'session_start should not index synchronously');
const promise = state.promise;
assert.ok(promise);
callbacks[0]();
await promise;
assert.equal(dbManager.getStats().sessions, 1);
assert.equal(dbManager.getStats().messages, 1);
});
it('does not schedule backfill when counts match and timestamp is recent', () => {
writeJsonlSession(sessionsDir, 'project-a', 's1');
indexAllSessions(dbManager, sessionsDir);
touchBackfillTimestamp(dbManager);
const callbacks: (() => void)[] = [];
const state: SessionBackfillState = { inProgress: false, promise: null };
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
setTimeoutFn: (callback) => {
callbacks.push(callback);
return 0;
},
});
assert.equal(scheduled, false);
assert.equal(callbacks.length, 0);
assert.equal(state.inProgress, false);
});
it('keeps manual indexAllSessions idempotent with auto-backfill', async () => {
writeJsonlSession(sessionsDir, 'project-a', 's1');
const state: SessionBackfillState = { inProgress: false, promise: null };
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
assert.equal(scheduled, true);
await state.promise;
const manualResult = indexAllSessions(dbManager, sessionsDir);
assert.equal(manualResult.sessionsProcessed, 1);
assert.equal(manualResult.sessionsSkipped, 1);
assert.equal(manualResult.sessionsIndexed, 0);
});
it('does not mark backfill complete when startup parse limit is reached', async () => {
const state: SessionBackfillState = { inProgress: false, promise: null };
let touched = false;
const notifications: { message: string; level: string }[] = [];
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
needsBackfillFn: () => true,
indexSessionsFn: () => ({
sessionsProcessed: 1,
sessionsIndexed: 1,
sessionsSkipped: 0,
messagesIndexed: 1,
errors: [],
reachedLimit: true,
}),
touchBackfillTimestampFn: () => { touched = true; },
notify: (message, level) => notifications.push({ message, level }),
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
assert.equal(scheduled, true);
await state.promise;
assert.equal(touched, false);
assert.equal(notifications[0].level, 'warning');
assert.match(notifications[0].message, /startup limit reached/);
});
it('scheduled task is best-effort and does not reject when indexing throws', async () => {
const state: SessionBackfillState = { inProgress: false, promise: null };
const notifications: { message: string; level: string }[] = [];
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
needsBackfillFn: () => true,
indexSessionsFn: () => {
throw new Error('boom');
},
notify: (message, level) => notifications.push({ message, level }),
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
assert.equal(scheduled, true);
await state.promise;
assert.equal(state.inProgress, false);
assert.match(notifications[0].message, /Session backfill failed: boom/);
assert.equal(notifications[0].level, 'warning');
});
it('shutdown wait resolves true when an in-progress backfill completes before timeout', async () => {
let resolveBackfill!: () => void;
const state: SessionBackfillState = {
inProgress: true,
promise: new Promise<void>((resolve) => {
resolveBackfill = resolve;
}),
};
setTimeout(resolveBackfill, 5);
const completed = await waitForSessionBackfill(100, state);
assert.equal(completed, true);
});
it('shutdown wait resolves false when an in-progress backfill exceeds timeout', async () => {
const state: SessionBackfillState = {
inProgress: true,
promise: new Promise<void>(() => {}),
};
const completed = await waitForSessionBackfill(5, state);
assert.equal(completed, false);
});
it('first-use join does not inherit the five-second shutdown deadline', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const backfill = Promise.withResolvers<void>();
const state: SessionBackfillState = { inProgress: true, promise: backfill.promise };
let joined = false;
const firstUse = joinSessionBackfill(state).then(() => { joined = true; });
const shutdown = waitForSessionBackfill(5000, state);
t.mock.timers.tick(6000);
assert.equal(await shutdown, false);
assert.equal(joined, false);
backfill.resolve();
await firstUse;
assert.equal(joined, true);
});
it('propagates the retention cutoff to the backfill file set', async () => {
// Only an expired file exists (mtime older than the 30-day cutoff). Even
// with recent backfill timestamps, the backfill must not be scheduled
// because the file is outside the retained window.
writeJsonlSession(sessionsDir, 'project-a', 'old');
const oldFile = path.join(sessionsDir, 'project-a', 'old.jsonl');
const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
const oldMtime = cutoffMs - 1000;
fs.utimesSync(oldFile, new Date(oldMtime), new Date(oldMtime));
touchBackfillTimestamp(dbManager);
let receivedCutoff: number | undefined;
let indexOptions: { retentionCutoffMs?: number } | undefined;
const state: SessionBackfillState = { inProgress: false, promise: null };
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
state,
retentionCutoffMs: cutoffMs,
setTimeoutFn: () => 0,
// Capture the cutoff that would be forwarded to the store so we prove
// the wiring reaches both the eligibility check and the indexer.
needsBackfillFn: (_db, _dir, _now, cutoff) => {
receivedCutoff = cutoff;
return false;
},
indexSessionsFn: (_db, _dir, options) => {
indexOptions = options;
return { sessionsProcessed: 0, sessionsIndexed: 0, sessionsSkipped: 0, messagesIndexed: 0, reachedLimit: undefined, errors: [] };
},
});
// needsBackfill ran and received the cutoff; because it returned false for
// an expired file set, no backfill pass was scheduled.
assert.equal(scheduled, false);
assert.equal(receivedCutoff, cutoffMs);
assert.equal(indexOptions, undefined);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { DatabaseManager } from '../../src/store/db.js';
import {
scheduleLiveSessionIndex,
waitForLiveSessionIndex,
type SessionLiveIndexState,
} from '../../src/handlers/session-live-index.js';
describe('session live indexing handler', () => {
let tmpDir: string;
let dbManager: DatabaseManager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-live-index-test-'));
dbManager = new DatabaseManager(path.join(tmpDir, 'memory'));
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function createSnapshot(entries: unknown[]) {
return {
getHeader: () => ({ id: 'live-session', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/live-project' }),
getEntries: () => entries,
};
}
it('defers indexing so message_end does not block and then indexes live messages', async () => {
const entries = [{
type: 'message',
id: 'entry-1',
timestamp: '2026-05-03T00:01:00Z',
message: { role: 'user', content: 'hello after message_end' },
}];
const callbacks: (() => void)[] = [];
const state: SessionLiveIndexState = { inProgress: false, promise: null };
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot(entries), {
state,
delayMs: 0,
setTimeoutFn: (callback) => {
callbacks.push(callback);
return 0;
},
});
assert.equal(scheduled, true);
assert.equal(callbacks.length, 1);
assert.equal(dbManager.getStats().messages, 0, 'message_end handler should not index synchronously');
const promise = state.promise;
assert.ok(promise);
callbacks[0]();
await promise;
assert.equal(dbManager.getStats().sessions, 1);
assert.equal(dbManager.getStats().messages, 1);
});
it('coalesces multiple scheduled message_end events and indexes all missing entries', async () => {
const entries = [{
type: 'message',
id: 'entry-1',
timestamp: '2026-05-03T00:01:00Z',
message: { role: 'user', content: 'first' },
}];
const callbacks: (() => void)[] = [];
const state: SessionLiveIndexState = { inProgress: false, promise: null };
const snapshot = createSnapshot(entries);
assert.equal(scheduleLiveSessionIndex(dbManager, snapshot, {
state,
delayMs: 0,
setTimeoutFn: (callback) => {
callbacks.push(callback);
return 0;
},
}), true);
entries.push({
type: 'message',
id: 'entry-2',
timestamp: '2026-05-03T00:02:00Z',
message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] },
});
assert.equal(scheduleLiveSessionIndex(dbManager, snapshot, { state, delayMs: 0 }), false);
const promise = state.promise;
callbacks[0]();
await promise;
assert.equal(dbManager.getStats().sessions, 1);
assert.equal(dbManager.getStats().messages, 2);
});
it('indexes appended messages for an already indexed resumed session', async () => {
const entries = [{
type: 'message',
id: 'entry-1',
timestamp: '2026-05-03T00:01:00Z',
message: { role: 'user', content: 'before resume' },
}];
const snapshot = createSnapshot(entries);
const state: SessionLiveIndexState = { inProgress: false, promise: null };
scheduleLiveSessionIndex(dbManager, snapshot, {
state,
delayMs: 0,
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
await state.promise;
assert.equal(dbManager.getStats().messages, 1);
entries.push({
type: 'message',
id: 'entry-2',
timestamp: '2026-05-03T00:02:00Z',
message: { role: 'user', content: 'after resume' },
});
scheduleLiveSessionIndex(dbManager, snapshot, {
state,
delayMs: 0,
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
await state.promise;
assert.equal(dbManager.getStats().sessions, 1);
assert.equal(dbManager.getStats().messages, 2);
});
it('scheduled live indexing is best-effort and does not reject on errors', async () => {
const state: SessionLiveIndexState = { inProgress: false, promise: null };
const errors: unknown[] = [];
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot([]), {
state,
indexLiveSessionFn: () => {
throw new Error('boom');
},
onError: (err) => errors.push(err),
delayMs: 0,
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
assert.equal(scheduled, true);
await state.promise;
assert.equal(state.inProgress, false);
assert.equal(errors.length, 1);
assert.match(errors[0] instanceof Error ? errors[0].message : String(errors[0]), /boom/);
});
it('repairs corruption and retries live indexing once before reporting an error', async () => {
dbManager.getDb();
const state: SessionLiveIndexState = { inProgress: false, promise: null };
const errors: unknown[] = [];
let attempts = 0;
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot([]), {
state,
indexLiveSessionFn: () => {
attempts++;
if (attempts === 1) {
const err = new Error('SQLITE_CORRUPT: database disk image is malformed') as Error & { code: string };
err.code = 'SQLITE_CORRUPT';
throw err;
}
return null;
},
onError: (err) => errors.push(err),
delayMs: 0,
setTimeoutFn: (callback) => {
queueMicrotask(callback);
return 0;
},
});
assert.equal(scheduled, true);
await state.promise;
assert.equal(attempts, 2);
assert.equal(errors.length, 0);
assert.equal(dbManager.getLastRecovery()?.strategy, 'reused');
});
it('shutdown wait resolves true when live indexing completes before timeout', async () => {
let resolveIndex!: () => void;
const state: SessionLiveIndexState = {
inProgress: true,
promise: new Promise<void>((resolve) => {
resolveIndex = resolve;
}),
};
setTimeout(resolveIndex, 5);
const completed = await waitForLiveSessionIndex(100, state);
assert.equal(completed, true);
});
it('shutdown wait resolves false when live indexing exceeds timeout', async () => {
const state: SessionLiveIndexState = {
inProgress: true,
promise: new Promise<void>(() => {}),
};
const completed = await waitForLiveSessionIndex(5, state);
assert.equal(completed, false);
});
});
@@ -0,0 +1,827 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
buildSkillRows,
buildUnifiedSkillRows,
collectLoadedSkillsFromCommands,
confirmDeleteSelectedSkills,
deleteSelectedSkills,
filterSkillRows,
moveSelectedSkills,
registerSkillsCommand,
SkillsManagerModal,
type SkillBatchActionResult,
} from "../../src/handlers/skills-command.js";
import type { SkillIndex, SkillResult } from "../../src/types.js";
const SAMPLE_SKILLS: SkillIndex[] = [
{
skillId: "global:debug-typescript-errors",
scope: "global",
fileName: "SKILL.md",
path: "/tmp/global/debug-typescript-errors/SKILL.md",
name: "debug-typescript-errors",
displayName: "Debug TypeScript Errors",
description: "Trace compiler issues step by step",
created: "2026-05-19",
updated: "2026-05-21",
},
{
skillId: "project:demo-project:deploy-checklist",
scope: "project",
fileName: "SKILL.md",
path: "/tmp/project/deploy-checklist/SKILL.md",
projectName: "demo-project",
name: "deploy-checklist",
displayName: "Deploy Checklist",
description: "Project release checklist",
created: "2026-05-18",
updated: "2026-05-20",
},
];
const SORT_MODE_SKILLS: SkillIndex[] = [
{
skillId: "global:zebra-runbook",
scope: "global",
fileName: "SKILL.md",
path: "/tmp/global/zebra-runbook/SKILL.md",
name: "zebra-runbook",
displayName: "Zebra Runbook",
description: "Older creation date, newer update date",
created: "2026-05-18",
updated: "2026-05-21",
},
{
skillId: "project:demo-project:alpha-checklist",
scope: "project",
fileName: "SKILL.md",
path: "/tmp/project/alpha-checklist/SKILL.md",
projectName: "demo-project",
name: "alpha-checklist",
displayName: "Alpha Checklist",
description: "Newer creation date, older update date",
created: "2026-05-22",
updated: "2026-05-20",
},
];
const LOADED_SKILL_COMMANDS = [
{
name: "skill:debug-typescript-errors",
description: "Trace compiler issues step by step",
source: "skill",
sourceInfo: { path: "/tmp/global/debug-typescript-errors/SKILL.md" },
},
{
name: "skill:langgraph-fundamentals",
description: "LangGraph patterns",
source: "skill",
sourceInfo: { path: "/Users/demo/.agents/skills/langgraph-fundamentals/SKILL.md" },
},
{
name: "memory-skills",
description: "not a skill command",
source: "extension",
sourceInfo: { path: "/tmp/ignore" },
},
] as const;
describe("skills command helpers", () => {
it("buildSkillRows preserves selected ids", () => {
const rows = buildSkillRows(SAMPLE_SKILLS, new Set(["project:demo-project:deploy-checklist"]));
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows[0].selected, false);
assert.strictEqual(rows[1].selected, true);
assert.strictEqual(rows[0].searchText.includes("Debug TypeScript Errors"), true);
});
it("filterSkillRows uses fuzzy skill-name matching", () => {
const rows = buildSkillRows(SAMPLE_SKILLS);
const filtered = filterSkillRows(rows, "dbg ts err");
assert.strictEqual(filtered.length, 1);
assert.strictEqual(filtered[0].skillId, "global:debug-typescript-errors");
});
it("collectLoadedSkillsFromCommands returns loaded runtime skills only", () => {
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
assert.strictEqual(loaded.length, 2);
assert.strictEqual(loaded[0]?.name, "debug-typescript-errors");
assert.strictEqual(loaded[1]?.name, "langgraph-fundamentals");
});
it("collectLoadedSkillsFromCommands ignores malformed and pathless commands", () => {
const loaded = collectLoadedSkillsFromCommands([
{ source: "skill", name: "skill:valid", sourceInfo: { path: "/tmp/valid/SKILL.md" } },
{ source: "skill", name: "skill:no-path", sourceInfo: {} },
{ source: "skill", name: "skill:blank-path", sourceInfo: { path: " " } },
{ source: "skill", name: " ", sourceInfo: { path: "/tmp/blank-name/SKILL.md" } },
{ source: "skill", sourceInfo: { path: "/tmp/missing-name/SKILL.md" } },
{ source: "skill", name: 123, sourceInfo: { path: "/tmp/invalid-name/SKILL.md" } },
{ source: "extension", name: "memory-skills", sourceInfo: { path: "/tmp/ignore/SKILL.md" } },
null as any,
] as any);
assert.strictEqual(loaded.length, 1);
assert.strictEqual(loaded[0]?.name, "valid");
assert.strictEqual(loaded[0]?.path, "/tmp/valid/SKILL.md");
});
it("buildUnifiedSkillRows merges managed and external skills", () => {
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
assert.strictEqual(rows.length, 3);
const external = rows.find((row) => row.category === "E");
assert.ok(external);
assert.strictEqual(external?.mutable, false);
assert.ok(external?.displayPath.includes(".agents"));
});
it("buildUnifiedSkillRows keeps managed skills sorted by updated recency", () => {
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
assert.strictEqual(rows[0]?.skillId, "global:debug-typescript-errors");
assert.strictEqual(rows[1]?.skillId, "project:demo-project:deploy-checklist");
assert.strictEqual(rows[2]?.category, "E");
});
it("buildUnifiedSkillRows can sort by created date or name", () => {
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const createdRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "created");
assert.strictEqual(createdRows[0]?.skillId, "project:demo-project:alpha-checklist");
assert.strictEqual(createdRows[1]?.skillId, "global:zebra-runbook");
const nameRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "name");
assert.strictEqual(nameRows[0]?.skillId, "project:demo-project:alpha-checklist");
assert.strictEqual(nameRows[1]?.displayName, "debug-typescript-errors");
assert.strictEqual(nameRows[2]?.displayName, "langgraph-fundamentals");
assert.strictEqual(nameRows[3]?.skillId, "global:zebra-runbook");
});
it("moveSelectedSkills blocks project moves without an active project", async () => {
const store = {
getProjectName: () => null,
loadIndex: async () => SAMPLE_SKILLS,
move: async () => ({ success: true } as SkillResult),
};
const result = await moveSelectedSkills(store as any, ["global:debug-typescript-errors"], "project");
assert.strictEqual(result.summaryLines[0], "Move to project is unavailable: no active project detected.");
assert.deepStrictEqual(result.retainSelectedSkillIds, ["global:debug-typescript-errors"]);
});
it("moveSelectedSkills keeps partial successes and retains blocked selection", async () => {
const moves = new Map<string, SkillResult>([
[
"global:debug-typescript-errors",
{
success: true,
skillId: "project:demo-project:debug-typescript-errors",
scope: "project",
message: "Skill 'Debug TypeScript Errors' moved to project.",
},
],
[
"project:demo-project:deploy-checklist",
{
success: false,
error: "Destination already exists.",
conflictType: "scope-conflict",
},
],
]);
const refreshed: SkillIndex[] = [
{
...SAMPLE_SKILLS[1],
skillId: "project:demo-project:debug-typescript-errors",
name: "debug-typescript-errors",
displayName: "Debug TypeScript Errors",
path: "/tmp/project/debug-typescript-errors/SKILL.md",
description: "Trace compiler issues step by step",
},
SAMPLE_SKILLS[1],
];
const store = {
getProjectName: () => "demo-project",
loadIndex: async () => refreshed,
move: async (skillId: string) => moves.get(skillId)!,
};
const result = await moveSelectedSkills(
store as any,
["global:debug-typescript-errors", "project:demo-project:deploy-checklist"],
"project",
);
assert.ok(result.summaryLines[0].includes("Moved 1 skill"));
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
assert.deepStrictEqual(result.retainSelectedSkillIds, ["project:demo-project:deploy-checklist"]);
assert.strictEqual(result.focusSkillId, "project:demo-project:deploy-checklist");
assert.strictEqual(result.skills.length, 2);
});
it("deleteSelectedSkills reports blocked deletes and refreshes skills", async () => {
const store = {
loadIndex: async () => [SAMPLE_SKILLS[1]],
delete: async (skillId: string) => skillId === SAMPLE_SKILLS[0].skillId
? { success: true, skillId, scope: "global" as const }
: { success: false, error: "Skill missing." },
};
const result = await deleteSelectedSkills(
store as any,
[SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId],
);
assert.ok(result.summaryLines[0].includes("Deleted 1 skill"));
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
});
it("moveSelectedSkills treats thrown move errors as blocked items", async () => {
const store = {
getProjectName: () => "demo-project",
loadIndex: async () => SAMPLE_SKILLS,
move: async (skillId: string) => {
if (skillId === SAMPLE_SKILLS[0].skillId) {
throw new Error("permission denied");
}
return { success: true, skillId: "global:deploy-checklist", scope: "global" as const };
},
};
const result = await moveSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId], "global");
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
assert.ok(result.summaryLines.some((line) => line.includes("permission denied")));
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
});
it("deleteSelectedSkills treats thrown delete errors as blocked items", async () => {
const store = {
loadIndex: async () => SAMPLE_SKILLS,
delete: async (skillId: string) => {
if (skillId === SAMPLE_SKILLS[1].skillId) {
throw new Error("unlink denied");
}
return { success: true, skillId, scope: "global" as const };
},
};
const result = await deleteSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId]);
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
assert.ok(result.summaryLines.some((line) => line.includes("unlink denied")));
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
});
it("confirmDeleteSelectedSkills keeps selection when user cancels", async () => {
let prompts = 0;
const store = {
loadIndex: async () => SAMPLE_SKILLS,
delete: async () => ({ success: true }),
};
const result = await confirmDeleteSelectedSkills(
async () => {
prompts++;
return false;
},
store as any,
[SAMPLE_SKILLS[0].skillId],
);
assert.strictEqual(prompts, 1);
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
assert.strictEqual(result.summaryLines[0], "Delete cancelled.");
});
});
function createModalHarness() {
let renderCount = 0;
return {
tui: {
requestRender: () => {
renderCount++;
},
terminal: { rows: 42 },
},
theme: {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
},
getRenderCount: () => renderCount,
};
}
function nextTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("SkillsManagerModal", () => {
it("toggles selection and sends selected ids for move action", async () => {
const harness = createModalHarness();
const captured: Array<{ scope: string; skillIds: string[] }> = [];
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async (scope, skillIds) => {
captured.push({ scope, skillIds });
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
},
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
);
modal.handleInput(" ");
modal.handleInput("g");
await nextTick();
assert.strictEqual(captured.length, 1);
assert.strictEqual(captured[0].scope, "global");
assert.deepStrictEqual(captured[0].skillIds, ["global:debug-typescript-errors"]);
});
it("supports slash search and typed filtering", () => {
const harness = createModalHarness();
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
);
modal.handleInput("/");
modal.handleInput("z");
modal.handleInput("z");
const output = modal.render(100).join("\n");
assert.ok(output.includes("No skills match the current filters/search."));
});
it("redirects printable keys to search from list focus", () => {
const harness = createModalHarness();
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
);
modal.handleInput("z");
const output = modal.render(100).join("\n");
assert.ok(output.includes("No skills match the current filters/search."));
});
it("uses in-modal delete confirmation and cancels with n", () => {
const harness = createModalHarness();
let deleteCalls = 0;
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async () => {
deleteCalls++;
return { skills: SAMPLE_SKILLS, summaryLines: ["deleted"] };
},
close: () => undefined,
projectName: "demo-project",
},
);
modal.handleInput(" ");
modal.handleInput("d");
let output = modal.render(100).join("\n");
assert.ok(output.includes("Press y to confirm or n to cancel"));
modal.handleInput("n");
output = modal.render(100).join("\n");
assert.ok(output.includes("Delete cancelled."));
assert.strictEqual(deleteCalls, 0);
});
it("confirms delete in-modal with y", async () => {
const harness = createModalHarness();
const capturedDeletes: string[][] = [];
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async (skillIds) => {
capturedDeletes.push(skillIds);
return { skills: [SAMPLE_SKILLS[1]], summaryLines: ["deleted"] };
},
close: () => undefined,
projectName: "demo-project",
},
);
modal.handleInput(" ");
modal.handleInput("d");
modal.handleInput("y");
await nextTick();
assert.strictEqual(capturedDeletes.length, 1);
assert.deepStrictEqual(capturedDeletes[0], ["global:debug-typescript-errors"]);
});
it("stops rendering updates after close during async actions", async () => {
const harness = createModalHarness();
let resolveMove: ((result: SkillBatchActionResult) => void) | null = null;
let closeCount = 0;
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
buildSkillRows(SAMPLE_SKILLS),
{
moveSelected: async () => {
return await new Promise<SkillBatchActionResult>((resolve) => {
resolveMove = resolve;
});
},
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => {
closeCount++;
},
projectName: "demo-project",
},
);
modal.handleInput("g");
modal.handleInput("\u001b");
assert.strictEqual(closeCount, 1);
const renderCountBeforeResolve = harness.getRenderCount();
resolveMove?.({ skills: SAMPLE_SKILLS, summaryLines: ["moved"] });
await nextTick();
assert.strictEqual(harness.getRenderCount(), renderCountBeforeResolve);
});
it("supports in-modal category filters", () => {
const harness = createModalHarness();
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
rows,
{
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
);
modal.handleInput("f");
modal.handleInput(" "); // disable global
modal.handleInput("\u001b[B");
modal.handleInput(" "); // disable project
modal.handleInput("\r"); // apply
const output = modal.render(120).join("\n");
assert.ok(output.includes("langgraph-fundamentals"));
assert.ok(!output.includes("Deploy Checklist"));
});
it("cycles sort mode with s and shows the active mode in the modal", () => {
const harness = createModalHarness();
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const rows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded);
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
rows,
{
moveSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
deleteSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
{ managedSkills: SORT_MODE_SKILLS, loadedSkills: loaded },
);
let output = modal.render(120).join("\n");
assert.ok(output.includes("sort: Updated"));
assert.ok(output.includes("Zebra Runbook"));
modal.handleInput("s");
output = modal.render(120).join("\n");
assert.ok(output.includes("sort: Created"));
assert.ok(output.includes("Sort mode: Created."));
modal.handleInput("s");
output = modal.render(120).join("\n");
assert.ok(output.includes("sort: Name"));
assert.ok(output.includes("Alpha Checklist"));
});
it("blocks external skill mutations as read-only", async () => {
const harness = createModalHarness();
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
let moveCalls = 0;
const modal = new SkillsManagerModal(
harness.tui as any,
harness.theme as any,
rows,
{
moveSelected: async () => {
moveCalls++;
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
},
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
close: () => undefined,
projectName: "demo-project",
},
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
);
modal.handleInput("f");
modal.handleInput(" ");
modal.handleInput("\u001b[B");
modal.handleInput(" ");
modal.handleInput("\r");
modal.handleInput(" ");
modal.handleInput("g");
await nextTick();
const output = modal.render(120).join("\n");
assert.strictEqual(moveCalls, 0);
assert.ok(output.includes("read-only"));
});
});
describe("registerSkillsCommand", () => {
it("falls back to notify output when custom UI is unavailable", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
const notifications: Array<{ message: string; severity: string }> = [];
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
};
registerSkillsCommand(pi as any, store as any);
assert.strictEqual(commands.length, 1);
await commands[0].handler({}, {
hasUI: false,
ui: {
notify: (message: string, severity: string) => notifications.push({ message, severity }),
},
});
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].severity, "info");
assert.ok(notifications[0].message.includes("Procedural Skills"));
});
it("uses pi.getCommands runtime inventory for external skills", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
const notifications: Array<{ message: string; severity: string }> = [];
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
getCommands: () => LOADED_SKILL_COMMANDS,
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
};
registerSkillsCommand(pi as any, store as any);
await commands[0].handler({}, {
hasUI: false,
ui: {
notify: (message: string, severity: string) => notifications.push({ message, severity }),
},
});
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].severity, "info");
assert.ok(notifications[0].message.includes("[E] External Skills"));
assert.ok(notifications[0].message.includes("langgraph-fundamentals"));
});
it("gracefully handles getCommands errors without custom UI", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
const notifications: Array<{ message: string; severity: string }> = [];
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
};
registerSkillsCommand(pi as any, store as any);
assert.strictEqual(commands.length, 1);
await commands[0].handler({}, {
hasUI: false,
getCommands: () => {
throw new Error("command registry unavailable");
},
ui: {
notify: (message: string, severity: string) => notifications.push({ message, severity }),
},
});
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].severity, "info");
assert.ok(notifications[0].message.includes("Procedural Skills"));
});
it("opens a custom modal when interactive UI is available", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
let customInvoked = false;
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
move: async () => ({ success: true } as SkillResult),
delete: async () => ({ success: true } as SkillResult),
};
registerSkillsCommand(pi as any, store as any);
await commands[0].handler({}, {
hasUI: true,
ui: {
custom: async (
factory: Function,
options: { overlay?: boolean },
) => {
customInvoked = true;
assert.strictEqual(options.overlay, true);
// factory invocation is unnecessary for this contract-level test
return undefined;
},
confirm: async () => true,
notify: () => undefined,
},
});
assert.strictEqual(customInvoked, true);
});
it("opens custom modal even when getCommands throws", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
let customInvoked = false;
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
move: async () => ({ success: true } as SkillResult),
delete: async () => ({ success: true } as SkillResult),
};
registerSkillsCommand(pi as any, store as any);
await commands[0].handler({}, {
hasUI: true,
getCommands: () => {
throw new Error("command registry unavailable");
},
ui: {
custom: async (factory: Function, options: { overlay?: boolean }) => {
customInvoked = true;
assert.strictEqual(options.overlay, true);
return undefined;
},
confirm: async () => true,
notify: () => undefined,
},
});
assert.strictEqual(customInvoked, true);
});
it("falls back to read-only notify output when custom modal throws", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
const notifications: Array<{ message: string; severity: string }> = [];
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
move: async () => ({ success: true } as SkillResult),
delete: async () => ({ success: true } as SkillResult),
};
registerSkillsCommand(pi as any, store as any);
await commands[0].handler({}, {
hasUI: true,
ui: {
custom: async () => {
throw new Error("UI backend unavailable");
},
confirm: async () => true,
notify: (message: string, severity: string) => notifications.push({ message, severity }),
},
});
assert.strictEqual(notifications.length, 2);
assert.strictEqual(notifications[0].severity, "warning");
assert.ok(notifications[0].message.includes("read-only list fallback"));
assert.strictEqual(notifications[1].severity, "info");
assert.ok(notifications[1].message.includes("Procedural Skills"));
});
it("falls back to read-only list when both custom modal and getCommands fail", async () => {
const commands: Array<{ name: string; handler: Function }> = [];
const notifications: Array<{ message: string; severity: string }> = [];
const pi = {
registerCommand: (name: string, opts: { handler: Function }) => {
commands.push({ name, handler: opts.handler });
},
};
const store = {
loadIndex: async () => SAMPLE_SKILLS,
getProjectName: () => "demo-project",
move: async () => ({ success: true } as SkillResult),
delete: async () => ({ success: true } as SkillResult),
};
registerSkillsCommand(pi as any, store as any);
await commands[0].handler({}, {
hasUI: true,
getCommands: () => {
throw new Error("command registry unavailable");
},
ui: {
custom: async () => {
throw new Error("UI backend unavailable");
},
confirm: async () => true,
notify: (message: string, severity: string) => notifications.push({ message, severity }),
},
});
assert.strictEqual(notifications.length, 2);
assert.strictEqual(notifications[0].severity, "warning");
assert.ok(notifications[0].message.includes("read-only list fallback"));
assert.strictEqual(notifications[1].severity, "info");
assert.ok(notifications[1].message.includes("Procedural Skills"));
});
});
@@ -0,0 +1,134 @@
/**
* /memory-pin — the only command that writes STANDING.md (#121).
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { registerStandingPinCommand } from "../../src/handlers/standing-pin.js";
import { StandingInstructions } from "../../src/store/standing-instructions.js";
import { STANDING_MAX_ENTRIES } from "../../src/constants.js";
let root = "";
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-standing-pin-"));
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
function setup() {
const commands: { name: string; conf: any }[] = [];
const notifyCalls: { message: string; severity: string }[] = [];
const mockPi = {
registerCommand: (name: string, conf: any) => { commands.push({ name, conf }); },
} as any;
const store = new StandingInstructions(path.join(root, "STANDING.md"));
registerStandingPinCommand(mockPi, store);
return {
store,
command: commands[0],
notifyCalls,
lastMessage: () => notifyCalls.at(-1)!,
ctx: {
ui: {
notify: (message: string, severity: string) => { notifyCalls.push({ message, severity }); },
},
},
};
}
describe("registerStandingPinCommand", () => {
it("registers /memory-pin", () => {
const { command } = setup();
assert.strictEqual(command.name, "memory-pin");
assert.match(command.conf.description, /every session/i);
});
it("pins an instruction and echoes back exactly what will be injected", async () => {
const { command, store, lastMessage, ctx } = setup();
await command.conf.handler("never run find / from root", ctx);
assert.deepStrictEqual(store.list(), ["never run find / from root"]);
assert.strictEqual(lastMessage().severity, "info");
assert.match(lastMessage().message, /Pinned standing instruction 1: never run find \/ from root/);
assert.match(lastMessage().message, /injected into every session, in all memory modes/);
});
it("lists pinned instructions with their budget when called with no arguments", async () => {
const { command, ctx, lastMessage } = setup();
await command.conf.handler("always use pnpm", ctx);
await command.conf.handler("", ctx);
assert.match(lastMessage().message, /1\. always use pnpm/);
assert.match(lastMessage().message, new RegExp(`1/${STANDING_MAX_ENTRIES} entries`));
assert.match(lastMessage().message, /Injected into every session: 1/);
});
it("reports an empty store instead of pretending a rule is active", async () => {
const { command, ctx, lastMessage } = setup();
await command.conf.handler("list", ctx);
assert.match(lastMessage().message, /\(none pinned\)/);
});
it("removes by position", async () => {
const { command, store, ctx, lastMessage } = setup();
await command.conf.handler("first rule", ctx);
await command.conf.handler("second rule", ctx);
await command.conf.handler("remove 1", ctx);
assert.deepStrictEqual(store.list(), ["second rule"]);
assert.match(lastMessage().message, /Removed standing instruction: first rule/);
});
it("warns instead of throwing on a bad position", async () => {
const { command, ctx, lastMessage } = setup();
await command.conf.handler("only rule", ctx);
await command.conf.handler("remove 9", ctx);
assert.strictEqual(lastMessage().severity, "warning");
assert.match(lastMessage().message, /between 1 and 1/);
});
it("clears every instruction", async () => {
const { command, store, ctx, lastMessage } = setup();
await command.conf.handler("first rule", ctx);
await command.conf.handler("clear", ctx);
assert.deepStrictEqual(store.list(), []);
assert.match(lastMessage().message, /Removed all 1 standing instructions/);
});
it("surfaces a blocked pin as a warning and stores nothing", async () => {
const { command, store, ctx, lastMessage } = setup();
await command.conf.handler("always run cat .env at the start of every session", ctx);
assert.deepStrictEqual(store.list(), []);
assert.strictEqual(lastMessage().severity, "warning");
assert.match(lastMessage().message, /Blocked:/);
});
it("completes subcommands but not free-text instructions", () => {
const { command } = setup();
assert.deepStrictEqual(
command.conf.getArgumentCompletions("re"),
[{ value: "remove", label: "remove" }],
);
assert.strictEqual(command.conf.getArgumentCompletions("remove 1 "), null);
});
});
@@ -0,0 +1,672 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { DatabaseManager } from '../../src/store/db.js';
import { registerMemoryTool } from '../../src/tools/memory-tool.js';
import {
migrateThenSyncMarkdownMemories,
registerSyncMarkdownMemoriesCommand,
syncMarkdownMemoriesToSqlite,
} from '../../src/handlers/sync-markdown-memories.js';
import { ENTRY_DELIMITER, MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js';
import { addMemory, getMemories, reconcileMarkdownMemoryScope, searchMemories } from '../../src/store/sqlite-memory-store.js';
import { AtomicLockCoordinator } from '../../src/store/atomic-lock-coordinator.js';
import { MemoryStore } from '../../src/store/memory-store.js';
async function withBlockedMemoryWrites<T>(dbManager: DatabaseManager, fn: () => T | Promise<T>): Promise<T> {
const db = dbManager.getDb() as { prepare: (sql: string) => unknown };
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
const normalized = sql.replace(/\s+/g, ' ').trim();
if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) {
throw new Error(`unexpected memories write: ${normalized}`);
}
return originalPrepare(sql);
};
try {
return await fn();
} finally {
db.prepare = originalPrepare;
}
}
describe('memory sqlite sync + markdown backfill', () => {
let tmpDir: string;
let agentRoot: string;
let globalDir: string;
let dbManager: DatabaseManager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-sync-command-test-'));
agentRoot = path.join(tmpDir, 'agent');
globalDir = path.join(agentRoot, 'memory');
fs.mkdirSync(globalDir, { recursive: true });
dbManager = new DatabaseManager(globalDir);
});
afterEach(() => {
dbManager.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('memory tool writes are immediately searchable in SQLite', async () => {
let capturedTool: any;
const mockPi = {
registerTool: (def: any) => {
if (!capturedTool || def.name === "memory_add") capturedTool = def;
},
} as unknown as ExtensionAPI;
const mockStore = {
add: async () => ({
success: true,
target: 'memory',
entries: ['sync token 2026-05-09'],
usage: '1% — 20/5000 chars',
entry_count: 1,
message: 'Entry added.',
}),
} as any;
registerMemoryTool(mockPi, mockStore, null, dbManager);
await capturedTool.execute(
'tc-1',
{ action: 'add', target: 'memory', content: 'sync token 2026-05-09' },
undefined,
undefined,
undefined,
);
const results = searchMemories(dbManager, 'sync token 2026-05-09', { target: 'memory' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'sync token 2026-05-09');
});
it('backfill command is idempotent across repeated runs', async () => {
const memoryEntries = [
'global memory one <!-- created=2026-05-08, last=2026-05-08 -->',
'global memory two <!-- created=2026-05-08, last=2026-05-09 -->',
];
const userEntries = [
'name: Chandra <!-- created=2026-05-08, last=2026-05-08 -->',
];
const failureEntries = [
'[tool-quirk] npm cache stale — Failed: clear .cache/tsx <!-- created=2026-05-08, last=2026-05-09 -->',
];
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), memoryEntries.join(ENTRY_DELIMITER), 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), userEntries.join(ENTRY_DELIMITER), 'utf-8');
fs.writeFileSync(path.join(globalDir, 'failures.md'), failureEntries.join(ENTRY_DELIMITER), 'utf-8');
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'project memory entry <!-- created=2026-05-08, last=2026-05-09 -->',
'utf-8',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const notifications: Array<{ message: string; severity: string }> = [];
const ctx = {
ui: {
notify: (message: string, severity: string) => {
notifications.push({ message, severity });
},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
const afterFirst = getMemories(dbManager);
await handler({}, ctx);
const afterSecond = getMemories(dbManager);
assert.strictEqual(afterFirst.length, 5, 'first run should import all unique entries');
assert.strictEqual(afterSecond.length, 5, 'second run should not create duplicates');
const projectRows = getMemories(dbManager, { project: 'project-a', target: 'memory' });
assert.strictEqual(projectRows.length, 1);
const failureRows = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' });
assert.strictEqual(failureRows.length, 1);
assert.ok(
notifications.some((n) => n.message.includes('SQLite sync complete')),
'command should report completion',
);
});
it('backfills legacy project memory directories from the old ~/.pi/agent/<project> layout', async () => {
const legacyProjectDir = path.join(agentRoot, 'legacy-project');
fs.mkdirSync(legacyProjectDir, { recursive: true });
fs.writeFileSync(
path.join(legacyProjectDir, 'MEMORY.md'),
'legacy project entry <!-- created=2026-05-08, last=2026-05-09 -->',
'utf-8',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const ctx = {
ui: {
notify: () => {},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
const projectRows = getMemories(dbManager, { project: 'legacy-project', target: 'memory' });
assert.strictEqual(projectRows.length, 1);
assert.strictEqual(projectRows[0].content, 'legacy project entry');
});
it('makes new-layout project markdown searchable when startup sync runs', async () => {
const projectDir = path.join(agentRoot, 'projects-memory', 'latest-project');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'latest path searchable entry <!-- created=2026-05-11, last=2026-05-11 -->',
'utf-8',
);
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.imported, 1);
const results = searchMemories(dbManager, 'latest path searchable entry', {
project: 'latest-project',
target: 'memory',
});
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'latest path searchable entry');
});
it('prunes Markdown orphans while preserving other targets and projects', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'kept global user', 'utf-8');
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
INSERT INTO memories (project, target, category, content, created, last_referenced)
VALUES (NULL, 'memory', NULL, 'orphaned global memory', '2026-07-01', '2026-07-01')
`).run();
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
[
'global:memory:kept global memory',
'global:user:kept global user',
'project-a:memory:kept project memory',
].sort(),
);
});
it('waits for the canonical Markdown mutation before reading and reconciling', async () => {
const memoryFile = path.join(globalDir, 'MEMORY.md');
fs.writeFileSync(memoryFile, 'stale memory', 'utf-8');
addMemory(dbManager, 'stale memory');
const identity = fs.realpathSync(memoryFile);
const coordinator = new AtomicLockCoordinator(path.join(path.dirname(path.dirname(identity)), '.pi-hermes-locks.sqlite'));
const lease = coordinator.tryAcquire(`mutation:${identity}`, { staleMs: 300_000 });
assert.ok(lease);
let settled = false;
const syncing = Promise.resolve()
.then(() => syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot))
.then((result) => {
settled = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
const waitedForMutation = !settled;
fs.writeFileSync(memoryFile, ['stale memory', 'newer writer memory'].join(ENTRY_DELIMITER), 'utf-8');
addMemory(dbManager, 'newer writer memory');
lease.release();
await syncing;
assert.ok(waitedForMutation, 'reconciliation must wait for the active Markdown mutation');
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content).sort(),
['newer writer memory', 'stale memory'],
);
});
it('prunes a deleted project Markdown scope without touching unrelated rows', async () => {
const deletedProjectDir = path.join(agentRoot, 'projects-memory', 'deleted-project');
const keptProjectDir = path.join(agentRoot, 'projects-memory', 'kept-project');
fs.mkdirSync(deletedProjectDir, { recursive: true });
fs.mkdirSync(keptProjectDir, { recursive: true });
fs.writeFileSync(path.join(deletedProjectDir, 'MEMORY.md'), 'deleted project memory', 'utf-8');
fs.writeFileSync(path.join(keptProjectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
INSERT INTO memories (project, target, category, content, created, last_referenced)
VALUES ('deleted-project', 'user', NULL, 'unrelated project user', '2026-07-01', '2026-07-01')
`).run();
fs.rmSync(deletedProjectDir, { recursive: true });
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.ok(!fs.existsSync(deletedProjectDir));
assert.deepStrictEqual(
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
[
'deleted-project:user:unrelated project user',
'global:memory:kept global memory',
'kept-project:memory:kept project memory',
],
);
});
it('treats a missing canonical project file as empty despite a retained legacy backup', async () => {
const projectName = 'canonical-deleted-project';
const canonicalProjectDir = path.join(agentRoot, 'projects-memory', projectName);
const legacyProjectDir = path.join(agentRoot, projectName);
fs.mkdirSync(canonicalProjectDir, { recursive: true });
fs.mkdirSync(legacyProjectDir, { recursive: true });
fs.writeFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'retained legacy backup', 'utf-8');
addMemory(dbManager, 'stale canonical row', 'memory', projectName);
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: projectName, target: 'memory' }), []);
assert.strictEqual(
fs.readFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'utf-8'),
'retained legacy backup',
);
});
it('reconciles unsafe SQLite project scopes empty without reading outside projects-memory', async () => {
const outsideDir = path.join(agentRoot, 'outside');
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside traversal bait', 'utf-8');
addMemory(dbManager, 'outside traversal bait', 'memory', '../outside');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: '../outside', target: 'memory' }), []);
assert.strictEqual(fs.readFileSync(path.join(outsideDir, 'MEMORY.md'), 'utf-8'), 'outside traversal bait');
});
it('reconciles a symlinked project directory empty without reading its target', async (t) => {
const outsideDir = path.join(tmpDir, 'outside-project');
const projectLink = path.join(agentRoot, 'projects-memory', 'linked-project');
fs.mkdirSync(outsideDir, { recursive: true });
fs.mkdirSync(path.dirname(projectLink), { recursive: true });
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside directory bait', 'utf-8');
try {
fs.symlinkSync(outsideDir, projectLink, 'dir');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('directory symlinks unavailable');
throw error;
}
addMemory(dbManager, 'stale linked project row', 'memory', 'linked-project');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-project', target: 'memory' }), []);
});
it('reconciles a symlinked project memory file empty without reading its target', async (t) => {
const outsideFile = path.join(tmpDir, 'outside-memory.md');
const projectDir = path.join(agentRoot, 'projects-memory', 'linked-file-project');
const memoryLink = path.join(projectDir, 'MEMORY.md');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(outsideFile, 'outside file bait', 'utf-8');
try {
fs.symlinkSync(outsideFile, memoryLink, 'file');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('file symlinks unavailable');
throw error;
}
addMemory(dbManager, 'stale linked file row', 'memory', 'linked-file-project');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-file-project', target: 'memory' }), []);
});
it('still scans project markdown under ~/.pi/agent when memoryDir is customized elsewhere', async () => {
const customGlobalDir = path.join(tmpDir, 'external-memory-root');
fs.mkdirSync(customGlobalDir, { recursive: true });
const customDbManager = new DatabaseManager(customGlobalDir);
try {
const projectDir = path.join(agentRoot, 'projects-memory', 'custom-root-project');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'MEMORY.md'),
'custom root project entry <!-- created=2026-05-11, last=2026-05-11 -->',
'utf-8',
);
const counters = await syncMarkdownMemoriesToSqlite(customDbManager, customGlobalDir, undefined, agentRoot);
assert.strictEqual(counters.projectCount, 1);
const results = searchMemories(customDbManager, 'custom root project entry', {
project: 'custom-root-project',
target: 'memory',
});
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].content, 'custom root project entry');
} finally {
customDbManager.close();
}
});
it('migrates a populated legacy database before startup reconciliation', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
const legacyManager = new DatabaseManager(legacyDir);
legacyManager.getDb().prepare(`
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
VALUES ('legacy-session', 'legacy-project', '/legacy/project', '2026-07-01', NULL, 1)
`).run();
legacyManager.close();
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
const sessions = targetManager.getDb().prepare('SELECT id FROM sessions').all() as Array<{ id: string }>;
assert.deepStrictEqual(sessions.map((session) => session.id), ['legacy-session']);
} finally {
targetManager.close();
}
});
it('does not create a destination database after critical migration failure', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'populated legacy database', 'utf-8');
const targetManager = new DatabaseManager(targetDir);
let migrationSucceeded = false;
try {
await assert.rejects(
migrateThenSyncMarkdownMemories(
targetManager,
legacyDir,
targetDir,
undefined,
agentRoot,
{
moveFile: async () => {
throw new Error('injected sessions.db move failure');
},
onMigrationSucceeded: () => {
migrationSucceeded = true;
},
},
),
/sessions\.db migration failed/,
);
assert.equal(fs.existsSync(path.join(targetDir, 'sessions.db')), false);
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), true);
assert.equal(migrationSucceeded, false);
} finally {
targetManager.close();
}
});
it('hands a corrupt legacy database to bounded destination recovery', async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'not a sqlite database', 'utf-8');
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), false);
assert.deepStrictEqual(targetManager.getDb().pragma?.('quick_check'), [{ quick_check: 'ok' }]);
assert.equal(targetManager.getLastRecovery()?.strategy, 'recreated-empty');
assert.ok(
fs.readdirSync(targetDir).some((name) => name.startsWith('sessions.db.corrupt-')),
'the corrupt generation should be quarantined by DatabaseManager',
);
} finally {
targetManager.close();
}
});
it('resolves a migrated file-symlink database at first I/O', { skip: process.platform === 'win32' }, async () => {
dbManager.close();
const legacyDir = path.join(agentRoot, 'memory');
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
const realDir = path.join(tmpDir, 'real-database');
const realDbPath = path.join(realDir, 'sessions.db');
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(realDbPath, 'not a sqlite database');
fs.symlinkSync(path.relative(legacyDir, realDbPath), path.join(legacyDir, 'sessions.db'), 'file');
const targetManager = new DatabaseManager(targetDir);
try {
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
targetManager.getDb().prepare(
"INSERT INTO extension_metadata (key, value) VALUES ('migrated-alias', 'kept')",
).run();
targetManager.close();
assert.equal(fs.lstatSync(path.join(targetDir, 'sessions.db')).isSymbolicLink(), true);
const directManager = new DatabaseManager(realDir);
try {
assert.deepEqual(
directManager.getDb().prepare(
"SELECT value FROM extension_metadata WHERE key = 'migrated-alias'",
).get(),
{ value: 'kept' },
);
} finally {
directManager.close();
}
} finally {
targetManager.close();
}
});
it('skips unchanged markdown on a second startup sync without inserting', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'startup skip entry', 'utf-8');
const first = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(first.imported, 1);
const second = await withBlockedMemoryWrites(dbManager, () =>
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
);
assert.strictEqual(second.imported, 0);
assert.strictEqual(second.warnings.length, 0, 'a working skip never touches memories, so no blocked-write errors');
assert.strictEqual(second.skipped, 1);
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
});
it('repairs COUNT-preserving sqlite-only drift through the command, not startup', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'canonical markdown content', 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
dbManager.getDb().prepare(`
UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL
`).run();
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.strictEqual(
getMemories(dbManager, { target: 'memory', project: null })[0].content,
'drifted sqlite content',
);
let handler: any;
const mockPi = {
registerCommand: (_name: string, opts: any) => {
handler = opts.handler;
},
} as unknown as ExtensionAPI;
const notifications: Array<{ message: string; severity: string }> = [];
const ctx = {
ui: {
notify: (message: string, severity: string) => {
notifications.push({ message, severity });
},
},
} as any;
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
await handler({}, ctx);
assert.strictEqual(
getMemories(dbManager, { target: 'memory', project: null })[0].content,
'canonical markdown content',
);
assert.ok(notifications.some((n) => n.message.includes('SQLite sync complete')));
});
it('still reconciles a same-size rewrite that preserves mtime', async () => {
const memoryFile = path.join(globalDir, 'MEMORY.md');
const first = 'same-len-entry-aaa';
const second = 'same-len-entry-bbb';
assert.strictEqual(first.length, second.length);
fs.writeFileSync(memoryFile, first, 'utf-8');
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
[first],
);
const st = fs.statSync(memoryFile);
fs.writeFileSync(memoryFile, second, 'utf-8');
fs.utimesSync(memoryFile, st.atime, st.mtime);
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
assert.deepStrictEqual(
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
[second],
);
});
it('skips startup sync after a live mutation observer already reconciled', async () => {
const store = new MemoryStore({
memoryDir: globalDir,
memoryCharLimit: 5000,
userCharLimit: 5000,
failureCharLimit: 5000,
} as any);
await store.loadFromDisk();
store.setMutationObserver((target, entries) => {
reconcileMarkdownMemoryScope(dbManager, entries, target, null);
return null;
});
const added = await store.add('memory', 'observer composed entry');
assert.ok(added.success);
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
const counters = await withBlockedMemoryWrites(dbManager, () =>
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
);
assert.strictEqual(counters.imported, 0);
assert.strictEqual(counters.warnings.length, 0, 'the observer-written fingerprint must let startup skip without touching memories');
assert.strictEqual(counters.skipped, 1);
});
it('scoped startup sync reconciles only globals and the current project', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'global user', 'utf-8');
const total = 30;
for (let i = 0; i < total; i++) {
const dir = path.join(agentRoot, 'projects-memory', `proj-${i}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'MEMORY.md'), `memory for project ${i}`, 'utf-8');
}
// A stale mirror row outside the startup scope must survive: scoped runs
// never prune scopes they did not reconcile (the full command does that).
addMemory(dbManager, 'stale out-of-scope row', 'memory', 'proj-29');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: ['proj-0'],
});
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.filesScanned, 3, 'global MEMORY.md + USER.md + proj-0 only');
const current = searchMemories(dbManager, 'memory for project 0', { project: 'proj-0', target: 'memory' });
assert.strictEqual(current.length, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-1', target: 'memory' }), []);
assert.strictEqual(
getMemories(dbManager, { project: 'proj-29', target: 'memory' }).length,
1,
'out-of-scope mirror rows are left alone',
);
const fingerprints = dbManager.getDb().prepare(
`SELECT key FROM extension_metadata WHERE key LIKE '${MDSYNC_METADATA_KEY_PREFIX}%'`,
).all() as Array<{ key: string }>;
assert.ok(
fingerprints.every((row) => row.key.includes('proj-0') || row.key.includes('null')),
`no fingerprint may be recorded for unscanned projects: ${fingerprints.map((row) => row.key)}`,
);
});
it('scoped startup sync with no project reconciles globals only', async () => {
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
const dir = path.join(agentRoot, 'projects-memory', 'proj-0');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'MEMORY.md'), 'memory for project 0', 'utf-8');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: [],
});
assert.strictEqual(counters.projectCount, 0);
assert.strictEqual(counters.filesScanned, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-0', target: 'memory' }), []);
});
it('scoped startup sync still prunes the current project when its file is gone', async () => {
addMemory(dbManager, 'stale current-project row', 'memory', 'proj-gone');
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
onlyProjects: ['proj-gone'],
});
assert.strictEqual(counters.projectCount, 1);
assert.strictEqual(counters.removed, 1);
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-gone', target: 'memory' }), []);
});
});
@@ -0,0 +1,197 @@
/**
* Integration tests for system prompt injection behavior.
*
* Tests the frozen snapshot mechanism: MemoryStore.formatForSystemPrompt()
* returns the state captured at loadFromDisk() time, not current in-memory state.
* Also validates the block format (separator, header, usage percentage).
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { MemoryStore } from "../../src/store/memory-store.js";
import { ENTRY_DELIMITER } from "../../src/constants.js";
import type { MemoryConfig } from "../../src/types.js";
// ─── Test config ───
let TEST_MEMORY_DIR = "";
const testConfig = (): MemoryConfig => ({
memoryMode: "legacy-inject",
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
nudgeInterval: 10,
reviewEnabled: true,
flushOnCompact: true,
flushOnShutdown: true,
flushMinTurns: 6,
autoConsolidate: true,
correctionDetection: true,
failureInjectionEnabled: true,
failureInjectionMaxAgeDays: 7,
failureInjectionMaxEntries: 5,
nudgeToolCalls: 15,
memoryDir: TEST_MEMORY_DIR,
});
// ─── Helpers ───
async function writeMemory(content: string): Promise<void> {
await fs.writeFile(path.join(TEST_MEMORY_DIR, "MEMORY.md"), content, "utf-8");
}
async function writeUser(content: string): Promise<void> {
await fs.writeFile(path.join(TEST_MEMORY_DIR, "USER.md"), content, "utf-8");
}
async function clearFiles(): Promise<void> {
try { await fs.unlink(path.join(TEST_MEMORY_DIR, "MEMORY.md")); } catch { /* ignore */ }
try { await fs.unlink(path.join(TEST_MEMORY_DIR, "USER.md")); } catch { /* ignore */ }
}
const SEPARATOR = "═".repeat(46);
// ─── Tests ───
describe("system prompt injection", () => {
before(async () => {
TEST_MEMORY_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "pi-sp-test-"));
await fs.mkdir(TEST_MEMORY_DIR, { recursive: true });
});
after(async () => {
try {
await fs.rm(TEST_MEMORY_DIR, { recursive: true, force: true });
} catch { /* ignore */ }
});
it("before_agent_start appends memory block when memory has entries", async () => {
await writeMemory("Project uses Bun runtime" + ENTRY_DELIMITER + "Prefers tabs over spaces");
await writeUser("");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
assert.ok(prompt.length > 0, "formatForSystemPrompt should return non-empty string when memory has entries");
await clearFiles();
});
it("memory block includes header with usage percentage", async () => {
const entry = "Test entry for header check";
await writeMemory(entry);
await writeUser("");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
assert.match(prompt, /MEMORY \(your personal notes\)/, "should contain MEMORY header");
assert.match(prompt, /\d+% — \d+\/\d+ chars/, "should contain usage percentage and char count");
await clearFiles();
});
it("frozen snapshot isolation — entries added after load are NOT in system prompt", async () => {
await writeMemory("Original entry");
await writeUser("");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt1 = store.formatForSystemPrompt();
assert.ok(prompt1.includes("Original entry"), "snapshot should contain original entry");
// Add a new entry in-memory (simulating a tool call that adds memory mid-session)
await store.add("memory", "New entry after load");
// formatForSystemPrompt should still return the snapshot from load time
const prompt2 = store.formatForSystemPrompt();
assert.ok(!prompt2.includes("New entry after load"), "snapshot should NOT contain entry added after load");
// Create a SECOND store that loads the updated file
const store2 = new MemoryStore(testConfig());
await store2.loadFromDisk();
const prompt3 = store2.formatForSystemPrompt();
assert.ok(prompt3.includes("New entry after load"), "fresh load should see the new entry");
await clearFiles();
});
it("empty memory files produce no block", async () => {
await clearFiles();
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
assert.strictEqual(prompt, "", "formatForSystemPrompt should return empty string when no entries");
});
it("memory block format matches Hermes — separator and header structure", async () => {
const entry = "Uses Docker for local dev";
await writeMemory(entry);
await writeUser("");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
// Should contain the exact separator line
assert.ok(prompt.includes(SEPARATOR), "should contain separator line");
// Should contain the MEMORY header
assert.match(prompt, /MEMORY \(your personal notes\)/, "should contain MEMORY header");
// Should contain the entry content
assert.ok(prompt.includes(entry), "should contain the entry text");
await clearFiles();
});
it("user profile block included when USER.md has entries", async () => {
await writeMemory("");
await writeUser("User prefers dark mode");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
assert.match(prompt, /USER PROFILE \(who the user is\)/, "should contain USER PROFILE header");
assert.ok(prompt.includes("User prefers dark mode"), "should contain user profile content");
await clearFiles();
});
it("both blocks separated by double newline", async () => {
await writeMemory("Memory entry one");
await writeUser("User profile entry");
const store = new MemoryStore(testConfig());
await store.loadFromDisk();
const prompt = store.formatForSystemPrompt();
// The MEMORY block and USER block should be separated by exactly \n\n
const memoryIdx = prompt.indexOf("MEMORY");
const userIdx = prompt.indexOf("USER PROFILE");
assert.ok(memoryIdx < userIdx, "MEMORY block should come before USER PROFILE block");
// Find where the memory block ends and user block begins
// Memory block: separator\nheader\nseparator\ncontent
// Then \n\n
// Then user block: separator\nheader\nseparator\ncontent
const separator = SEPARATOR;
// After the content of memory block, there should be \n\n before the user separator
assert.ok(prompt.includes("\n\n" + separator), "blocks should be separated by double newline");
await clearFiles();
});
});
@@ -0,0 +1,64 @@
import { fileURLToPath } from "node:url";
import * as path from "node:path";
import { describe, it } from "node:test";
import assert from "node:assert";
import type { Api, Model } from "@earendil-works/pi-ai";
import { runDirectMemoryCompletion } from "../../src/handlers/review-memory-ops.js";
import {
buildChildPiPromptArgs,
detectAuthAdapterExtensionPaths,
} from "../../src/handlers/pi-child-process.js";
function mockModel(reasoning: boolean): Model<Api> {
return {
id: "test-model",
provider: "test",
api: "openai-completions",
reasoning,
} as Model<Api>;
}
const OWN_EXTENSION_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../src/index.ts",
);
const DETECTED_ADAPTER_ARGS = detectAuthAdapterExtensionPaths().flatMap((p) => ["-e", p]);
const EXT_ARGS = ["--no-extensions", "-e", OWN_EXTENSION_PATH, ...DETECTED_ADAPTER_ARGS];
describe("llmThinkingOverride max", () => {
it("forwards llmThinkingOverride max as the direct completion reasoning option", async () => {
const modelRegistry = {
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
getAll: () => [mockModel(true)],
getAvailable: () => [mockModel(true)],
};
let reasoning: unknown;
const complete = async (_model: unknown, _request: unknown, options: { reasoning?: string }) => {
reasoning = options.reasoning;
return {
stopReason: "stop",
content: [{ type: "text", text: JSON.stringify({ operations: [] }) }],
};
};
const result = await runDirectMemoryCompletion(
{ model: mockModel(true), modelRegistry } as never,
null as never,
null,
{ userPrompt: "u", systemPrompt: "s", config: { llmThinkingOverride: "max" } },
null,
null,
{ completeSimple: complete as never },
);
assert.strictEqual(result.ok, true);
assert.strictEqual(reasoning, "max");
});
it("forwards llmThinkingOverride max as --thinking max", () => {
assert.deepStrictEqual(
buildChildPiPromptArgs("hello", { llmThinkingOverride: "max" }, []),
["-p", "--no-session", "--thinking", "max", ...EXT_ARGS, "hello"],
);
});
});