openclaw/extensions/memory-core/src/cli.test.ts

1000 lines
31 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
firstWrittenJsonArg,
spyRuntimeErrors,
spyRuntimeJson,
spyRuntimeLogs,
} from "../../../src/cli/test-runtime-capture.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
const resolveCommandSecretRefsViaGateway = vi.hoisted(() =>
vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
diagnostics: [] as string[],
})),
);
vi.mock("./cli.host.runtime.js", async () => {
const actual =
await vi.importActual<typeof import("./cli.host.runtime.js")>("./cli.host.runtime.js");
return {
...actual,
getMemorySearchManager,
loadConfig,
resolveCommandSecretRefsViaGateway,
resolveDefaultAgentId,
};
});
let registerMemoryCli: typeof import("./cli.js").registerMemoryCli;
let defaultRuntime: typeof import("openclaw/plugin-sdk/memory-core-host-runtime-cli").defaultRuntime;
let isVerbose: typeof import("openclaw/plugin-sdk/memory-core-host-runtime-cli").isVerbose;
let setVerbose: typeof import("openclaw/plugin-sdk/memory-core-host-runtime-cli").setVerbose;
beforeAll(async () => {
({ registerMemoryCli } = await import("./cli.js"));
({ defaultRuntime, isVerbose, setVerbose } =
await import("openclaw/plugin-sdk/memory-core-host-runtime-cli"));
});
beforeEach(() => {
getMemorySearchManager.mockReset();
loadConfig.mockReset().mockReturnValue({});
resolveDefaultAgentId.mockReset().mockReturnValue("main");
resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [] as string[],
}));
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
setVerbose(false);
});
describe("memory cli", () => {
const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret
function expectCliSync(sync: ReturnType<typeof vi.fn>) {
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
}
function makeMemoryStatus(overrides: Record<string, unknown> = {}) {
return {
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/openclaw",
dbPath: "/tmp/memory.sqlite",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
vector: { enabled: true, available: true },
...overrides,
};
}
function mockManager(manager: Record<string, unknown>) {
getMemorySearchManager.mockResolvedValueOnce({ manager });
}
function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType<typeof vi.fn>) {
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: [inactiveMemorySecretDiagnostic] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
}
function hasLoggedInactiveSecretDiagnostic(spy: ReturnType<typeof vi.spyOn>) {
return spy.mock.calls.some(
(call: unknown[]) =>
typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic),
);
}
async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Promise<T> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
return await task();
} catch (error) {
lastError = error;
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("Timed out waiting for async test condition");
}
async function runMemoryCli(args: string[]) {
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", ...args], { from: "user" });
}
function captureHelpOutput(command: Command | undefined) {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
chunk: string | Uint8Array,
) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write);
try {
command?.outputHelp();
return output;
} finally {
writeSpy.mockRestore();
}
}
function getMemoryHelpText() {
const program = new Command();
registerMemoryCli(program);
const memoryCommand = program.commands.find((command) => command.name() === "memory");
return captureHelpOutput(memoryCommand);
}
async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise<void>) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
const dbPath = path.join(tmpDir, "index.sqlite");
try {
await fs.writeFile(dbPath, content, "utf-8");
await run(dbPath);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-promote-"));
try {
await run(workspaceDir);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
}
async function writeDailyMemoryNote(
workspaceDir: string,
date: string,
lines: string[],
): Promise<void> {
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
await fs.mkdir(path.dirname(notePath), { recursive: true });
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
}
async function expectCloseFailureAfterCommand(params: {
args: string[];
manager: Record<string, unknown>;
beforeExpect?: () => void;
}) {
const close = vi.fn(async () => {
throw new Error("close boom");
});
mockManager({ ...params.manager, close });
const error = spyRuntimeErrors(defaultRuntime);
await runMemoryCli(params.args);
params.beforeExpect?.();
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
);
expect(process.exitCode).toBeUndefined();
}
it("prints vector status when available", async () => {
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () =>
makeMemoryStatus({
files: 2,
chunks: 5,
cache: { enabled: true, entries: 123, maxEntries: 50000 },
fts: { enabled: true, available: true },
vector: {
enabled: true,
available: true,
extensionPath: "/opt/sqlite-vec.dylib",
dims: 1024,
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("FTS: ready"));
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Embedding cache: enabled (123 entries)"),
);
expect(close).toHaveBeenCalled();
});
it("resolves configured memory SecretRefs through gateway snapshot", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
await runMemoryCli(["status"]);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
commandName: "memory status",
targetIds: new Set([
"agents.defaults.memorySearch.remote.apiKey",
"agents.list[].memorySearch.remote.apiKey",
]),
}),
);
});
it("logs gateway secret diagnostics for non-json status output", async () => {
const close = vi.fn(async () => {});
setupMemoryStatusWithInactiveSecretDiagnostics(close);
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true);
});
it("documents memory help examples", () => {
const helpText = getMemoryHelpText();
expect(helpText).toContain("openclaw memory status --fix");
expect(helpText).toContain("Repair stale recall locks and normalize promotion metadata.");
expect(helpText).toContain("openclaw memory status --deep");
expect(helpText).toContain("Probe embedding provider readiness.");
expect(helpText).toContain('openclaw memory search "meeting notes"');
expect(helpText).toContain("Quick search using positional query.");
expect(helpText).toContain('openclaw memory search --query "deployment" --max-results 20');
expect(helpText).toContain("Limit results for focused troubleshooting.");
expect(helpText).toContain("openclaw memory promote --apply");
expect(helpText).toContain("Append top-ranked short-term candidates into MEMORY.md.");
});
it("prints vector error when unavailable", async () => {
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => false),
status: () =>
makeMemoryStatus({
dirty: true,
vector: {
enabled: true,
available: false,
loadError: "load failed",
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--agent", "main"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector error: load failed"));
expect(close).toHaveBeenCalled();
});
it("prints embeddings status when deep", async () => {
const close = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
probeVectorAvailability: vi.fn(async () => true),
probeEmbeddingAvailability,
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--deep"]);
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Embeddings: ready"));
expect(close).toHaveBeenCalled();
});
it("prints recall-store audit details during status", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "router vlan",
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 3,
score: 0.93,
snippet: "Configured router VLAN 10 for IoT clients.",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Recall store: 1 entries"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Dreaming: 0 3 * * *"));
expect(close).toHaveBeenCalled();
});
});
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "QMD router cache note",
recallCount: 1,
totalScore: 0.8,
maxScore: 0.8,
firstRecalledAt: "2026-04-04T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a"],
},
bad: {
path: "",
},
},
},
null,
2,
),
"utf-8",
);
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
await fs.writeFile(lockPath, "999999:0\n", "utf-8");
const staleMtime = new Date(Date.now() - 120_000);
await fs.utimes(lockPath, staleMtime, staleMtime);
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--fix"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Repair: rewrote store"));
await expect(fs.stat(lockPath)).rejects.toThrow();
const repaired = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<string, { conceptTags?: string[] }>;
};
expect(repaired.entries.good?.conceptTags).toContain("router");
expect(close).toHaveBeenCalled();
});
});
it("shows the fix hint only before --fix has been run", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-fix-hint-"));
try {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, " \n", "utf-8");
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Fix: openclaw memory status --fix --agent main"),
);
log.mockClear();
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
await runMemoryCli(["status", "--fix"]);
expect(log).not.toHaveBeenCalledWith(
expect.stringContaining("Fix: openclaw memory status --fix --agent main"),
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("enables verbose logging with --verbose", async () => {
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
await runMemoryCli(["status", "--verbose"]);
expect(isVerbose()).toBe(true);
});
it("logs close failure after status", async () => {
await expectCloseFailureAfterCommand({
args: ["status"],
manager: {
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
},
});
});
it("reindexes on status --index", async () => {
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
probeVectorAvailability: vi.fn(async () => true),
probeEmbeddingAvailability,
sync,
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
close,
});
spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--index"]);
expectCliSync(sync);
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
it("closes manager after index", async () => {
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
mockManager({ sync, close });
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
});
it("logs qmd index file path and size after index", async () => {
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
await withQmdIndexDb("sqlite-bytes", async (dbPath) => {
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
expect(close).toHaveBeenCalled();
});
});
it("surfaces qmd audit details in status output", async () => {
const close = vi.fn(async () => {});
await withQmdIndexDb("sqlite-bytes", async (dbPath) => {
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () =>
makeMemoryStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
dbPath,
custom: {
qmd: {
collections: 2,
},
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD audit:"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("2 collections"));
expect(close).toHaveBeenCalled();
});
});
it("fails index when qmd db file is empty", async () => {
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
await withQmdIndexDb("", async (dbPath) => {
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
const error = spyRuntimeErrors(defaultRuntime);
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
);
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
});
it("logs close failures without failing the command", async () => {
const sync = vi.fn(async () => {});
await expectCloseFailureAfterCommand({
args: ["index"],
manager: { sync },
beforeExpect: () => {
expectCliSync(sync);
},
});
});
it("logs close failure after search", async () => {
const search = vi.fn(async () => [
{
path: "memory/2026-01-12.md",
startLine: 1,
endLine: 2,
score: 0.5,
snippet: "Hello",
},
]);
await expectCloseFailureAfterCommand({
args: ["search", "hello"],
manager: { search },
beforeExpect: () => {
expect(search).toHaveBeenCalled();
},
});
});
it("closes manager after search error", async () => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => {
throw new Error("boom");
});
mockManager({ search, close });
const error = spyRuntimeErrors(defaultRuntime);
await runMemoryCli(["search", "oops"]);
expect(search).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining("Memory search failed: boom"));
expect(process.exitCode).toBe(1);
});
it("prints status json output when requested", async () => {
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli(["status", "--json"]);
const payload = firstWrittenJsonArg<unknown[]>(writeJson);
expect(payload).not.toBeNull();
if (!payload) {
throw new Error("expected json payload");
}
expect(Array.isArray(payload)).toBe(true);
expect((payload[0] as Record<string, unknown>)?.agentId).toBe("main");
expect(close).toHaveBeenCalled();
});
it("routes gateway secret diagnostics to stderr for json status output", async () => {
const close = vi.fn(async () => {});
setupMemoryStatusWithInactiveSecretDiagnostics(close);
const writeJson = spyRuntimeJson(defaultRuntime);
const error = spyRuntimeErrors(defaultRuntime);
await runMemoryCli(["status", "--json"]);
const payload = firstWrittenJsonArg<unknown[]>(writeJson);
expect(payload).not.toBeNull();
if (!payload) {
throw new Error("expected json payload");
}
expect(Array.isArray(payload)).toBe(true);
expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true);
});
it("logs default message when memory manager is missing", async () => {
getMemorySearchManager.mockResolvedValueOnce({ manager: null });
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expect(log).toHaveBeenCalledWith("Memory search disabled.");
});
it("logs backend unsupported message when index has no sync", async () => {
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus(),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["index"]);
expect(log).toHaveBeenCalledWith("Memory backend does not support manual reindex.");
expect(close).toHaveBeenCalled();
});
it("prints no matches for empty search results", async () => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => []);
mockManager({ search, close });
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["search", "hello"]);
expect(search).toHaveBeenCalledWith("hello", {
maxResults: undefined,
minScore: undefined,
sessionKey: "agent:main:cli:direct:memory-search",
});
expect(log).toHaveBeenCalledWith("No matches.");
expect(close).toHaveBeenCalled();
});
it("accepts --query for memory search", async () => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => []);
mockManager({ search, close });
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["search", "--query", "deployment notes"]);
expect(search).toHaveBeenCalledWith("deployment notes", {
maxResults: undefined,
minScore: undefined,
sessionKey: "agent:main:cli:direct:memory-search",
});
expect(log).toHaveBeenCalledWith("No matches.");
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
});
it("prefers --query when positional and flag are both provided", async () => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => []);
mockManager({ search, close });
spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["search", "positional", "--query", "flagged"]);
expect(search).toHaveBeenCalledWith("flagged", {
maxResults: undefined,
minScore: undefined,
sessionKey: "agent:main:cli:direct:memory-search",
});
expect(close).toHaveBeenCalled();
});
it("fails when neither positional query nor --query is provided", async () => {
const error = spyRuntimeErrors(defaultRuntime);
await runMemoryCli(["search"]);
expect(error).toHaveBeenCalledWith(
"Missing search query. Provide a positional query or use --query <text>.",
);
expect(getMemorySearchManager).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("prints search results as json when requested", async () => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => [
{
path: "memory/2026-01-12.md",
startLine: 1,
endLine: 2,
score: 0.5,
snippet: "Hello",
},
]);
mockManager({ search, close });
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli(["search", "hello", "--json"]);
const payload = firstWrittenJsonArg<{ results: unknown[] }>(writeJson);
expect(payload).not.toBeNull();
if (!payload) {
throw new Error("expected json payload");
}
expect(Array.isArray(payload.results)).toBe(true);
expect(payload.results).toHaveLength(1);
expect(close).toHaveBeenCalled();
});
it("records short-term recall entries from memory search hits", async () => {
await withTempWorkspace(async (workspaceDir) => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.91,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
]);
mockManager({
search,
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
await runMemoryCli(["search", "glacier", "--json"]);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<string, { path: string; recallCount: number }>;
};
const entries = Object.values(store.entries ?? {});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
path: "memory/2026-04-03.md",
recallCount: 1,
});
expect(close).toHaveBeenCalled();
});
});
it("prints no candidates when promote has no short-term recall data", async () => {
await withTempWorkspace(async (workspaceDir) => {
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["promote"]);
expect(log).toHaveBeenCalledWith("No short-term recall candidates.");
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
});
});
it("prints promote candidates as json", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "router notes",
results: [
{
path: "memory/2026-04-03.md",
startLine: 4,
endLine: 8,
score: 0.86,
snippet: "Configured VLAN 10 for IoT on router",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli([
"promote",
"--json",
"--min-score",
"0",
"--min-recall-count",
"0",
"--min-unique-queries",
"0",
]);
const payload = firstWrittenJsonArg<{ candidates: unknown[] }>(writeJson);
expect(payload).not.toBeNull();
if (!payload) {
throw new Error("expected json payload");
}
expect(Array.isArray(payload.candidates)).toBe(true);
expect(payload.candidates).toHaveLength(1);
expect(close).toHaveBeenCalled();
});
});
it("applies top promote candidates into MEMORY.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
"line 6",
"line 7",
"line 8",
"line 9",
"Gateway host uses local mode and binds loopback port 18789",
"Keep agent gateway local",
"Expose healthcheck only on loopback",
"Monitor restart policy",
"Review proxy config",
]);
await recordShortTermRecalls({
workspaceDir,
query: "network setup",
results: [
{
path: "memory/2026-04-01.md",
startLine: 10,
endLine: 14,
score: 0.91,
snippet: "Gateway host uses local mode and binds loopback port 18789",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli([
"promote",
"--apply",
"--min-score",
"0",
"--min-recall-count",
"0",
"--min-unique-queries",
"0",
]);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
const memoryText = await fs.readFile(memoryPath, "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory");
expect(memoryText).toContain("memory/2026-04-01.md:10-10");
expect(log).toHaveBeenCalledWith(expect.stringContaining("Promoted 1 candidate(s) to"));
expect(close).toHaveBeenCalled();
});
});
it("prints conceptual promotion signals", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "router vlan",
nowMs: Date.parse("2026-04-01T00:00:00.000Z"),
results: [
{
path: "memory/2026-04-01.md",
startLine: 4,
endLine: 8,
score: 0.9,
snippet: "Configured router VLAN 10 and Glacier backup notes for QMD.",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir,
query: "glacier backup",
nowMs: Date.parse("2026-04-03T00:00:00.000Z"),
results: [
{
path: "memory/2026-04-01.md",
startLine: 4,
endLine: 8,
score: 0.88,
snippet: "Configured router VLAN 10 and Glacier backup notes for QMD.",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli([
"promote",
"--min-score",
"0",
"--min-recall-count",
"0",
"--min-unique-queries",
"0",
]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("consolidate="));
expect(log).toHaveBeenCalledWith(expect.stringContaining("concepts="));
expect(close).toHaveBeenCalled();
});
});
});