mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
从官方 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。
746 lines
26 KiB
TypeScript
746 lines
26 KiB
TypeScript
/**
|
|
* Unit tests for memory tool registration and execute function.
|
|
*/
|
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
import * as assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { registerMemoryTool } from "../../src/tools/memory-tool.js";
|
|
import { MemoryStore } from "../../src/store/memory-store.js";
|
|
import { DatabaseManager } from "../../src/store/db.js";
|
|
import { getMemories, searchMemories, syncMemoryEntry } from "../../src/store/sqlite-memory-store.js";
|
|
import { ENTRY_DELIMITER, MEMORY_FILE } from "../../src/constants.js";
|
|
import { Value } from "typebox/value";
|
|
|
|
describe("registerMemoryTool", () => {
|
|
let tmpDir: string;
|
|
let dbManager: DatabaseManager;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memory-tool-test-"));
|
|
dbManager = new DatabaseManager(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
dbManager.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("registers action-specific memory tools with required parameters", () => {
|
|
const registeredTools: any[] = [];
|
|
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
registeredTools.push(def);
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
registerMemoryTool(mockPi, {} as MemoryStore, null);
|
|
|
|
assert.deepStrictEqual(
|
|
registeredTools.map((tool) => tool.name),
|
|
["memory_add", "memory_replace", "memory_remove"],
|
|
);
|
|
for (const tool of registeredTools) {
|
|
assert.ok(tool.description.length > 0);
|
|
assert.ok(tool.promptSnippet.length > 0);
|
|
assert.ok(Array.isArray(tool.promptGuidelines));
|
|
assert.ok(tool.parameters);
|
|
}
|
|
});
|
|
|
|
it("execute add returns JSON with usage field", async () => {
|
|
let capturedResult: any;
|
|
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["Entry one"],
|
|
usage: "5% — 110/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
|
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
assert.strictEqual(result.content[0].type, "text", "content should be text type");
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
assert.strictEqual(parsed.success, true, "result should be success");
|
|
assert.ok(parsed.usage.includes("chars"), "usage should contain 'chars'");
|
|
assert.ok(parsed.usage.includes("5000"), "usage should show total limit");
|
|
assert.strictEqual(parsed.entry_count, 1, "entry_count should be 1");
|
|
assert.strictEqual(result.details.success, true, "details should mirror result");
|
|
});
|
|
|
|
it("execute add with FIFO evictions returns normal text with full rotated entries", async () => {
|
|
let capturedResult: any;
|
|
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const evictedOne = "First rotated entry with full detail.";
|
|
const evictedTwo = "Second rotated entry with\nmultiple lines preserved.";
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["New entry"],
|
|
usage: "90% — 4500/5000 chars",
|
|
entry_count: 1,
|
|
message: "Memory updated. Rotated 2 older entries to stay within the limit.",
|
|
evicted_entries: [evictedOne, evictedTwo],
|
|
evicted_count: 2,
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null);
|
|
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "New entry" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
const text = result.content[0].text;
|
|
assert.throws(() => JSON.parse(text));
|
|
assert.match(text, /Memory updated\. Rotated 2 older entries/);
|
|
assert.match(text, /Rotated active memory entries:/);
|
|
assert.ok(text.includes(`1. ${evictedOne}`));
|
|
assert.ok(text.includes(`2. ${evictedTwo}`));
|
|
assert.match(text, /If one of these entries should stay active, add it again\./);
|
|
assert.match(text, /Usage: 90%/);
|
|
assert.deepStrictEqual(result.details.evicted_entries, [evictedOne, evictedTwo]);
|
|
});
|
|
|
|
it("syncs successful adds into SQLite", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["Entry one"],
|
|
usage: "5% — 110/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
|
await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
const results = getMemories(dbManager, { target: 'memory', project: null });
|
|
assert.strictEqual(results.length, 1);
|
|
assert.strictEqual(results[0].content, 'Entry one');
|
|
});
|
|
|
|
it("lands over-cap policy-only adds in searchable SQLite", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
const store = new MemoryStore({
|
|
memoryMode: "policy-only",
|
|
memoryCharLimit: 40,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
nudgeInterval: 10,
|
|
reviewEnabled: false,
|
|
flushOnCompact: false,
|
|
flushOnShutdown: false,
|
|
flushMinTurns: 6,
|
|
autoConsolidate: false,
|
|
correctionDetection: false,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
consolidationTimeoutMs: 60000,
|
|
memoryDir: tmpDir,
|
|
});
|
|
await store.loadFromDisk();
|
|
registerMemoryTool(mockPi, store, null, dbManager);
|
|
const first = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "alpha entry" }, undefined as any, undefined as any, undefined as any);
|
|
assert.strictEqual(first.details.success, true);
|
|
const second = await capturedResult.execute("tc-2", { action: "add", target: "memory", content: "beta entry past the tiny cap" }, undefined as any, undefined as any, undefined as any);
|
|
assert.strictEqual(second.details.success, true);
|
|
const results = searchMemories(dbManager, "beta entry past the tiny cap", { target: "memory" });
|
|
assert.ok(results.some((r) => r.content.includes("beta entry past the tiny cap")));
|
|
});
|
|
|
|
it("reports the matching target when replace is sent to the wrong target", async () => {
|
|
let removeTool: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (def.name === "memory_remove") removeTool = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
const store = new MemoryStore({
|
|
memoryMode: "policy-only",
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
nudgeInterval: 10,
|
|
reviewEnabled: false,
|
|
flushOnCompact: false,
|
|
flushOnShutdown: false,
|
|
flushMinTurns: 6,
|
|
autoConsolidate: false,
|
|
correctionDetection: false,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
consolidationTimeoutMs: 60000,
|
|
memoryDir: tmpDir,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.addFailure("use pnpm for lockfiles", { category: "correction" });
|
|
|
|
registerMemoryTool(mockPi, store, null);
|
|
const result = await removeTool.execute(
|
|
"tc-1",
|
|
{ target: "memory", old_text: "use pnpm for lockfiles" },
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
assert.equal(result.details.success, false);
|
|
assert.match(result.details.error, /No match in target "memory"/);
|
|
assert.match(result.details.error, /target "failure"/);
|
|
assert.deepEqual(result.details.matching_targets, ["failure"]);
|
|
});
|
|
|
|
it("prunes same-scope SQLite orphans after a Markdown mutation", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (definition: any) => {
|
|
if (!capturedResult || definition.name === "memory_add") capturedResult = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
const store = new MemoryStore({
|
|
memoryMode: "policy-only",
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
nudgeInterval: 10,
|
|
reviewEnabled: false,
|
|
flushOnCompact: false,
|
|
flushOnShutdown: false,
|
|
flushMinTurns: 6,
|
|
autoConsolidate: false,
|
|
correctionDetection: false,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
consolidationTimeoutMs: 60000,
|
|
memoryDir: tmpDir,
|
|
});
|
|
await store.loadFromDisk();
|
|
syncMemoryEntry(dbManager, { content: "orphaned row", target: "memory", project: null });
|
|
|
|
registerMemoryTool(mockPi, store, null, dbManager);
|
|
await capturedResult.execute(
|
|
"tc-1",
|
|
{ action: "add", target: "memory", content: "authoritative Markdown row" },
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
getMemories(dbManager, { target: "memory", project: null }).map((entry) => entry.content),
|
|
["authoritative Markdown row"],
|
|
);
|
|
});
|
|
|
|
it("reconciles SQLite from fresh authoritative Markdown state", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (definition: any) => {
|
|
if (!capturedResult || definition.name === "memory_add") capturedResult = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
const store = new MemoryStore({
|
|
memoryMode: "policy-only",
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
nudgeInterval: 10,
|
|
reviewEnabled: false,
|
|
flushOnCompact: false,
|
|
flushOnShutdown: false,
|
|
flushMinTurns: 6,
|
|
autoConsolidate: false,
|
|
correctionDetection: false,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
consolidationTimeoutMs: 60000,
|
|
memoryDir: tmpDir,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const originalSave = (store as any).saveToDisk.bind(store);
|
|
(store as any).saveToDisk = async (target: "memory") => {
|
|
await originalSave(target);
|
|
const markdownPath = path.join(tmpDir, MEMORY_FILE);
|
|
const existing = fs.readFileSync(markdownPath, "utf-8");
|
|
const date = new Date().toISOString().split("T")[0];
|
|
fs.writeFileSync(markdownPath, `${existing}${ENTRY_DELIMITER}newer writer <!-- created=${date}, last=${date} -->`);
|
|
syncMemoryEntry(dbManager, { content: "newer writer", target: "memory", project: null });
|
|
};
|
|
|
|
registerMemoryTool(mockPi, store, null, dbManager);
|
|
await capturedResult.execute(
|
|
"tc-1",
|
|
{ action: "add", target: "memory", content: "first writer" },
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
getMemories(dbManager, { target: "memory", project: null }).map((entry) => entry.content).sort(),
|
|
["first writer", "newer writer"],
|
|
);
|
|
});
|
|
|
|
it("removes FIFO-evicted entries from the SQLite mirror", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
syncMemoryEntry(dbManager, {
|
|
content: "Older entry",
|
|
target: "memory",
|
|
project: null,
|
|
});
|
|
syncMemoryEntry(dbManager, {
|
|
content: "Older entry with extra detail",
|
|
target: "memory",
|
|
project: null,
|
|
});
|
|
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["New entry"],
|
|
usage: "90% — 4500/5000 chars",
|
|
entry_count: 1,
|
|
message: "Memory updated. Rotated 1 older entry to stay within the limit.",
|
|
evicted_entries: ["Older entry"],
|
|
evicted_count: 1,
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
|
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "New entry" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
assert.match(result.content[0].text, /Rotated active memory entries:/);
|
|
const rows = getMemories(dbManager, { target: "memory", project: null });
|
|
assert.deepStrictEqual(rows.map((row) => row.content).sort(), ["New entry", "Older entry with extra detail"].sort());
|
|
});
|
|
|
|
it("uses project scope when removing FIFO-evicted SQLite entries", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
syncMemoryEntry(dbManager, {
|
|
content: "Shared wording",
|
|
target: "memory",
|
|
project: null,
|
|
});
|
|
syncMemoryEntry(dbManager, {
|
|
content: "Shared wording",
|
|
target: "memory",
|
|
project: "project-a",
|
|
});
|
|
|
|
const mockProjectStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["Project replacement"],
|
|
usage: "90% — 4500/5000 chars",
|
|
entry_count: 1,
|
|
message: "Memory updated. Rotated 1 older entry to stay within the limit.",
|
|
evicted_entries: ["Shared wording"],
|
|
evicted_count: 1,
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, {} as MemoryStore, mockProjectStore, dbManager, "project-a");
|
|
await capturedResult.execute("tc-1", { action: "add", target: "project", content: "Project replacement" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
const globalRows = getMemories(dbManager, { target: "memory", project: null });
|
|
const projectRows = getMemories(dbManager, { target: "memory", project: "project-a" });
|
|
assert.deepStrictEqual(globalRows.map((row) => row.content), ["Shared wording"]);
|
|
assert.deepStrictEqual(projectRows.map((row) => row.content), ["Project replacement"]);
|
|
});
|
|
|
|
it("maps project target to SQLite project scope", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const addTargets: string[] = [];
|
|
const mockProjectStore = {
|
|
add: (target: string) => {
|
|
addTargets.push(target);
|
|
return {
|
|
success: true,
|
|
target,
|
|
entries: ["Project entry"],
|
|
usage: "2% — 20/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
};
|
|
},
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, {} as MemoryStore, mockProjectStore, dbManager, 'project-a');
|
|
const result = await capturedResult.execute("tc-1", { action: "add", target: "project", content: "Project entry" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
assert.strictEqual(parsed.target, 'project');
|
|
assert.strictEqual(result.details.target, 'project');
|
|
assert.deepStrictEqual(addTargets, ['memory']);
|
|
|
|
const results = getMemories(dbManager, { project: 'project-a', target: 'memory' });
|
|
assert.strictEqual(results.length, 1);
|
|
assert.strictEqual(results[0].content, 'Project entry');
|
|
});
|
|
|
|
it("resolves the active project store and name for each mutation", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
let activeProjectName = "project-a";
|
|
const calls: string[] = [];
|
|
const stores = new Map<string, MemoryStore>();
|
|
const makeProjectStore = (name: string) => ({
|
|
add: (target: string, content: string) => {
|
|
calls.push(`${name}:${target}:${content}`);
|
|
return {
|
|
success: true,
|
|
target,
|
|
entries: [content],
|
|
usage: "2% — 20/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
};
|
|
},
|
|
}) as unknown as MemoryStore;
|
|
stores.set("project-a", makeProjectStore("project-a"));
|
|
stores.set("project-b", makeProjectStore("project-b"));
|
|
|
|
registerMemoryTool(
|
|
mockPi,
|
|
{} as MemoryStore,
|
|
() => stores.get(activeProjectName) ?? null,
|
|
dbManager,
|
|
() => activeProjectName,
|
|
);
|
|
|
|
await capturedResult.execute("tc-1", { target: "project", content: "first" }, undefined as any, undefined as any, undefined as any);
|
|
activeProjectName = "project-b";
|
|
await capturedResult.execute("tc-2", { target: "project", content: "second" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
assert.deepStrictEqual(calls, ["project-a:memory:first", "project-b:memory:second"]);
|
|
const projectRows = getMemories(dbManager, { project: "project-b", target: "memory" });
|
|
assert.deepStrictEqual(projectRows.map((row) => row.content), ["second"]);
|
|
});
|
|
it("reconciles rebound project-store mutations in the project SQLite scope", async () => {
|
|
let activeProjectName = "project-a";
|
|
type MutationObserver = (
|
|
target: "memory" | "user" | "failure",
|
|
entries: string[],
|
|
) => Promise<string | null | undefined>;
|
|
const observers = new Map<string, MutationObserver>();
|
|
const stores = new Map<string, MemoryStore>();
|
|
const makeProjectStore = (name: string) => ({
|
|
setMutationObserver: (observer: MutationObserver) => {
|
|
observers.set(name, observer);
|
|
},
|
|
}) as unknown as MemoryStore;
|
|
stores.set("project-a", makeProjectStore("project-a"));
|
|
stores.set("project-b", makeProjectStore("project-b"));
|
|
const mockPi = {
|
|
registerTool: () => {},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const configureProjectStore = registerMemoryTool(
|
|
mockPi,
|
|
{} as MemoryStore,
|
|
() => stores.get(activeProjectName) ?? null,
|
|
dbManager,
|
|
() => activeProjectName,
|
|
);
|
|
|
|
await observers.get("project-a")?.("memory", ["first project entry"]);
|
|
activeProjectName = "project-b";
|
|
configureProjectStore(stores.get("project-b") ?? null);
|
|
await observers.get("project-b")?.("memory", ["second project entry"]);
|
|
|
|
assert.deepStrictEqual(
|
|
getMemories(dbManager, { target: "memory", project: "project-a" }).map((row) => row.content),
|
|
["first project entry"],
|
|
);
|
|
assert.deepStrictEqual(
|
|
getMemories(dbManager, { target: "memory", project: "project-b" }).map((row) => row.content),
|
|
["second project entry"],
|
|
);
|
|
assert.deepStrictEqual(getMemories(dbManager, { target: "memory", project: null }), []);
|
|
});
|
|
|
|
it("returns a warning instead of failing when SQLite sync errors", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["Entry one"],
|
|
usage: "5% — 110/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
const failingDbManager = {
|
|
getDb: () => {
|
|
throw new Error('sqlite unavailable');
|
|
},
|
|
} as unknown as DatabaseManager;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null, failingDbManager);
|
|
const result = await capturedResult.execute("tc-1", { action: "add", target: "memory", content: "Entry one" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
assert.strictEqual(parsed.success, true);
|
|
assert.match(parsed.message, /SQLite search sync failed/);
|
|
assert.match(parsed.warning, /sqlite unavailable/);
|
|
});
|
|
|
|
it("does not sync to SQLite when core Markdown add fails", async () => {
|
|
let capturedResult: any;
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
add: () => ({
|
|
success: false,
|
|
error: "Memory at 5000/5000 chars. Adding this entry would exceed the limit.",
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
|
const result = await capturedResult.execute(
|
|
"tc-1",
|
|
{ action: "add", target: "memory", content: "overflow entry" },
|
|
undefined as any,
|
|
undefined as any,
|
|
undefined as any,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.content[0].text);
|
|
assert.strictEqual(parsed.success, false);
|
|
|
|
const rows = getMemories(dbManager, { target: "memory", project: null });
|
|
assert.strictEqual(rows.length, 0, "SQLite should stay unchanged when core add fails");
|
|
});
|
|
|
|
it("registers action-specific schemas that reject incomplete mutations", () => {
|
|
const registeredTools: Record<string, any> = {};
|
|
const mockPi = {
|
|
registerTool: (definition: any) => {
|
|
registeredTools[definition.name] = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
registerMemoryTool(mockPi, {} as MemoryStore, null);
|
|
|
|
assert.deepStrictEqual(Object.keys(registeredTools).sort(), [
|
|
"memory_add",
|
|
"memory_remove",
|
|
"memory_replace",
|
|
]);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_add.parameters, {
|
|
action: "add",
|
|
target: "failure",
|
|
category: "tool-quirk",
|
|
failure_reason: "missing content",
|
|
}),
|
|
false,
|
|
"add without content must fail schema validation",
|
|
);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_replace.parameters, {
|
|
target: "memory",
|
|
content: "new",
|
|
}),
|
|
false,
|
|
"replace without old_text must fail schema validation",
|
|
);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_remove.parameters, { target: "memory" }),
|
|
false,
|
|
"remove without old_text must fail schema validation",
|
|
);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_add.parameters, {
|
|
target: "memory",
|
|
content: "durable fact",
|
|
}),
|
|
true,
|
|
);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_replace.parameters, {
|
|
target: "memory",
|
|
old_text: "old",
|
|
content: "new",
|
|
}),
|
|
true,
|
|
);
|
|
assert.strictEqual(
|
|
Value.Check(registeredTools.memory_remove.parameters, {
|
|
target: "memory",
|
|
old_text: "old",
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("execute delegates remove to store.remove", async () => {
|
|
let capturedResult: any;
|
|
let removeArgs: any;
|
|
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (def.name === "memory_remove") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
remove: (...args: any[]) => {
|
|
removeArgs = args;
|
|
return { success: true, target: "memory", entries: [], usage: "0% — 0/5000 chars", entry_count: 0 };
|
|
},
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null);
|
|
await capturedResult.execute("tc-1", { target: "memory", old_text: "old entry" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
assert.deepStrictEqual(removeArgs, ["memory", "old entry"], "should pass target, old_text to store.remove");
|
|
});
|
|
|
|
it("execute delegates replace to store.replace", async () => {
|
|
let capturedResult: any;
|
|
let replaceArgs: any;
|
|
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (def.name === "memory_replace") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockStore = {
|
|
replace: (...args: any[]) => {
|
|
replaceArgs = args;
|
|
return { success: true, target: "memory", entries: ["new"], usage: "5% — 110/5000 chars", entry_count: 1 };
|
|
},
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(mockPi, mockStore, null);
|
|
await capturedResult.execute("tc-1", { action: "replace", target: "memory", content: "new", old_text: "old" }, undefined as any, undefined as any, undefined as any);
|
|
|
|
assert.deepStrictEqual(replaceArgs, ["memory", "old", "new"], "should pass target, old_text, content to store.replace");
|
|
});
|
|
|
|
it("binds project identity from execute ctx.cwd instead of a factory snapshot", async () => {
|
|
let capturedResult: any;
|
|
const boundCwds: string[] = [];
|
|
const mockPi = {
|
|
registerTool: (def: any) => {
|
|
if (!capturedResult || def.name === "memory_add") capturedResult = def;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
|
|
const mockProjectStore = {
|
|
add: () => ({
|
|
success: true,
|
|
target: "memory",
|
|
entries: ["from session cwd"],
|
|
usage: "2% — 20/5000 chars",
|
|
entry_count: 1,
|
|
message: "Entry added.",
|
|
}),
|
|
} as unknown as MemoryStore;
|
|
|
|
registerMemoryTool(
|
|
mockPi,
|
|
{} as MemoryStore,
|
|
mockProjectStore,
|
|
dbManager,
|
|
"factory-project",
|
|
(cwd) => {
|
|
if (cwd) boundCwds.push(cwd);
|
|
},
|
|
);
|
|
|
|
await capturedResult.execute(
|
|
"tc-1",
|
|
{ target: "project", content: "from session cwd" },
|
|
undefined,
|
|
undefined,
|
|
{ cwd: "/tmp/opened-session-project" },
|
|
);
|
|
|
|
assert.deepStrictEqual(boundCwds, ["/tmp/opened-session-project"]);
|
|
});
|
|
|
|
});
|