refactor: consolidate agent/editor plugins under integrations/ (#5491)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractConversation } from "../src/capture/index.ts";
|
||||
|
||||
describe("extractConversation", () => {
|
||||
it("extracts user and assistant text messages", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
];
|
||||
const result = extractConversation(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ role: "user", content: "Hello" });
|
||||
expect(result[1]).toEqual({ role: "assistant", content: "Hi there!" });
|
||||
});
|
||||
|
||||
it("skips assistant tool_use blocks, keeps text blocks", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Search" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "x", name: "mem0" }] },
|
||||
{ role: "tool", content: "results" },
|
||||
{ role: "assistant", content: "Here are the results" },
|
||||
];
|
||||
const result = extractConversation(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ role: "user", content: "Search" });
|
||||
expect(result[1]).toEqual({ role: "assistant", content: "Here are the results" });
|
||||
});
|
||||
|
||||
it("extracts text from content arrays for both roles", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello world" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Response" }, { type: "tool_use", id: "x" }] },
|
||||
];
|
||||
const result = extractConversation(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].content).toBe("Hello world");
|
||||
expect(result[1].content).toBe("Response");
|
||||
});
|
||||
|
||||
it("skips tool and system messages", () => {
|
||||
const messages = [
|
||||
{ role: "system", content: "You are helpful" },
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "tool", content: "tool output" },
|
||||
];
|
||||
const result = extractConversation(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ role: "user", content: "Hi" });
|
||||
});
|
||||
|
||||
it("skips assistant messages with only tool_use (no text)", () => {
|
||||
const messages = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "x", name: "bash" }] },
|
||||
];
|
||||
const result = extractConversation(messages);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(extractConversation([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import { loadConfig } from "../src/config/index.ts";
|
||||
|
||||
vi.mock("node:fs");
|
||||
|
||||
describe("loadConfig", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, HOME: "/home/testuser" };
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reads config from env vars when no config file exists", () => {
|
||||
process.env.MEM0_API_KEY = "m0-test-key";
|
||||
process.env.MEM0_USER_ID = "env-user";
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe("m0-test-key");
|
||||
expect(config.userId).toBe("env-user");
|
||||
expect(config.autoCapture).toBe(true);
|
||||
expect(config.defaultScope).toBe("project");
|
||||
});
|
||||
|
||||
it("returns empty apiKey when no key found anywhere", () => {
|
||||
delete process.env.MEM0_API_KEY;
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe("");
|
||||
});
|
||||
|
||||
it("reads config file and merges with defaults", () => {
|
||||
delete process.env.MEM0_API_KEY;
|
||||
delete process.env.MEM0_USER_ID;
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ apiKey: "m0-file-key", userId: "file-user" })
|
||||
);
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe("m0-file-key");
|
||||
expect(config.userId).toBe("file-user");
|
||||
expect(config.dream.enabled).toBe(true);
|
||||
expect(config.dream.minHours).toBe(24);
|
||||
});
|
||||
|
||||
it("env vars override config file", () => {
|
||||
process.env.MEM0_API_KEY = "m0-env-key";
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ apiKey: "m0-file-key" })
|
||||
);
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe("m0-env-key");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import { checkCheapGates, checkMemoryGate } from "../src/dream/index.ts";
|
||||
|
||||
vi.mock("node:fs");
|
||||
|
||||
const STATE_DIR = "/tmp/test-mem0-dream";
|
||||
|
||||
describe("checkCheapGates", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blocks when no state file exists (zero sessions)", () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error("ENOENT"); });
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("sessions");
|
||||
});
|
||||
|
||||
it("blocks when consolidated recently", () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ lastConsolidatedAt: Date.now() - 3_600_000, sessionsSince: 10, lastSessionId: null })
|
||||
);
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("time");
|
||||
});
|
||||
|
||||
it("blocks when not enough sessions", () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ lastConsolidatedAt: Date.now() - 48 * 3_600_000, sessionsSince: 2, lastSessionId: null })
|
||||
);
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("sessions");
|
||||
});
|
||||
|
||||
it("proceeds when both gates pass", () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ lastConsolidatedAt: Date.now() - 48 * 3_600_000, sessionsSince: 10, lastSessionId: null })
|
||||
);
|
||||
expect(checkCheapGates(STATE_DIR, {}).proceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMemoryGate", () => {
|
||||
it("blocks when too few memories", () => {
|
||||
expect(checkMemoryGate(5, {}).pass).toBe(false);
|
||||
});
|
||||
|
||||
it("passes when enough memories", () => {
|
||||
expect(checkMemoryGate(25, {}).pass).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatAge, formatMemoryCompact, formatMemoryList } from "../src/memory/formatting.ts";
|
||||
|
||||
describe("formatAge", () => {
|
||||
it("formats minutes", () => {
|
||||
expect(formatAge(new Date(Date.now() - 30 * 60_000))).toBe("30m ago");
|
||||
});
|
||||
|
||||
it("formats hours", () => {
|
||||
expect(formatAge(new Date(Date.now() - 3 * 3_600_000))).toBe("3h ago");
|
||||
});
|
||||
|
||||
it("formats days", () => {
|
||||
expect(formatAge(new Date(Date.now() - 5 * 86_400_000))).toBe("5d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryCompact", () => {
|
||||
it("formats a memory as a single line", () => {
|
||||
const mem = {
|
||||
id: "abc-123-def-456",
|
||||
memory: "User prefers dark mode",
|
||||
categories: ["preference"],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const line = formatMemoryCompact(mem);
|
||||
expect(line).toContain("[preference]");
|
||||
expect(line).toContain("User prefers dark mode");
|
||||
expect(line).toContain("[mem0:abc-123-def-456]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryList", () => {
|
||||
it("formats multiple memories with numbering", () => {
|
||||
const memories = [
|
||||
{ id: "id-1", memory: "Fact one", categories: ["insight"], createdAt: new Date() },
|
||||
{ id: "id-2", memory: "Fact two", categories: ["convention"], createdAt: new Date() },
|
||||
];
|
||||
const output = formatMemoryList(memories);
|
||||
expect(output).toContain("1.");
|
||||
expect(output).toContain("2.");
|
||||
});
|
||||
|
||||
it("returns empty message for no memories", () => {
|
||||
expect(formatMemoryList([])).toBe("No memories found.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveAddParams, resolveSearchFilters } from "../src/memory/scoping.ts";
|
||||
import type { ScopeContext } from "../src/types.ts";
|
||||
|
||||
const ctx: ScopeContext = {
|
||||
userId: "kartik",
|
||||
appId: "my-project",
|
||||
runId: "session-abc123",
|
||||
};
|
||||
|
||||
describe("resolveSearchFilters", () => {
|
||||
it("project scope returns user_id + app_id", () => {
|
||||
expect(resolveSearchFilters("project", ctx)).toEqual({
|
||||
user_id: "kartik", app_id: "my-project",
|
||||
});
|
||||
});
|
||||
|
||||
it("session scope returns user_id + app_id + run_id", () => {
|
||||
expect(resolveSearchFilters("session", ctx)).toEqual({
|
||||
user_id: "kartik", app_id: "my-project", run_id: "session-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("global scope returns user_id with app_id wildcard", () => {
|
||||
expect(resolveSearchFilters("global", ctx)).toEqual({
|
||||
user_id: "kartik", app_id: "*",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAddParams", () => {
|
||||
it("project scope returns userId + appId (camelCase)", () => {
|
||||
expect(resolveAddParams("project", ctx)).toEqual({
|
||||
userId: "kartik", appId: "my-project",
|
||||
});
|
||||
});
|
||||
|
||||
it("session scope includes runId", () => {
|
||||
expect(resolveAddParams("session", ctx)).toEqual({
|
||||
userId: "kartik", appId: "my-project", runId: "session-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("global scope returns userId only", () => {
|
||||
expect(resolveAddParams("global", ctx)).toEqual({ userId: "kartik" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
readFileSync: vi.fn().mockReturnValue("{}"),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
captureEvent,
|
||||
captureToolEvent,
|
||||
captureCommandEvent,
|
||||
_getEventQueue,
|
||||
_resetForTesting,
|
||||
} from "../src/telemetry.ts";
|
||||
|
||||
describe("telemetry", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
_resetForTesting();
|
||||
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
});
|
||||
|
||||
it("queues an event with correct name and standard properties", () => {
|
||||
captureEvent("pi.test.event", { custom: 42 });
|
||||
const queue = _getEventQueue();
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].event).toBe("pi.test.event");
|
||||
const props = queue[0].properties as Record<string, unknown>;
|
||||
expect(props.source).toBe("PI_AGENT_PLUGIN");
|
||||
expect(props.language).toBe("node");
|
||||
expect(props.custom).toBe(42);
|
||||
expect(props.$process_person_profile).toBe(false);
|
||||
});
|
||||
|
||||
it("uses SHA-256 of apiKey as distinct_id when provided", () => {
|
||||
captureEvent("test", {}, { apiKey: "m0-testkey" });
|
||||
const queue = _getEventQueue();
|
||||
expect(queue[0].distinct_id).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(queue[0].distinct_id).not.toBe("m0-testkey");
|
||||
});
|
||||
|
||||
it("generates a persistent anonymous id when no apiKey", () => {
|
||||
captureEvent("test", {}, {});
|
||||
const queue = _getEventQueue();
|
||||
expect(queue[0].distinct_id).toMatch(/^pi-mem0-anon-/);
|
||||
});
|
||||
|
||||
it("does not queue events when MEM0_TELEMETRY=false", () => {
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
captureEvent("should.not.appear");
|
||||
expect(_getEventQueue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not queue events when MEM0_TELEMETRY=0", () => {
|
||||
process.env.MEM0_TELEMETRY = "0";
|
||||
captureEvent("should.not.appear");
|
||||
expect(_getEventQueue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not queue events when MEM0_TELEMETRY=off", () => {
|
||||
process.env.MEM0_TELEMETRY = "off";
|
||||
captureEvent("should.not.appear");
|
||||
expect(_getEventQueue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-enables telemetry when env var is cleared between calls", () => {
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
captureEvent("blocked");
|
||||
expect(_getEventQueue()).toHaveLength(0);
|
||||
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
captureEvent("allowed");
|
||||
expect(_getEventQueue()).toHaveLength(1);
|
||||
expect(_getEventQueue()[0].event).toBe("allowed");
|
||||
});
|
||||
|
||||
it("flushes via fetch when queue reaches threshold", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
captureEvent(`event_${i}`);
|
||||
}
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(_getEventQueue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("captureToolEvent uses pi.tool.mem0_memory event name with action property", () => {
|
||||
captureToolEvent("search", { success: true, latency_ms: 42 });
|
||||
const queue = _getEventQueue();
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].event).toBe("pi.tool.mem0_memory");
|
||||
const props = queue[0].properties as Record<string, unknown>;
|
||||
expect(props.action).toBe("search");
|
||||
expect(props.success).toBe(true);
|
||||
expect(props.latency_ms).toBe(42);
|
||||
});
|
||||
|
||||
it("captureCommandEvent uses pi.command.<name> event name", () => {
|
||||
captureCommandEvent("mem0-search", { result_count: 5 });
|
||||
const queue = _getEventQueue();
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].event).toBe("pi.command.mem0-search");
|
||||
const props = queue[0].properties as Record<string, unknown>;
|
||||
expect(props.result_count).toBe(5);
|
||||
});
|
||||
|
||||
it("never throws even if fetch throws", () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("network down"));
|
||||
expect(() => {
|
||||
for (let i = 0; i < 10; i++) captureEvent(`event_${i}`);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { buildToolExecute } from "../src/memory/tools.ts";
|
||||
import type { ScopeContext } from "../src/types.ts";
|
||||
|
||||
const mockMem0 = {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteAll: vi.fn(),
|
||||
};
|
||||
|
||||
const scopeCtx: ScopeContext = {
|
||||
userId: "testuser",
|
||||
appId: "testproject",
|
||||
runId: "session123",
|
||||
};
|
||||
|
||||
describe("buildToolExecute", () => {
|
||||
const execute = buildToolExecute(mockMem0 as any, scopeCtx, "project");
|
||||
|
||||
it("search calls mem0.search with correct filters", async () => {
|
||||
mockMem0.search.mockResolvedValue({ results: [] });
|
||||
await execute({ action: "search", query: "dark mode" });
|
||||
expect(mockMem0.search).toHaveBeenCalledWith("dark mode", {
|
||||
filters: { user_id: "testuser", app_id: "testproject" },
|
||||
});
|
||||
});
|
||||
|
||||
it("add calls mem0.add with customCategories and entity params", async () => {
|
||||
mockMem0.add.mockResolvedValue([{ id: "new-id", memory: "test" }]);
|
||||
await execute({ action: "add", content: "User likes tabs" });
|
||||
const call = mockMem0.add.mock.calls[0];
|
||||
expect(call[0]).toEqual([{ role: "user", content: "User likes tabs" }]);
|
||||
expect(call[1].userId).toBe("testuser");
|
||||
expect(call[1].appId).toBe("testproject");
|
||||
expect(call[1].customCategories).toBeDefined();
|
||||
expect(call[1].customCategories.length).toBe(10);
|
||||
});
|
||||
|
||||
it("search with scope=global filters by user_id with app_id wildcard", async () => {
|
||||
mockMem0.search.mockResolvedValue({ results: [] });
|
||||
await execute({ action: "search", query: "preferences", scope: "global" });
|
||||
expect(mockMem0.search).toHaveBeenCalledWith("preferences", {
|
||||
filters: { user_id: "testuser", app_id: "*" },
|
||||
});
|
||||
});
|
||||
|
||||
it("delete calls mem0.delete with full memory_id", async () => {
|
||||
mockMem0.delete.mockResolvedValue({ message: "deleted" });
|
||||
await execute({ action: "delete", memory_id: "abc12345-6789-0abc-def0-123456789abc" });
|
||||
expect(mockMem0.delete).toHaveBeenCalledWith("abc12345-6789-0abc-def0-123456789abc");
|
||||
});
|
||||
|
||||
it("delete passes memory_id directly to mem0.delete", async () => {
|
||||
const fullId = "956e3d68-b420-4e07-a4e3-3019e7cebe6f";
|
||||
mockMem0.delete.mockResolvedValue({ message: "deleted" });
|
||||
await execute({ action: "delete", memory_id: fullId });
|
||||
expect(mockMem0.delete).toHaveBeenCalledWith(fullId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user