feat: integrate Cortex local memory into OpenClaw
This commit is contained in:
parent
0f03c4d256
commit
715e0e6fa8
577
src/agents/cortex.test.ts
Normal file
577
src/agents/cortex.test.ts
Normal file
@ -0,0 +1,577 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const {
|
||||
previewCortexContext,
|
||||
getCortexModeOverride,
|
||||
listCortexMemoryConflicts,
|
||||
ingestCortexMemoryFromText,
|
||||
syncCortexCodingContext,
|
||||
} = vi.hoisted(() => ({
|
||||
previewCortexContext: vi.fn(),
|
||||
getCortexModeOverride: vi.fn(),
|
||||
listCortexMemoryConflicts: vi.fn(),
|
||||
ingestCortexMemoryFromText: vi.fn(),
|
||||
syncCortexCodingContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../memory/cortex.js", () => ({
|
||||
previewCortexContext,
|
||||
listCortexMemoryConflicts,
|
||||
ingestCortexMemoryFromText,
|
||||
syncCortexCodingContext,
|
||||
}));
|
||||
|
||||
vi.mock("../memory/cortex-mode-overrides.js", () => ({
|
||||
getCortexModeOverride,
|
||||
}));
|
||||
|
||||
import {
|
||||
getAgentCortexMemoryCaptureStatus,
|
||||
ingestAgentCortexMemoryCandidate,
|
||||
resetAgentCortexConflictNoticeStateForTests,
|
||||
resolveAgentCortexConflictNotice,
|
||||
resolveAgentCortexConfig,
|
||||
resolveAgentCortexModeStatus,
|
||||
resolveAgentCortexPromptContext,
|
||||
resolveCortexChannelTarget,
|
||||
} from "./cortex.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAgentCortexConflictNoticeStateForTests();
|
||||
});
|
||||
|
||||
describe("resolveAgentCortexConfig", () => {
|
||||
it("returns null when Cortex prompt bridge is disabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAgentCortexConfig(cfg, "main")).toBeNull();
|
||||
});
|
||||
|
||||
it("merges defaults with per-agent overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
mode: "professional",
|
||||
maxChars: 1200,
|
||||
graphPath: ".cortex/default.json",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
cortex: {
|
||||
mode: "technical",
|
||||
maxChars: 3000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAgentCortexConfig(cfg, "main")).toEqual({
|
||||
enabled: true,
|
||||
graphPath: ".cortex/default.json",
|
||||
mode: "technical",
|
||||
maxChars: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps max chars to a bounded value", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
maxChars: 999999,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAgentCortexConfig(cfg, "main")?.maxChars).toBe(8000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentCortexPromptContext", () => {
|
||||
it("skips Cortex lookup in minimal prompt mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveAgentCortexPromptContext({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
promptMode: "minimal",
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(previewCortexContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns exported context when enabled", async () => {
|
||||
getCortexModeOverride.mockResolvedValueOnce(null);
|
||||
previewCortexContext.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
policy: "technical",
|
||||
maxChars: 1500,
|
||||
context: "## Cortex Context\n- Shipping",
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveAgentCortexPromptContext({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
promptMode: "full",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
context: "## Cortex Context\n- Shipping",
|
||||
});
|
||||
expect(previewCortexContext).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: undefined,
|
||||
policy: "technical",
|
||||
maxChars: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers stored session/channel mode overrides", async () => {
|
||||
getCortexModeOverride.mockResolvedValueOnce({
|
||||
agentId: "main",
|
||||
scope: "session",
|
||||
targetId: "session-1",
|
||||
mode: "minimal",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
previewCortexContext.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
policy: "minimal",
|
||||
maxChars: 1500,
|
||||
context: "## Cortex Context\n- Minimal",
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
mode: "technical",
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveAgentCortexPromptContext({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
promptMode: "full",
|
||||
sessionId: "session-1",
|
||||
channelId: "slack",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
context: "## Cortex Context\n- Minimal",
|
||||
});
|
||||
expect(getCortexModeOverride).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
channelId: "slack",
|
||||
});
|
||||
expect(previewCortexContext).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: undefined,
|
||||
policy: "minimal",
|
||||
maxChars: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an error without throwing when Cortex preview fails", async () => {
|
||||
getCortexModeOverride.mockResolvedValueOnce(null);
|
||||
previewCortexContext.mockRejectedValueOnce(new Error("Cortex graph not found"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveAgentCortexPromptContext({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
promptMode: "full",
|
||||
});
|
||||
|
||||
expect(result.error).toContain("Cortex graph not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentCortexConflictNotice", () => {
|
||||
it("returns a throttled high-severity conflict notice", async () => {
|
||||
listCortexMemoryConflicts.mockResolvedValueOnce([
|
||||
{
|
||||
id: "conf_1",
|
||||
type: "temporal_flip",
|
||||
severity: 0.91,
|
||||
summary: "Hiring status changed from active to paused",
|
||||
},
|
||||
]);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const notice = await resolveAgentCortexConflictNotice({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
now: 1_000,
|
||||
cooldownMs: 10_000,
|
||||
});
|
||||
|
||||
expect(notice?.conflictId).toBe("conf_1");
|
||||
expect(notice?.text).toContain("Cortex conflict detected");
|
||||
expect(notice?.text).toContain("/cortex resolve conf_1");
|
||||
|
||||
const second = await resolveAgentCortexConflictNotice({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
now: 5_000,
|
||||
cooldownMs: 10_000,
|
||||
});
|
||||
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when Cortex is disabled", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const notice = await resolveAgentCortexConflictNotice({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
expect(notice).toBeNull();
|
||||
expect(listCortexMemoryConflicts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestAgentCortexMemoryCandidate", () => {
|
||||
it("captures high-signal user text into Cortex", async () => {
|
||||
ingestCortexMemoryFromText.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
stored: true,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "I prefer concise answers and I am focused on fundraising this quarter.",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
|
||||
expect(result.captured).toBe(true);
|
||||
expect(result.reason).toBe("high-signal memory candidate");
|
||||
expect(ingestCortexMemoryFromText).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: undefined,
|
||||
event: {
|
||||
actor: "user",
|
||||
text: "I prefer concise answers and I am focused on fundraising this quarter.",
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
provider: undefined,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
getAgentCortexMemoryCaptureStatus({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
}),
|
||||
).toMatchObject({
|
||||
captured: true,
|
||||
reason: "high-signal memory candidate",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-syncs coding context for technical captures", async () => {
|
||||
ingestCortexMemoryFromText.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
stored: true,
|
||||
});
|
||||
syncCortexCodingContext.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
policy: "technical",
|
||||
platforms: ["cursor"],
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "I am debugging a Python backend API bug in this repo.",
|
||||
sessionId: "session-1",
|
||||
channelId: "channel-1",
|
||||
provider: "cursor",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
captured: true,
|
||||
syncedCodingContext: true,
|
||||
syncPlatforms: ["cursor"],
|
||||
});
|
||||
expect(syncCortexCodingContext).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: undefined,
|
||||
policy: "technical",
|
||||
platforms: ["cursor"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-sync generic technical chatter from messaging providers", async () => {
|
||||
ingestCortexMemoryFromText.mockResolvedValueOnce({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
stored: true,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "I am debugging a Python API bug right now.",
|
||||
sessionId: "session-1",
|
||||
channelId: "telegram:1",
|
||||
provider: "telegram",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
captured: true,
|
||||
syncedCodingContext: false,
|
||||
});
|
||||
expect(syncCortexCodingContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips low-signal text", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "ok",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
captured: false,
|
||||
reason: "low-signal short reply",
|
||||
});
|
||||
expect(ingestCortexMemoryFromText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the same graph path across channels for the same agent", async () => {
|
||||
ingestCortexMemoryFromText.mockResolvedValue({
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
graphPath: "/tmp/openclaw-workspace/.cortex/context.json",
|
||||
stored: true,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
graphPath: ".cortex/context.json",
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "I prefer concise answers for work updates.",
|
||||
sessionId: "session-1",
|
||||
channelId: "slack:C123",
|
||||
provider: "slack",
|
||||
});
|
||||
await ingestAgentCortexMemoryCandidate({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
commandBody: "I am focused on fundraising this quarter.",
|
||||
sessionId: "session-2",
|
||||
channelId: "telegram:456",
|
||||
provider: "telegram",
|
||||
});
|
||||
|
||||
expect(ingestCortexMemoryFromText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
graphPath: ".cortex/context.json",
|
||||
}),
|
||||
);
|
||||
expect(ingestCortexMemoryFromText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
graphPath: ".cortex/context.json",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentCortexModeStatus", () => {
|
||||
it("reports the active source for a session override", async () => {
|
||||
getCortexModeOverride.mockResolvedValueOnce({
|
||||
agentId: "main",
|
||||
scope: "session",
|
||||
targetId: "session-1",
|
||||
mode: "minimal",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cortex: {
|
||||
enabled: true,
|
||||
mode: "technical",
|
||||
maxChars: 1500,
|
||||
},
|
||||
},
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveAgentCortexModeStatus({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
channelId: "slack",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
mode: "minimal",
|
||||
source: "session-override",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCortexChannelTarget", () => {
|
||||
it("prefers concrete conversation ids before provider labels", () => {
|
||||
expect(
|
||||
resolveCortexChannelTarget({
|
||||
channel: "slack",
|
||||
channelId: "slack",
|
||||
nativeChannelId: "C123",
|
||||
}),
|
||||
).toBe("C123");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user