feat: integrate Cortex local memory into OpenClaw

This commit is contained in:
Marc J Saint-jour 2026-03-12 18:41:28 -04:00
parent b3be74d1dd
commit cf98c3f209

406
src/memory/cortex.ts Normal file
View File

@ -0,0 +1,406 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runExec } from "../process/exec.js";
export type CortexPolicy = "full" | "professional" | "technical" | "minimal";
export type CortexStatus = {
available: boolean;
workspaceDir: string;
graphPath: string;
graphExists: boolean;
error?: string;
};
export type CortexPreview = {
workspaceDir: string;
graphPath: string;
policy: CortexPolicy;
maxChars: number;
context: string;
};
export type CortexMemoryConflict = {
id: string;
type: string;
severity: number;
summary: string;
nodeLabel?: string;
oldValue?: string;
newValue?: string;
};
export type CortexMemoryResolveAction = "accept-new" | "keep-old" | "merge" | "ignore";
export type CortexMemoryResolveResult = {
status: string;
conflictId: string;
action: CortexMemoryResolveAction;
nodesUpdated?: number;
nodesRemoved?: number;
commitId?: string;
message?: string;
};
export type CortexCodingSyncResult = {
workspaceDir: string;
graphPath: string;
policy: CortexPolicy;
platforms: string[];
};
export type CortexMemoryIngestResult = {
workspaceDir: string;
graphPath: string;
stored: boolean;
};
export type CortexMemoryEvent = {
actor: "user" | "assistant" | "tool";
text: string;
agentId?: string;
sessionId?: string;
channelId?: string;
provider?: string;
timestamp?: string;
};
const DEFAULT_GRAPH_RELATIVE_PATH = path.join(".cortex", "context.json");
const DEFAULT_POLICY: CortexPolicy = "technical";
const DEFAULT_MAX_CHARS = 1_500;
export const DEFAULT_CORTEX_CODING_PLATFORMS = ["claude-code", "cursor", "copilot"] as const;
function parseJson<T>(raw: string, label: string): T {
try {
return JSON.parse(raw) as T;
} catch (error) {
throw new Error(`Cortex ${label} returned invalid JSON`, { cause: error });
}
}
export function resolveCortexGraphPath(workspaceDir: string, graphPath?: string): string {
const trimmed = graphPath?.trim();
if (!trimmed) {
return path.join(workspaceDir, DEFAULT_GRAPH_RELATIVE_PATH);
}
if (path.isAbsolute(trimmed)) {
return path.normalize(trimmed);
}
return path.normalize(path.resolve(workspaceDir, trimmed));
}
async function pathExists(pathname: string): Promise<boolean> {
try {
await fs.access(pathname);
return true;
} catch {
return false;
}
}
function formatCortexExecError(error: unknown): string {
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : "unknown error";
const stderr =
typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
? error.stderr
: "";
const combined = stderr.trim() || message.trim();
return combined || "unknown error";
}
function asOptionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
export async function getCortexStatus(params: {
workspaceDir: string;
graphPath?: string;
}): Promise<CortexStatus> {
const graphPath = resolveCortexGraphPath(params.workspaceDir, params.graphPath);
const graphExists = await pathExists(graphPath);
try {
await runExec("cortex", ["context-export", "--help"], {
timeoutMs: 5_000,
cwd: params.workspaceDir,
maxBuffer: 512 * 1024,
});
return {
available: true,
workspaceDir: params.workspaceDir,
graphPath,
graphExists,
};
} catch (error) {
return {
available: false,
workspaceDir: params.workspaceDir,
graphPath,
graphExists,
error: formatCortexExecError(error),
};
}
}
export async function previewCortexContext(params: {
workspaceDir: string;
graphPath?: string;
policy?: CortexPolicy;
maxChars?: number;
}): Promise<CortexPreview> {
const status = await getCortexStatus({
workspaceDir: params.workspaceDir,
graphPath: params.graphPath,
});
if (!status.available) {
throw new Error(`Cortex CLI unavailable: ${status.error ?? "unknown error"}`);
}
if (!status.graphExists) {
throw new Error(`Cortex graph not found: ${status.graphPath}`);
}
const policy = params.policy ?? DEFAULT_POLICY;
const maxChars = params.maxChars ?? DEFAULT_MAX_CHARS;
try {
const { stdout } = await runExec(
"cortex",
["context-export", status.graphPath, "--policy", policy, "--max-chars", String(maxChars)],
{
timeoutMs: 10_000,
cwd: params.workspaceDir,
maxBuffer: 2 * 1024 * 1024,
},
);
return {
workspaceDir: params.workspaceDir,
graphPath: status.graphPath,
policy,
maxChars,
context: stdout.trim(),
};
} catch (error) {
throw new Error(`Cortex preview failed: ${formatCortexExecError(error)}`, { cause: error });
}
}
export async function listCortexMemoryConflicts(params: {
workspaceDir: string;
graphPath?: string;
minSeverity?: number;
}): Promise<CortexMemoryConflict[]> {
const status = await getCortexStatus({
workspaceDir: params.workspaceDir,
graphPath: params.graphPath,
});
if (!status.available) {
throw new Error(`Cortex CLI unavailable: ${status.error ?? "unknown error"}`);
}
if (!status.graphExists) {
throw new Error(`Cortex graph not found: ${status.graphPath}`);
}
const args = ["memory", "conflicts", status.graphPath, "--format", "json"];
if (typeof params.minSeverity === "number" && Number.isFinite(params.minSeverity)) {
args.push("--severity", String(params.minSeverity));
}
try {
const { stdout } = await runExec("cortex", args, {
timeoutMs: 10_000,
cwd: params.workspaceDir,
maxBuffer: 2 * 1024 * 1024,
});
const parsed = parseJson<{ conflicts?: Array<Record<string, unknown>> }>(stdout, "conflicts");
return (parsed.conflicts ?? []).map((entry) => ({
id: asString(entry.id),
type: asString(entry.type),
severity: asNumber(entry.severity),
summary: asString(entry.summary, asString(entry.description)),
nodeLabel: asOptionalString(entry.node_label),
oldValue: asOptionalString(entry.old_value),
newValue: asOptionalString(entry.new_value),
}));
} catch (error) {
throw new Error(`Cortex conflicts failed: ${formatCortexExecError(error)}`, { cause: error });
}
}
export async function resolveCortexMemoryConflict(params: {
workspaceDir: string;
graphPath?: string;
conflictId: string;
action: CortexMemoryResolveAction;
commitMessage?: string;
}): Promise<CortexMemoryResolveResult> {
const status = await getCortexStatus({
workspaceDir: params.workspaceDir,
graphPath: params.graphPath,
});
if (!status.available) {
throw new Error(`Cortex CLI unavailable: ${status.error ?? "unknown error"}`);
}
if (!status.graphExists) {
throw new Error(`Cortex graph not found: ${status.graphPath}`);
}
const args = [
"memory",
"resolve",
status.graphPath,
"--conflict-id",
params.conflictId,
"--action",
params.action,
"--format",
"json",
];
if (params.commitMessage?.trim()) {
args.push("--commit-message", params.commitMessage.trim());
}
try {
const { stdout } = await runExec("cortex", args, {
timeoutMs: 10_000,
cwd: params.workspaceDir,
maxBuffer: 2 * 1024 * 1024,
});
const parsed = parseJson<Record<string, unknown>>(stdout, "resolve");
return {
status: asString(parsed.status, "unknown"),
conflictId: asString(parsed.conflict_id, params.conflictId),
action: params.action,
nodesUpdated: typeof parsed.nodes_updated === "number" ? parsed.nodes_updated : undefined,
nodesRemoved: typeof parsed.nodes_removed === "number" ? parsed.nodes_removed : undefined,
commitId: asOptionalString(parsed.commit_id),
message: asOptionalString(parsed.message),
};
} catch (error) {
throw new Error(`Cortex resolve failed: ${formatCortexExecError(error)}`, { cause: error });
}
}
export async function syncCortexCodingContext(params: {
workspaceDir: string;
graphPath?: string;
policy?: CortexPolicy;
platforms?: string[];
}): Promise<CortexCodingSyncResult> {
const status = await getCortexStatus({
workspaceDir: params.workspaceDir,
graphPath: params.graphPath,
});
if (!status.available) {
throw new Error(`Cortex CLI unavailable: ${status.error ?? "unknown error"}`);
}
if (!status.graphExists) {
throw new Error(`Cortex graph not found: ${status.graphPath}`);
}
const policy = params.policy ?? DEFAULT_POLICY;
const requestedPlatforms = params.platforms?.map((entry) => entry.trim()).filter(Boolean) ?? [];
const platforms =
requestedPlatforms.length > 0 ? requestedPlatforms : [...DEFAULT_CORTEX_CODING_PLATFORMS];
try {
await runExec(
"cortex",
["context-write", status.graphPath, "--platforms", ...platforms, "--policy", policy],
{
timeoutMs: 15_000,
cwd: params.workspaceDir,
maxBuffer: 2 * 1024 * 1024,
},
);
return {
workspaceDir: params.workspaceDir,
graphPath: status.graphPath,
policy,
platforms,
};
} catch (error) {
throw new Error(`Cortex coding sync failed: ${formatCortexExecError(error)}`, {
cause: error,
});
}
}
function formatCortexMemoryEvent(event: CortexMemoryEvent): string {
const metadata = {
source: "openclaw",
actor: event.actor,
agentId: event.agentId,
sessionId: event.sessionId,
channelId: event.channelId,
provider: event.provider,
timestamp: event.timestamp ?? new Date().toISOString(),
};
return [
"Source: OpenClaw conversation",
`Actor: ${event.actor}`,
event.agentId ? `Agent: ${event.agentId}` : "",
event.sessionId ? `Session: ${event.sessionId}` : "",
event.channelId ? `Channel: ${event.channelId}` : "",
event.provider ? `Provider: ${event.provider}` : "",
`Timestamp: ${metadata.timestamp}`,
"",
"Metadata:",
JSON.stringify(metadata, null, 2),
"",
"Message:",
event.text.trim(),
]
.filter(Boolean)
.join("\n");
}
export async function ingestCortexMemoryFromText(params: {
workspaceDir: string;
graphPath?: string;
event: CortexMemoryEvent;
}): Promise<CortexMemoryIngestResult> {
const text = params.event.text.trim();
if (!text) {
throw new Error("Cortex memory ingest requires non-empty text");
}
const status = await getCortexStatus({
workspaceDir: params.workspaceDir,
graphPath: params.graphPath,
});
if (!status.available) {
throw new Error(`Cortex CLI unavailable: ${status.error ?? "unknown error"}`);
}
await fs.mkdir(path.dirname(status.graphPath), { recursive: true });
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-ingest-"));
const inputPath = path.join(tmpDir, "memory.txt");
const payload = formatCortexMemoryEvent(params.event);
try {
await fs.writeFile(inputPath, payload, "utf8");
await runExec(
"cortex",
["extract", inputPath, "-o", status.graphPath, "--merge", status.graphPath],
{
timeoutMs: 15_000,
cwd: params.workspaceDir,
maxBuffer: 2 * 1024 * 1024,
},
);
return {
workspaceDir: params.workspaceDir,
graphPath: status.graphPath,
stored: true,
};
} catch (error) {
throw new Error(`Cortex ingest failed: ${formatCortexExecError(error)}`, { cause: error });
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}