feat: integrate Cortex local memory into OpenClaw

This commit is contained in:
Marc J Saint-jour 2026-03-12 18:41:08 -04:00
parent c889cdfe5b
commit 0a8b5ba43c

View File

@ -0,0 +1,543 @@
import {
getAgentCortexMemoryCaptureStatusWithHistory,
resolveAgentCortexConfig,
resolveAgentCortexModeStatus,
resolveCortexChannelTarget,
} from "../../agents/cortex.js";
import { logVerbose } from "../../globals.js";
import {
clearCortexModeOverride,
getCortexModeOverride,
setCortexModeOverride,
type CortexModeScope,
} from "../../memory/cortex-mode-overrides.js";
import type { CortexMemoryResolveAction } from "../../memory/cortex.js";
import {
type CortexMemoryConflict,
listCortexMemoryConflicts,
previewCortexContext,
resolveCortexMemoryConflict,
syncCortexCodingContext,
type CortexPolicy,
} from "../../memory/cortex.js";
import type { ReplyPayload } from "../types.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
function parseCortexCommandArgs(commandBodyNormalized: string): string {
if (commandBodyNormalized === "/cortex") {
return "";
}
if (commandBodyNormalized.startsWith("/cortex ")) {
return commandBodyNormalized.slice(8).trim();
}
return "";
}
function parseMode(value?: string): CortexPolicy | null {
if (
value === "full" ||
value === "professional" ||
value === "technical" ||
value === "minimal"
) {
return value;
}
return null;
}
function parseResolveAction(value?: string): CortexMemoryResolveAction | null {
if (value === "accept-new" || value === "keep-old" || value === "merge" || value === "ignore") {
return value;
}
return null;
}
function resolveActiveSessionId(params: HandleCommandsParams): string | undefined {
return params.sessionEntry?.sessionId ?? params.ctx.SessionId;
}
function resolveActiveChannelId(params: HandleCommandsParams): string {
return resolveCortexChannelTarget({
channel: params.command.channel,
channelId: params.command.channelId,
originatingChannel: String(params.ctx.OriginatingChannel ?? ""),
originatingTo: params.ctx.OriginatingTo,
nativeChannelId: params.ctx.NativeChannelId,
to: params.command.to ?? params.ctx.To,
from: params.command.from ?? params.ctx.From,
});
}
function resolveScopeTarget(
params: HandleCommandsParams,
rawScope?: string,
): { scope: CortexModeScope; targetId: string } | { error: string } {
const requested = rawScope?.trim().toLowerCase();
if (!requested || requested === "here" || requested === "session") {
const sessionId = resolveActiveSessionId(params);
if (sessionId) {
return { scope: "session", targetId: sessionId };
}
if (!requested || requested === "here") {
return {
scope: "channel",
targetId: resolveActiveChannelId(params),
};
}
return { error: "No active session id is available for this conversation." };
}
if (requested === "channel") {
return {
scope: "channel",
targetId: resolveActiveChannelId(params),
};
}
return { error: "Use `/cortex mode set <mode> [here|session|channel]`." };
}
async function buildCortexHelpReply(): Promise<ReplyPayload> {
return {
text: [
"🧠 /cortex",
"",
"Manage Cortex prompt context for the active conversation.",
"",
"Try:",
"- /cortex preview",
"- /cortex why",
"- /cortex continuity",
"- /cortex conflicts",
"- /cortex conflict <conflictId>",
"- /cortex resolve <conflictId> <accept-new|keep-old|merge|ignore>",
"- /cortex sync coding",
"- /cortex mode show",
"- /cortex mode set minimal",
"- /cortex mode set professional channel",
"- /cortex mode reset",
"",
"Tip: after changing mode, run /status or /cortex preview to verify what will be used.",
].join("\n"),
};
}
function formatCortexConflictLines(conflict: CortexMemoryConflict, index?: number): string[] {
const prefix = typeof index === "number" ? `${index + 1}. ` : "";
return [
`${prefix}${conflict.id} · ${conflict.type} · severity ${conflict.severity.toFixed(2)}`,
conflict.summary,
conflict.nodeLabel ? `Node: ${conflict.nodeLabel}` : null,
conflict.oldValue ? `Old: ${conflict.oldValue}` : null,
conflict.newValue ? `New: ${conflict.newValue}` : null,
`Inspect: /cortex conflict ${conflict.id}`,
`Resolve newer: /cortex resolve ${conflict.id} accept-new`,
`Keep older: /cortex resolve ${conflict.id} keep-old`,
`Ignore: /cortex resolve ${conflict.id} ignore`,
].filter(Boolean) as string[];
}
async function resolveCortexConversationState(params: HandleCommandsParams) {
const agentId = params.agentId ?? "main";
const cortex = resolveAgentCortexConfig(params.cfg, agentId);
if (!cortex) {
return null;
}
const sessionId = resolveActiveSessionId(params);
const channelId = resolveActiveChannelId(params);
const modeStatus = await resolveAgentCortexModeStatus({
agentId,
cfg: params.cfg,
sessionId,
channelId,
});
const source =
modeStatus?.source === "session-override"
? "session override"
: modeStatus?.source === "channel-override"
? "channel override"
: "agent config";
return {
agentId,
cortex,
sessionId,
channelId,
mode: modeStatus?.mode ?? cortex.mode,
source,
};
}
async function buildCortexPreviewReply(params: HandleCommandsParams): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const preview = await previewCortexContext({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
policy: state.mode,
maxChars: state.cortex.maxChars,
});
if (!preview.context) {
return {
text: `No Cortex context available for mode ${state.mode}.`,
};
}
return {
text: [`Cortex preview (${state.mode}, ${state.source})`, "", preview.context].join("\n"),
};
}
async function buildCortexWhyReply(params: HandleCommandsParams): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const preview = await previewCortexContext({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
policy: state.mode,
maxChars: state.cortex.maxChars,
});
const previewBody = preview.context || "No Cortex context is currently being injected.";
const captureStatus = await getAgentCortexMemoryCaptureStatusWithHistory({
agentId: state.agentId,
sessionId: state.sessionId,
channelId: state.channelId,
});
return {
text: [
"Why I answered this way",
"",
`Mode: ${state.mode}`,
`Source: ${state.source}`,
`Graph: ${preview.graphPath}`,
state.sessionId ? `Session: ${state.sessionId}` : null,
state.channelId ? `Channel: ${state.channelId}` : null,
captureStatus
? `Last memory capture: ${captureStatus.captured ? "stored" : "skipped"} (${captureStatus.reason}, score ${captureStatus.score.toFixed(2)})`
: "Last memory capture: not evaluated yet",
captureStatus?.error ? `Capture error: ${captureStatus.error}` : null,
captureStatus?.syncedCodingContext
? `Coding sync: updated (${(captureStatus.syncPlatforms ?? []).join(", ")})`
: null,
"",
"Injected Cortex context:",
previewBody,
]
.filter(Boolean)
.join("\n"),
};
}
async function buildCortexContinuityReply(params: HandleCommandsParams): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
return {
text: [
"Cortex continuity",
"",
"This conversation is using the shared Cortex graph for the active agent.",
`Agent: ${state.agentId}`,
`Mode: ${state.mode} (${state.source})`,
`Graph: ${state.cortex.graphPath ?? ".cortex/context.json"}`,
state.sessionId ? `Session: ${state.sessionId}` : null,
state.channelId ? `Channel: ${state.channelId}` : null,
"",
"Messages from other channels on this agent reuse the same graph unless you override the graph path or mode there.",
"Try /cortex preview from another channel to verify continuity.",
]
.filter(Boolean)
.join("\n"),
};
}
async function buildCortexConflictsReply(params: HandleCommandsParams): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const conflicts = await listCortexMemoryConflicts({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
});
if (conflicts.length === 0) {
return {
text: "No Cortex memory conflicts.",
};
}
return {
text: [
`Cortex conflicts (${conflicts.length})`,
"",
...conflicts
.slice(0, 3)
.flatMap((conflict, index) => [...formatCortexConflictLines(conflict, index), ""]),
conflicts.length > 3 ? `…and ${conflicts.length - 3} more.` : null,
"",
"Use /cortex conflict <conflictId> for the full structured view.",
]
.filter(Boolean)
.join("\n"),
};
}
async function buildCortexConflictDetailReply(
params: HandleCommandsParams,
args: string,
): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const tokens = args.split(/\s+/).filter(Boolean);
const conflictId = tokens[1];
if (!conflictId) {
return {
text: "Usage: /cortex conflict <conflictId>",
};
}
const conflicts = await listCortexMemoryConflicts({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
});
const conflict = conflicts.find((entry) => entry.id === conflictId);
if (!conflict) {
return {
text: `Cortex conflict not found: ${conflictId}`,
};
}
return {
text: ["Cortex conflict detail", "", ...formatCortexConflictLines(conflict)].join("\n"),
};
}
async function buildCortexResolveReply(
params: HandleCommandsParams,
args: string,
): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const tokens = args.split(/\s+/).filter(Boolean);
const conflictId = tokens[1];
const action = parseResolveAction(tokens[2]);
if (!conflictId || !action) {
return {
text: "Usage: /cortex resolve <conflictId> <accept-new|keep-old|merge|ignore>",
};
}
const result = await resolveCortexMemoryConflict({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
conflictId,
action,
commitMessage: `openclaw cortex resolve ${conflictId} ${action}`,
});
return {
text: [
`Resolved Cortex conflict ${result.conflictId}.`,
`Action: ${result.action}`,
`Status: ${result.status}`,
typeof result.nodesUpdated === "number" ? `Nodes updated: ${result.nodesUpdated}` : null,
typeof result.nodesRemoved === "number" ? `Nodes removed: ${result.nodesRemoved}` : null,
result.commitId ? `Commit: ${result.commitId}` : null,
result.message ?? null,
"Use /cortex conflicts or /cortex preview to inspect the updated memory state.",
]
.filter(Boolean)
.join("\n"),
};
}
async function buildCortexSyncReply(
params: HandleCommandsParams,
args: string,
): Promise<ReplyPayload> {
const state = await resolveCortexConversationState(params);
if (!state) {
return {
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const tokens = args.split(/\s+/).filter(Boolean);
if (tokens[1]?.toLowerCase() !== "coding") {
return {
text: "Usage: /cortex sync coding [full|professional|technical|minimal] [platform ...]",
};
}
const requestedMode = parseMode(tokens[2]);
const policy = requestedMode ?? "technical";
const platformStartIndex = requestedMode ? 3 : 2;
const platforms = tokens.slice(platformStartIndex).filter(Boolean);
const result = await syncCortexCodingContext({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
policy,
platforms,
});
return {
text: [
"Synced Cortex coding context.",
`Mode: ${result.policy}`,
`Platforms: ${result.platforms.join(", ")}`,
`Graph: ${result.graphPath}`,
].join("\n"),
};
}
async function buildCortexModeReply(
params: HandleCommandsParams,
args: string,
): Promise<ReplyPayload> {
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[1]?.toLowerCase();
const agentId = params.agentId ?? "main";
if (!action || action === "help") {
return {
text: [
"Usage:",
"- /cortex mode show",
"- /cortex mode set <full|professional|technical|minimal> [here|session|channel]",
"- /cortex mode reset [here|session|channel]",
].join("\n"),
};
}
if (action === "show") {
const target = resolveScopeTarget(params, tokens[2]);
if ("error" in target) {
return { text: target.error };
}
const override = await getCortexModeOverride({
agentId,
sessionId: target.scope === "session" ? target.targetId : undefined,
channelId: target.scope === "channel" ? target.targetId : undefined,
});
if (!override) {
return {
text: `No Cortex mode override for this ${target.scope}.`,
};
}
return {
text: `Cortex mode for this ${target.scope}: ${override.mode}`,
};
}
if (action === "set") {
const mode = parseMode(tokens[2]);
if (!mode) {
return {
text: "Usage: /cortex mode set <full|professional|technical|minimal> [here|session|channel]",
};
}
const target = resolveScopeTarget(params, tokens[3]);
if ("error" in target) {
return { text: target.error };
}
await setCortexModeOverride({
agentId,
scope: target.scope,
targetId: target.targetId,
mode,
});
return {
text: [
`Set Cortex mode for this ${target.scope} to ${mode}.`,
"Use /status or /cortex preview to verify.",
].join("\n"),
};
}
if (action === "reset") {
const target = resolveScopeTarget(params, tokens[2]);
if ("error" in target) {
return { text: target.error };
}
const removed = await clearCortexModeOverride({
agentId,
scope: target.scope,
targetId: target.targetId,
});
return {
text: removed
? [
`Cleared Cortex mode override for this ${target.scope}.`,
"Use /status or /cortex preview to verify.",
].join("\n")
: `No Cortex mode override for this ${target.scope}.`,
};
}
return {
text: "Usage: /cortex preview | /cortex mode <show|set|reset> ...",
};
}
export const handleCortexCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/cortex" && !normalized.startsWith("/cortex ")) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /cortex from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
try {
const args = parseCortexCommandArgs(normalized);
const subcommand = args.split(/\s+/).filter(Boolean)[0]?.toLowerCase() ?? "";
const reply =
!subcommand || subcommand === "help"
? await buildCortexHelpReply()
: subcommand === "preview"
? await buildCortexPreviewReply(params)
: subcommand === "why"
? await buildCortexWhyReply(params)
: subcommand === "continuity"
? await buildCortexContinuityReply(params)
: subcommand === "conflicts"
? await buildCortexConflictsReply(params)
: subcommand === "conflict"
? await buildCortexConflictDetailReply(params, args)
: subcommand === "resolve"
? await buildCortexResolveReply(params, args)
: subcommand === "sync"
? await buildCortexSyncReply(params, args)
: subcommand === "mode"
? await buildCortexModeReply(params, args)
: {
text: "Usage: /cortex preview | /cortex why | /cortex continuity | /cortex conflicts | /cortex conflict <id> | /cortex resolve ... | /cortex sync coding ... | /cortex mode <show|set|reset> ...",
};
return {
shouldContinue: false,
reply,
};
} catch (error) {
return {
shouldContinue: false,
reply: {
text: error instanceof Error ? error.message : String(error),
},
};
}
};