chore: merge origin/main into mintlify/af82bda4 and resolve conflicts
This commit is contained in:
@@ -29,11 +29,6 @@ vi.mock("../fs-safe.ts", () => ({
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../skill-loader.ts", () => ({
|
||||
loadDreamPrompt: vi.fn().mockReturnValue("dream prompt"),
|
||||
}));
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports (after mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,7 +41,6 @@ import {
|
||||
enableSkillsConfig,
|
||||
getBaseUrl,
|
||||
} from "../cli/config-file.ts";
|
||||
import { loadDreamPrompt } from "../skill-loader.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock Commander program builder
|
||||
@@ -263,8 +257,6 @@ describe("registerCliCommands", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
|
||||
(writePluginAuth as ReturnType<typeof vi.fn>).mockImplementation(() => {});
|
||||
(getBaseUrl as ReturnType<typeof vi.fn>).mockReturnValue("https://api.mem0.ai");
|
||||
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValue("dream prompt");
|
||||
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
@@ -311,7 +303,6 @@ describe("registerCliCommands", () => {
|
||||
expect(names).toContain("delete");
|
||||
expect(names).toContain("status");
|
||||
expect(names).toContain("config");
|
||||
expect(names).toContain("dream");
|
||||
});
|
||||
|
||||
it("registers config subcommands: show, get, set", () => {
|
||||
@@ -1037,6 +1028,19 @@ describe("registerCliCommands", () => {
|
||||
// ========================================================================
|
||||
|
||||
describe("status subcommand", () => {
|
||||
it("reports setup instructions when Mem0 is not configured", async () => {
|
||||
const { mem0, backend, cfg } = setup();
|
||||
(cfg as any).needsSetup = true;
|
||||
const statusCmd = findCommand(mem0, "status")!;
|
||||
|
||||
await statusCmd._action!();
|
||||
|
||||
expect(backend.status).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("openclaw mem0 init"),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls backend.status and prints connection info", async () => {
|
||||
const { mem0, backend } = setup();
|
||||
const statusCmd = findCommand(mem0, "status")!;
|
||||
@@ -1280,104 +1284,6 @@ describe("registerCliCommands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// dream subcommand
|
||||
// ========================================================================
|
||||
|
||||
describe("dream subcommand", () => {
|
||||
it("fetches memories and outputs dream prompt to stdout", async () => {
|
||||
const { mem0, provider } = setup();
|
||||
provider.getAll.mockResolvedValueOnce([
|
||||
{
|
||||
id: "m1",
|
||||
memory: "User is an engineer",
|
||||
categories: ["identity"],
|
||||
metadata: { category: "identity", importance: 0.9 },
|
||||
created_at: "2026-01-01",
|
||||
},
|
||||
]);
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const dreamCmd = findCommand(mem0, "dream")!;
|
||||
|
||||
await dreamCmd._action!({});
|
||||
|
||||
expect(provider.getAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: "testuser",
|
||||
source: "OPENCLAW",
|
||||
}),
|
||||
);
|
||||
expect(loadDreamPrompt).toHaveBeenCalled();
|
||||
|
||||
// stdout should contain the dream prompt
|
||||
const stdoutOutput = stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
||||
expect(stdoutOutput).toContain("<dream-protocol>");
|
||||
expect(stdoutOutput).toContain("dream prompt");
|
||||
expect(stdoutOutput).toContain("<all-memories");
|
||||
expect(stdoutOutput).toContain("User is an engineer");
|
||||
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("prints dry-run message and does not output dream prompt", async () => {
|
||||
const { mem0, provider } = setup();
|
||||
provider.getAll.mockResolvedValueOnce([
|
||||
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
|
||||
]);
|
||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const dreamCmd = findCommand(mem0, "dream")!;
|
||||
|
||||
await dreamCmd._action!({ dryRun: true });
|
||||
|
||||
// Dry run should write inventory to stderr, NOT dream prompt to stdout
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run"),
|
||||
);
|
||||
expect(stdoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("prints message when no memories to consolidate", async () => {
|
||||
const { mem0, provider } = setup();
|
||||
provider.getAll.mockResolvedValueOnce([]);
|
||||
const dreamCmd = findCommand(mem0, "dream")!;
|
||||
|
||||
await dreamCmd._action!({});
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
"No memories to consolidate.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints error when dream skill file is not found", async () => {
|
||||
const { mem0, provider } = setup();
|
||||
provider.getAll.mockResolvedValueOnce([
|
||||
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
|
||||
]);
|
||||
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValueOnce("");
|
||||
const dreamCmd = findCommand(mem0, "dream")!;
|
||||
|
||||
await dreamCmd._action!({});
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dream skill file not found"),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles dream errors gracefully", async () => {
|
||||
const { mem0, provider } = setup();
|
||||
provider.getAll.mockRejectedValueOnce(new Error("dream boom"));
|
||||
const dreamCmd = findCommand(mem0, "dream")!;
|
||||
|
||||
await dreamCmd._action!({});
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dream failed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// import subcommand
|
||||
// ========================================================================
|
||||
@@ -1647,7 +1553,7 @@ describe("registerCliCommands", () => {
|
||||
// ========================================================================
|
||||
|
||||
describe("--json flag registration", () => {
|
||||
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import", "dream"]) {
|
||||
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import"]) {
|
||||
it(`registers --json on ${name}`, () => {
|
||||
const { mem0 } = setup();
|
||||
const cmd = findCommand(mem0, name)!;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Tests for config.ts — mem0ConfigSchema.parse() and exported constants.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mem0ConfigSchema,
|
||||
@@ -8,6 +10,14 @@ import {
|
||||
DEFAULT_CUSTOM_CATEGORIES,
|
||||
} from "../config.ts";
|
||||
|
||||
describe("plugin manifest", () => {
|
||||
it("matches the package version", () => {
|
||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
|
||||
expect(manifest.version).toBe(packageJson.version);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -379,13 +389,6 @@ describe("mem0ConfigSchema.parse() — skills config", () => {
|
||||
tokenBudget: 2000,
|
||||
maxMemories: 10,
|
||||
},
|
||||
dream: {
|
||||
enabled: true,
|
||||
auto: true,
|
||||
minHours: 12,
|
||||
minSessions: 3,
|
||||
minMemories: 15,
|
||||
},
|
||||
domain: "engineering",
|
||||
customRules: {
|
||||
include: ["tool configs"],
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
|
||||
* for automatic memory consolidation.
|
||||
*
|
||||
* All filesystem operations are mocked via fs-safe.ts.
|
||||
* Time-dependent tests use vi.useFakeTimers().
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../fs-safe.ts", () => ({
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
checkCheapGates,
|
||||
checkMemoryGate,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
getDreamState,
|
||||
} from "../dream-gate.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
|
||||
|
||||
const STATE_DIR = "/tmp/test-state";
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number;
|
||||
sessionsSince: number;
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
function setDreamState(state: DreamState): void {
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream-state.json")) {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function setNoState(): void {
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function getWrittenState(): DreamState {
|
||||
const call = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
if (!call) throw new Error("No state file written");
|
||||
return JSON.parse(call[1] as string);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockMkdirp.mockReturnValue(undefined);
|
||||
mockUnlink.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// incrementSessionCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("incrementSessionCount", () => {
|
||||
it("increments counter for a new session", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-old",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-new");
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.sessionsSince).toBe(4);
|
||||
expect(written.lastSessionId).toBe("session-new");
|
||||
});
|
||||
|
||||
it("deduplicates same session (no increment)", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-same",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-same");
|
||||
|
||||
// writeText should NOT have been called for the state file
|
||||
const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
expect(stateWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkCheapGates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCheapGates", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails time gate when consolidation was too recent", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 1 hour ago, but minHours is 24
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 1 * 3_600_000,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("time");
|
||||
});
|
||||
|
||||
it("fails session gate when too few sessions", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 48 hours ago (passes time gate), but only 2 sessions
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 2,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("sessions");
|
||||
});
|
||||
|
||||
it("passes both gates when conditions are met", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// 48 hours ago, 10 sessions — both gates pass
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 10,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses defaults when config is empty", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkMemoryGate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkMemoryGate", () => {
|
||||
it("fails when too few memories", () => {
|
||||
const result = checkMemoryGate(5, { minMemories: 20 });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.reason).toContain("memories");
|
||||
expect(result.reason).toContain("5");
|
||||
});
|
||||
|
||||
it("passes when enough memories", () => {
|
||||
const result = checkMemoryGate(25, { minMemories: 20 });
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acquireDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("acquireDreamLock", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("succeeds when no lock exists", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// readText throws for lock file (not found), writeText succeeds for wx create
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify it wrote a lock file with wx flag
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
const lockData = JSON.parse(lockWrite![1] as string);
|
||||
expect(lockData.pid).toBe(process.pid);
|
||||
expect(lockData.startedAt).toBe(now);
|
||||
expect(lockWrite![2]).toEqual({ flag: "wx" });
|
||||
});
|
||||
|
||||
it("fails when lock exists and is fresh", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 10 minutes ago — still fresh (< 1 hour)
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 12345,
|
||||
startedAt: now - 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Should NOT have written a new lock
|
||||
const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("succeeds when lock is stale (>1hr old)", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 2 hours ago — stale
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 99999,
|
||||
startedAt: now - 2 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Should have unlinked the stale lock
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
|
||||
// Should have written a new lock
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// releaseDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("releaseDreamLock", () => {
|
||||
it("removes lock file", () => {
|
||||
releaseDreamLock(STATE_DIR);
|
||||
expect(mockUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("dream.lock"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordDreamCompletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("recordDreamCompletion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resets session counter and records timestamp", () => {
|
||||
const now = 1700000000000;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 15,
|
||||
lastSessionId: "session-xyz",
|
||||
});
|
||||
|
||||
recordDreamCompletion(STATE_DIR);
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.lastConsolidatedAt).toBe(now);
|
||||
expect(written.sessionsSince).toBe(0);
|
||||
expect(written.lastSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDreamState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getDreamState", () => {
|
||||
it("returns default state when no file exists", () => {
|
||||
setNoState();
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 0,
|
||||
lastSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns persisted state when file exists", () => {
|
||||
const persisted = {
|
||||
lastConsolidatedAt: 1700000000000,
|
||||
sessionsSince: 7,
|
||||
lastSessionId: "session-abc",
|
||||
};
|
||||
setDreamState(persisted);
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual(persisted);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user