feat: integrate Cortex local memory into OpenClaw

This commit is contained in:
Marc J Saint-jour 2026-03-12 18:41:01 -04:00
parent 715e0e6fa8
commit 99bd165459

551
src/agents/cortex.ts Normal file
View File

@ -0,0 +1,551 @@
import type { OpenClawConfig } from "../config/config.js";
import type { AgentCortexConfig } from "../config/types.agent-defaults.js";
import { getCortexModeOverride } from "../memory/cortex-mode-overrides.js";
import {
ingestCortexMemoryFromText,
listCortexMemoryConflicts,
previewCortexContext,
syncCortexCodingContext,
type CortexPolicy,
} from "../memory/cortex.js";
import { resolveAgentConfig } from "./agent-scope.js";
import {
appendCortexCaptureHistory,
getLatestCortexCaptureHistoryEntry,
} from "./cortex-history.js";
export type ResolvedAgentCortexConfig = {
enabled: true;
graphPath?: string;
mode: CortexPolicy;
maxChars: number;
};
export type AgentCortexPromptContextResult = {
context?: string;
error?: string;
};
export type ResolvedAgentCortexModeStatus = {
enabled: true;
mode: CortexPolicy;
source: "agent-config" | "session-override" | "channel-override";
graphPath?: string;
maxChars: number;
};
export type AgentCortexConflictNotice = {
text: string;
conflictId: string;
severity: number;
};
export type AgentCortexMemoryCaptureResult = {
captured: boolean;
score: number;
reason: string;
error?: string;
syncedCodingContext?: boolean;
syncPlatforms?: string[];
};
export type AgentCortexMemoryCaptureStatus = AgentCortexMemoryCaptureResult & {
updatedAt: number;
};
const DEFAULT_CORTEX_MODE: CortexPolicy = "technical";
const DEFAULT_CORTEX_MAX_CHARS = 1_500;
const MAX_CORTEX_MAX_CHARS = 8_000;
const DEFAULT_CORTEX_CONFLICT_SEVERITY = 0.75;
const DEFAULT_CORTEX_CONFLICT_COOLDOWN_MS = 30 * 60 * 1000;
const cortexConflictNoticeCooldowns = new Map<string, number>();
const cortexMemoryCaptureStatuses = new Map<string, AgentCortexMemoryCaptureStatus>();
const MIN_CORTEX_MEMORY_CONTENT_LENGTH = 24;
const DEFAULT_CORTEX_CODING_SYNC_COOLDOWN_MS = 10 * 60 * 1000;
const LOW_SIGNAL_PATTERNS = [
/^ok[.!]?$/i,
/^okay[.!]?$/i,
/^thanks?[.!]?$/i,
/^cool[.!]?$/i,
/^sounds good[.!]?$/i,
/^yes[.!]?$/i,
/^no[.!]?$/i,
/^lol[.!]?$/i,
/^haha[.!]?$/i,
/^test$/i,
];
const HIGH_SIGNAL_PATTERNS = [
/\bI prefer\b/i,
/\bmy preference\b/i,
/\bI am working on\b/i,
/\bIm working on\b/i,
/\bmy project\b/i,
/\bI use\b/i,
/\bI don't use\b/i,
/\bI do not use\b/i,
/\bI need\b/i,
/\bmy goal\b/i,
/\bmy priority\b/i,
/\bremember that\b/i,
/\bI like\b/i,
/\bI dislike\b/i,
/\bI am focused on\b/i,
/\bI'm focused on\b/i,
/\bIm focused on\b/i,
/\bI work with\b/i,
/\bI work on\b/i,
];
const TECHNICAL_SIGNAL_PATTERNS = [
/\bpython\b/i,
/\btypescript\b/i,
/\bjavascript\b/i,
/\brepo\b/i,
/\bbug\b/i,
/\bdebug\b/i,
/\bdeploy\b/i,
/\bpr\b/i,
/\bcursor\b/i,
/\bcopilot\b/i,
/\bclaude code\b/i,
/\bgemini\b/i,
/\bapi\b/i,
/\bbackend\b/i,
];
const STRONG_CODING_SYNC_PATTERNS = [
/\brepo\b/i,
/\bcodebase\b/i,
/\bpull request\b/i,
/\bpackage\.json\b/i,
/\btsconfig\b/i,
/\bpytest\b/i,
/\bclaude code\b/i,
/\bcursor\b/i,
/\bcopilot\b/i,
/\bgemini cli\b/i,
];
const CORTEX_CODING_PROVIDER_PLATFORM_MAP: Record<string, string[]> = {
"claude-code": ["claude-code"],
copilot: ["copilot"],
cursor: ["cursor"],
"gemini-cli": ["gemini-cli"],
};
const CORTEX_MESSAGING_PROVIDERS = new Set([
"discord",
"imessage",
"signal",
"slack",
"telegram",
"voice",
"webchat",
"whatsapp",
]);
const cortexCodingSyncCooldowns = new Map<string, number>();
function normalizeMode(mode?: AgentCortexConfig["mode"]): CortexPolicy {
if (mode === "full" || mode === "professional" || mode === "technical" || mode === "minimal") {
return mode;
}
return DEFAULT_CORTEX_MODE;
}
function normalizeMaxChars(value?: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_CORTEX_MAX_CHARS;
}
return Math.min(MAX_CORTEX_MAX_CHARS, Math.max(1, Math.floor(value)));
}
export function resolveAgentCortexConfig(
cfg: OpenClawConfig,
agentId: string,
): ResolvedAgentCortexConfig | null {
const defaults = cfg.agents?.defaults?.cortex;
const overrides = resolveAgentConfig(cfg, agentId)?.cortex;
const enabled = overrides?.enabled ?? defaults?.enabled ?? false;
if (!enabled) {
return null;
}
return {
enabled: true,
graphPath: overrides?.graphPath ?? defaults?.graphPath,
mode: normalizeMode(overrides?.mode ?? defaults?.mode),
maxChars: normalizeMaxChars(overrides?.maxChars ?? defaults?.maxChars),
};
}
export function resolveCortexChannelTarget(params: {
channel?: string;
channelId?: string;
originatingChannel?: string;
originatingTo?: string;
nativeChannelId?: string;
to?: string;
from?: string;
}): string {
const directConversationId = params.originatingTo?.trim();
if (directConversationId) {
return directConversationId;
}
const nativeConversationId = params.nativeChannelId?.trim();
if (nativeConversationId) {
return nativeConversationId;
}
const destinationId = params.to?.trim();
if (destinationId) {
return destinationId;
}
const sourceId = params.from?.trim();
if (sourceId) {
return sourceId;
}
const providerChannelId = params.channelId?.trim();
if (providerChannelId) {
return providerChannelId;
}
return String(params.originatingChannel ?? params.channel ?? "").trim();
}
export async function resolveAgentCortexModeStatus(params: {
cfg?: OpenClawConfig;
agentId: string;
sessionId?: string;
channelId?: string;
}): Promise<ResolvedAgentCortexModeStatus | null> {
if (!params.cfg) {
return null;
}
const cortex = resolveAgentCortexConfig(params.cfg, params.agentId);
if (!cortex) {
return null;
}
const modeOverride = await getCortexModeOverride({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
});
return {
enabled: true,
graphPath: cortex.graphPath,
maxChars: cortex.maxChars,
mode: modeOverride?.mode ?? cortex.mode,
source:
modeOverride?.scope === "session"
? "session-override"
: modeOverride?.scope === "channel"
? "channel-override"
: "agent-config",
};
}
export async function resolveAgentCortexPromptContext(params: {
cfg?: OpenClawConfig;
agentId: string;
workspaceDir: string;
promptMode: "full" | "minimal";
sessionId?: string;
channelId?: string;
}): Promise<AgentCortexPromptContextResult> {
if (!params.cfg || params.promptMode !== "full") {
return {};
}
const cortex = await resolveAgentCortexModeStatus({
cfg: params.cfg,
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
});
if (!cortex) {
return {};
}
try {
const preview = await previewCortexContext({
workspaceDir: params.workspaceDir,
graphPath: cortex.graphPath,
policy: cortex.mode,
maxChars: cortex.maxChars,
});
return preview.context ? { context: preview.context } : {};
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
};
}
}
export function resetAgentCortexConflictNoticeStateForTests(): void {
cortexConflictNoticeCooldowns.clear();
cortexMemoryCaptureStatuses.clear();
cortexCodingSyncCooldowns.clear();
}
function buildAgentCortexConversationKey(params: {
agentId: string;
sessionId?: string;
channelId?: string;
}): string {
return [params.agentId, params.sessionId ?? "", params.channelId ?? ""].join(":");
}
export function getAgentCortexMemoryCaptureStatus(params: {
agentId: string;
sessionId?: string;
channelId?: string;
}): AgentCortexMemoryCaptureStatus | null {
const key = buildAgentCortexConversationKey({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
});
return cortexMemoryCaptureStatuses.get(key) ?? null;
}
function scoreAgentCortexMemoryCandidate(commandBody: string): AgentCortexMemoryCaptureResult {
const content = commandBody.trim();
if (!content) {
return { captured: false, score: 0, reason: "empty content" };
}
if (content.startsWith("/") || content.startsWith("!")) {
return { captured: false, score: 0, reason: "command content" };
}
if (LOW_SIGNAL_PATTERNS.some((pattern) => pattern.test(content))) {
return { captured: false, score: 0.05, reason: "low-signal short reply" };
}
let score = 0.1;
if (content.length >= MIN_CORTEX_MEMORY_CONTENT_LENGTH) {
score += 0.2;
}
if (content.length >= 80) {
score += 0.1;
}
if (HIGH_SIGNAL_PATTERNS.some((pattern) => pattern.test(content))) {
score += 0.4;
}
if (TECHNICAL_SIGNAL_PATTERNS.some((pattern) => pattern.test(content))) {
score += 0.2;
}
const captured = score >= 0.45;
return {
captured,
score,
reason: captured ? "high-signal memory candidate" : "below memory threshold",
};
}
function resolveAutoSyncCortexCodingContext(params: {
commandBody: string;
provider?: string;
}): { policy: CortexPolicy; platforms: string[] } | null {
if (!TECHNICAL_SIGNAL_PATTERNS.some((pattern) => pattern.test(params.commandBody))) {
return null;
}
const provider = params.provider?.trim().toLowerCase();
if (provider) {
const directPlatforms = CORTEX_CODING_PROVIDER_PLATFORM_MAP[provider];
if (directPlatforms) {
return {
policy: "technical",
platforms: directPlatforms,
};
}
}
const hasStrongCodingIntent = STRONG_CODING_SYNC_PATTERNS.some((pattern) =>
pattern.test(params.commandBody),
);
if (provider && CORTEX_MESSAGING_PROVIDERS.has(provider) && !hasStrongCodingIntent) {
return null;
}
return {
policy: "technical",
platforms: ["claude-code", "cursor", "copilot"],
};
}
export async function resolveAgentCortexConflictNotice(params: {
cfg?: OpenClawConfig;
agentId: string;
workspaceDir: string;
sessionId?: string;
channelId?: string;
minSeverity?: number;
now?: number;
cooldownMs?: number;
}): Promise<AgentCortexConflictNotice | null> {
if (!params.cfg) {
return null;
}
const cortex = resolveAgentCortexConfig(params.cfg, params.agentId);
if (!cortex) {
return null;
}
const targetKey = buildAgentCortexConversationKey({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
});
const now = params.now ?? Date.now();
const cooldownMs = params.cooldownMs ?? DEFAULT_CORTEX_CONFLICT_COOLDOWN_MS;
const nextAllowedAt = cortexConflictNoticeCooldowns.get(targetKey) ?? 0;
if (nextAllowedAt > now) {
return null;
}
try {
const conflicts = await listCortexMemoryConflicts({
workspaceDir: params.workspaceDir,
graphPath: cortex.graphPath,
minSeverity: params.minSeverity ?? DEFAULT_CORTEX_CONFLICT_SEVERITY,
});
const topConflict = conflicts
.filter((entry) => entry.id && entry.summary)
.toSorted((left, right) => right.severity - left.severity)[0];
if (!topConflict) {
return null;
}
cortexConflictNoticeCooldowns.set(targetKey, now + cooldownMs);
return {
conflictId: topConflict.id,
severity: topConflict.severity,
text: [
`⚠️ Cortex conflict detected: ${topConflict.summary}`,
`Resolve with: /cortex resolve ${topConflict.id} <accept-new|keep-old|merge|ignore>`,
].join("\n"),
};
} catch {
return null;
}
}
export async function ingestAgentCortexMemoryCandidate(params: {
cfg?: OpenClawConfig;
agentId: string;
workspaceDir: string;
commandBody: string;
sessionId?: string;
channelId?: string;
provider?: string;
}): Promise<AgentCortexMemoryCaptureResult> {
const conversationKey = buildAgentCortexConversationKey({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
});
if (!params.cfg) {
const result = { captured: false, score: 0, reason: "missing config" };
cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt: Date.now() });
return result;
}
const cortex = resolveAgentCortexConfig(params.cfg, params.agentId);
if (!cortex) {
const result = { captured: false, score: 0, reason: "cortex disabled" };
cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt: Date.now() });
return result;
}
const decision = scoreAgentCortexMemoryCandidate(params.commandBody);
if (!decision.captured) {
cortexMemoryCaptureStatuses.set(conversationKey, { ...decision, updatedAt: Date.now() });
return decision;
}
try {
await ingestCortexMemoryFromText({
workspaceDir: params.workspaceDir,
graphPath: cortex.graphPath,
event: {
actor: "user",
text: params.commandBody,
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
provider: params.provider,
},
});
let syncedCodingContext = false;
let syncPlatforms: string[] | undefined;
const syncPolicy = resolveAutoSyncCortexCodingContext({
commandBody: params.commandBody,
provider: params.provider,
});
if (syncPolicy) {
const nextAllowedAt = cortexCodingSyncCooldowns.get(conversationKey) ?? 0;
const now = Date.now();
if (nextAllowedAt <= now) {
try {
const syncResult = await syncCortexCodingContext({
workspaceDir: params.workspaceDir,
graphPath: cortex.graphPath,
policy: syncPolicy.policy,
platforms: syncPolicy.platforms,
});
syncedCodingContext = true;
syncPlatforms = syncResult.platforms;
cortexCodingSyncCooldowns.set(
conversationKey,
now + DEFAULT_CORTEX_CODING_SYNC_COOLDOWN_MS,
);
} catch {
syncedCodingContext = false;
}
}
}
const result = { ...decision, syncedCodingContext, syncPlatforms };
const updatedAt = Date.now();
cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt });
await appendCortexCaptureHistory({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
captured: result.captured,
score: result.score,
reason: result.reason,
syncedCodingContext: result.syncedCodingContext,
syncPlatforms: result.syncPlatforms,
timestamp: updatedAt,
}).catch(() => {});
return result;
} catch (error) {
const result = {
captured: false,
score: decision.score,
reason: decision.reason,
error: error instanceof Error ? error.message : String(error),
};
const updatedAt = Date.now();
cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt });
await appendCortexCaptureHistory({
agentId: params.agentId,
sessionId: params.sessionId,
channelId: params.channelId,
captured: result.captured,
score: result.score,
reason: result.reason,
error: result.error,
timestamp: updatedAt,
}).catch(() => {});
return result;
}
}
export async function getAgentCortexMemoryCaptureStatusWithHistory(params: {
agentId: string;
sessionId?: string;
channelId?: string;
}): Promise<AgentCortexMemoryCaptureStatus | null> {
const live = getAgentCortexMemoryCaptureStatus(params);
if (live) {
return live;
}
const fromHistory = await getLatestCortexCaptureHistoryEntry(params).catch(() => null);
if (!fromHistory) {
return null;
}
return {
captured: fromHistory.captured,
score: fromHistory.score,
reason: fromHistory.reason,
error: fromHistory.error,
syncedCodingContext: fromHistory.syncedCodingContext,
syncPlatforms: fromHistory.syncPlatforms,
updatedAt: fromHistory.timestamp,
};
}