import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resetWriteCount } from "../activity-counter.js"; import { resetGcTimestamp } from "../auto-gc.js"; import { runDream } from "../dream-pipeline.js"; import { MemoryStore } from "../store.js"; import { PACKAGE_VERSION } from "../version.js"; // Isolate the activity-counter default path away from the repo's data/activity-stats.json. let __origDataDir: string | undefined; beforeAll(() => { __origDataDir = process.env.RECALLNEST_DATA_DIR; process.env.RECALLNEST_DATA_DIR = mkdtempSync(join(tmpdir(), "rn-contracts-datadir-")); }); afterAll(() => { if (__origDataDir === undefined) delete process.env.RECALLNEST_DATA_DIR; else process.env.RECALLNEST_DATA_DIR = __origDataDir; }); const tmpDirs: string[] = []; function makeTmpDir(): string { const dir = mkdtempSync(join(tmpdir(), "recallnest-contract-")); tmpDirs.push(dir); return dir; } function makeStore(): MemoryStore { return new MemoryStore({ dbPath: makeTmpDir(), vectorDim: 3 }); } function uuidFor(n: number): string { return `00000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}`; } function activeMetadata(validFrom = Date.now()): string { return JSON.stringify({ evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, supersededBy: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom, validUntil: null, }, }); } afterEach(() => { while (tmpDirs.length > 0) { const dir = tmpDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } }); describe("MemoryStore interface contract", () => { it("returns stats with totalCount and no legacy total field", async () => { const store = makeStore(); await store.store({ id: "contract-stats-1", text: "contract stats one", vector: [1, 0, 0], category: "events", scope: "project:contracts", importance: 0.4, metadata: activeMetadata(), }); await store.store({ id: "contract-stats-2", text: "contract stats two", vector: [0, 1, 0], category: "preferences", scope: "project:contracts", importance: 0.6, metadata: activeMetadata(), }); const stats = await store.stats(["project:contracts"]); expect(stats.totalCount).toBe(2); expect(stats.scopeCounts["project:contracts"]).toBe(2); expect(stats.categoryCounts.events).toBe(1); expect(stats.categoryCounts.preferences).toBe(1); expect("total" in stats).toBe(false); }); it("uses positional list(scopeFilter, category, limit, offset) arguments", async () => { const store = makeStore(); await store.store({ id: "contract-list-1", text: "contract list one", vector: [1, 0, 0], category: "events", scope: "project:contracts", importance: 0.5, metadata: activeMetadata(), }); await store.store({ id: "contract-list-2", text: "contract list two", vector: [0, 1, 0], category: "events", scope: "project:contracts", importance: 0.5, metadata: activeMetadata(), }); await store.store({ id: "contract-list-3", text: "contract list out of scope", vector: [0, 0, 1], category: "events", scope: "project:other", importance: 0.5, metadata: activeMetadata(), }); const oneEvent = await store.list(["project:contracts"], "events", 1, 1); const allEvents = await store.list(["project:contracts"], "events", 10, 0); expect(oneEvent).toHaveLength(1); expect(oneEvent[0].scope).toBe("project:contracts"); expect(oneEvent[0].category).toBe("events"); expect(allEvents.map(e => e.id).sort()).toEqual(["contract-list-1", "contract-list-2"]); }); }); describe("Dream pipeline store contract", () => { beforeEach(() => { resetWriteCount("project:contracts"); resetGcTimestamp(); }); it("runs prune through auto-gc with real stats/list signatures", async () => { const store = makeStore(); const scope = "project:contracts"; const oldTimestamp = Date.now() - 120 * 86_400_000; for (let i = 0; i < 3; i++) { await store.store({ id: uuidFor(i), text: `contract dream stale memory ${i}`, vector: [1, 0, 0], category: "events", scope, importance: 0.1, metadata: activeMetadata(oldTimestamp), }); } const result = await runDream({ store, llm: null, embedder: { async embedPassage() { return [1, 0, 0]; } }, scope, force: true, config: { minClusterSize: 3, maxEntriesPerRun: 10, gc: { minMemoryCount: 1, minHoursSinceLastGc: 0, decayScoreThreshold: 0.99, maxArchivePerRun: 10, minAgeDays: 0, }, }, }); expect(result.ran).toBe(true); expect(result.stats.archivedCount).toBeGreaterThan(0); expect(result.phases.find(p => p.phase === "prune")?.detail).toContain("entries archived"); }); }); describe("MCP registry contract", () => { it("keeps registered tool count and tier header in sync", () => { const source = readFileSync(join(import.meta.dir, "..", "mcp-server.ts"), "utf8"); const header = source.slice(0, source.indexOf("const TOOL_TIERS")); // P3-B: the 44 registerTool(...) calls now live in the tier modules; mcp-server.ts // still owns TOOL_TIERS and the tier-count header comment above it. const registeredTools = ["mcp-tools-core", "mcp-tools-advanced", "mcp-tools-governance"] .flatMap((name) => [...readFileSync(join(import.meta.dir, "..", `${name}.ts`), "utf8") .matchAll(/^\s*registerTool\(\s*"([^"]+)"/gm)].map((m) => m[1]), ); const tiers = new Map( [...source.matchAll(/^\s*([A-Za-z0-9_]+):\s*"(core|advanced|governance)"/gm)] .map(m => [m[1], m[2]] as const), ); const missingTier = registeredTools.filter(name => !tiers.has(name)); const coreCount = registeredTools.filter(name => tiers.get(name) === "core").length; const defaultCount = registeredTools.filter(name => tiers.get(name) !== "governance").length; const fullCount = registeredTools.length; const headerCount = (tier: "core" | "advanced" | "full") => { const match = header.match(new RegExp(`\\* - ${tier}:.*\\((\\d+) tools\\)`)); expect(match).not.toBeNull(); return Number(match![1]); }; expect(registeredTools).toHaveLength(44); expect(missingTier).toEqual([]); expect(headerCount("core")).toBe(coreCount); expect(headerCount("advanced")).toBe(defaultCount); expect(headerCount("full")).toBe(fullCount); }); }); describe("Release metadata contract", () => { it("keeps every public version surface aligned with package.json", () => { const root = join(import.meta.dir, "..", ".."); const packageMetadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const marketplaceMetadata = JSON.parse( readFileSync(join(root, ".claude-plugin", "marketplace.json"), "utf8"), ); expect(marketplaceMetadata.plugins).toHaveLength(1); expect(PACKAGE_VERSION).toBe(packageMetadata.version); expect(marketplaceMetadata.plugins[0].version).toBe(packageMetadata.version); for (const file of ["cli.ts", "mcp-server.ts", "api-server.ts"]) { const source = readFileSync(join(root, "src", file), "utf8"); expect(source).toContain("PACKAGE_VERSION"); expect(source).not.toMatch(/version:\s*["'](?:1\.0\.0|1\.4\.0)["']/); } }); it("ships a self-contained Claude Code plugin without project MCP overrides", () => { const root = join(import.meta.dir, "..", ".."); const marketplaceMetadata = JSON.parse( readFileSync(join(root, ".claude-plugin", "marketplace.json"), "utf8"), ); const plugin = marketplaceMetadata.plugins[0]; const projectMcp = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); expect(plugin.strict).toBe(false); expect(plugin.skills).toContain("./.claude-plugin/recallnest-skill"); expect(plugin.mcpServers.recallnest.args).toContain( "${CLAUDE_PLUGIN_ROOT}/scripts/start-server.sh", ); expect(plugin.mcpServers.recallnest.env.LOCAL_MEMORY_CONFIG).toBe( "${CLAUDE_PLUGIN_DATA}/config.json", ); expect(plugin.mcpServers.recallnest.env.RECALLNEST_MCP_TIER).toBe("full"); expect(plugin.userConfig.jina_api_key).toMatchObject({ type: "string", sensitive: true, required: true, }); expect(projectMcp.mcpServers).toEqual({}); }); });