From d8db426fbdce7cd147f07a40783f2fa6f2d96a32 Mon Sep 17 00:00:00 2001 From: Junebugg1214 <82672745+Junebugg1214@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:41:38 -0400 Subject: [PATCH] Fix Cortex review comments and docs lint --- docs/install/azure.md | 2 + src/agents/cortex.test.ts | 52 ++++++++++++++++++++++++ src/agents/cortex.ts | 25 +++++++----- src/agents/memory-search.ts | 19 +++++++++ src/cli/memory-cli.test.ts | 30 ++++++++++++++ src/cli/memory-cli.ts | 4 +- src/gateway/server/health-state.test.ts | 8 ++-- src/gateway/server/health-state.ts | 6 +-- src/memory/cortex-mode-overrides.test.ts | 6 +-- src/memory/cortex-mode-overrides.ts | 14 +++---- src/memory/manager-sync-ops.ts | 7 ++-- 11 files changed, 141 insertions(+), 32 deletions(-) diff --git a/docs/install/azure.md b/docs/install/azure.md index 7c6abae64fe..012434bc43f 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -284,10 +284,12 @@ Azure Bastion Standard SKU runs approximately **\$140/month** and the VM (Standa To reduce costs: - **Deallocate the VM** when not in use (stops compute billing; disk charges remain). The OpenClaw Gateway will not be reachable while the VM is deallocated — restart it when you need it live again: + ```bash az vm deallocate -g "${RG}" -n "${VM_NAME}" az vm start -g "${RG}" -n "${VM_NAME}" # restart later ``` + - **Delete Bastion when not needed** and recreate it when you need SSH access. Bastion is the largest cost component and takes only a few minutes to provision. - **Use the Basic Bastion SKU** (~\$38/month) if you only need Portal-based SSH and don't require CLI tunneling (`az network bastion ssh`). diff --git a/src/agents/cortex.test.ts b/src/agents/cortex.test.ts index 16508871e51..0bd52e8c261 100644 --- a/src/agents/cortex.test.ts +++ b/src/agents/cortex.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; const { previewCortexContext, @@ -42,6 +44,7 @@ import { } from "./cortex.js"; beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); getCortexStatus.mockResolvedValue({ available: true, workspaceDir: "/tmp/openclaw-workspace", @@ -52,6 +55,7 @@ beforeEach(() => { afterEach(() => { vi.clearAllMocks(); + setActivePluginRegistry(createTestRegistry([])); resetAgentCortexConflictNoticeStateForTests(); }); @@ -588,6 +592,54 @@ describe("ingestAgentCortexMemoryCandidate", () => { expect(syncCortexCodingContext).not.toHaveBeenCalled(); }); + it("does not auto-sync generic technical chatter from registered channel plugins", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createChannelTestPluginBase({ + id: "matrix", + label: "Matrix", + docsPath: "/channels/matrix", + }), + }, + ]), + ); + 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: "!room:example.org", + provider: "matrix", + }); + + expect(result).toMatchObject({ + captured: true, + syncedCodingContext: false, + }); + expect(syncCortexCodingContext).not.toHaveBeenCalled(); + }); + it("skips low-signal text", async () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/cortex.ts b/src/agents/cortex.ts index d84e81a4dfc..acb09ed3fbd 100644 --- a/src/agents/cortex.ts +++ b/src/agents/cortex.ts @@ -10,6 +10,7 @@ import { type CortexPolicy, type CortexStatus, } from "../memory/cortex.js"; +import { resolveGatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { appendCortexCaptureHistory, @@ -136,16 +137,7 @@ const CORTEX_CODING_PROVIDER_PLATFORM_MAP: Record = { cursor: ["cursor"], "gemini-cli": ["gemini-cli"], }; -const CORTEX_MESSAGING_PROVIDERS = new Set([ - "discord", - "imessage", - "signal", - "slack", - "telegram", - "voice", - "webchat", - "whatsapp", -]); +const CORTEX_NON_GATEWAY_MESSAGING_PROVIDERS = new Set(["voice"]); const cortexCodingSyncCooldowns = new Map(); function normalizeMode(mode?: AgentCortexConfig["mode"]): CortexPolicy { @@ -162,6 +154,17 @@ function normalizeMaxChars(value?: number): number { return Math.min(MAX_CORTEX_MAX_CHARS, Math.max(1, Math.floor(value))); } +function isCortexMessagingProvider(provider?: string): boolean { + const normalized = provider?.trim().toLowerCase(); + if (!normalized) { + return false; + } + return ( + CORTEX_NON_GATEWAY_MESSAGING_PROVIDERS.has(normalized) || + resolveGatewayMessageChannel(normalized) !== undefined + ); +} + export function resolveAgentCortexConfig( cfg: OpenClawConfig, agentId: string, @@ -391,7 +394,7 @@ function resolveAutoSyncCortexCodingContext(params: { const hasStrongCodingIntent = STRONG_CODING_SYNC_PATTERNS.some((pattern) => pattern.test(params.commandBody), ); - if (provider && CORTEX_MESSAGING_PROVIDERS.has(provider) && !hasStrongCodingIntent) { + if (provider && isCortexMessagingProvider(provider) && !hasStrongCodingIntent) { return null; } diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 2bae513eb2d..b04a7968efc 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -4,7 +4,9 @@ import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { SecretInput } from "../config/types.secrets.js"; import { + isMemoryMultimodalEnabled, normalizeMemoryMultimodalSettings, + supportsMemoryMultimodalEmbeddings, type MemoryMultimodalSettings, } from "../memory/multimodal.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; @@ -386,5 +388,22 @@ export function resolveMemorySearchConfig( if (!resolved.enabled) { return null; } + const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal); + if ( + multimodalActive && + !supportsMemoryMultimodalEmbeddings({ + provider: resolved.provider, + model: resolved.model, + }) + ) { + throw new Error( + 'agents.*.memorySearch.multimodal requires memorySearch.provider = "gemini" and model = "gemini-embedding-2-preview".', + ); + } + if (multimodalActive && resolved.fallback !== "none") { + throw new Error( + 'agents.*.memorySearch.multimodal does not support memorySearch.fallback. Set fallback to "none".', + ); + } return resolved; } diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index f74aeacab55..3af9f0b16be 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -8,6 +8,7 @@ const getMemorySearchManager = vi.hoisted(() => vi.fn()); const getCortexStatus = vi.hoisted(() => vi.fn()); const previewCortexContext = vi.hoisted(() => vi.fn()); const ensureCortexGraphInitialized = vi.hoisted(() => vi.fn()); +const resolveAgentCortexConfig = vi.hoisted(() => vi.fn()); const getCortexModeOverride = vi.hoisted(() => vi.fn()); const setCortexModeOverride = vi.hoisted(() => vi.fn()); const clearCortexModeOverride = vi.hoisted(() => vi.fn()); @@ -33,6 +34,10 @@ vi.mock("../memory/cortex.js", () => ({ previewCortexContext, })); +vi.mock("../agents/cortex.js", () => ({ + resolveAgentCortexConfig, +})); + vi.mock("../memory/cortex-mode-overrides.js", () => ({ getCortexModeOverride, setCortexModeOverride, @@ -68,6 +73,7 @@ beforeAll(async () => { beforeEach(() => { getMemorySearchManager.mockReset(); loadConfig.mockReset().mockReturnValue({}); + resolveAgentCortexConfig.mockReset().mockReturnValue(null); resolveDefaultAgentId.mockReset().mockReturnValue("main"); resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, @@ -751,6 +757,30 @@ describe("memory cli", () => { ); }); + it("initializes the configured Cortex graph path when --graph is omitted", async () => { + resolveAgentCortexConfig.mockReturnValue({ + enabled: true, + graphPath: ".cortex/agent-main.json", + mode: "technical", + maxChars: 1500, + }); + ensureCortexGraphInitialized.mockResolvedValueOnce({ + graphPath: "/tmp/openclaw-workspace/.cortex/agent-main.json", + created: true, + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "init"]); + + expect(ensureCortexGraphInitialized).toHaveBeenCalledWith({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: ".cortex/agent-main.json", + }); + expect(log).toHaveBeenCalledWith( + "Initialized Cortex graph: /tmp/openclaw-workspace/.cortex/agent-main.json", + ); + }); + it("disables Cortex prompt bridge for a specific agent", async () => { mockWritableConfigSnapshot({ agents: { diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index a7dcfa9aa10..91a47ee2b10 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; import { resolveDefaultAgentId, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveAgentCortexConfig } from "../agents/cortex.js"; import { loadConfig, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; @@ -405,9 +406,10 @@ async function runCortexInit(opts: CortexCommandOptions): Promise { const agentId = resolveAgent(cfg, opts.agent); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); try { + const graphPath = opts.graph?.trim() || resolveAgentCortexConfig(cfg, agentId)?.graphPath; const result = await ensureCortexGraphInitialized({ workspaceDir, - graphPath: opts.graph, + graphPath, }); if (opts.json) { defaultRuntime.log(JSON.stringify({ agentId, workspaceDir, ...result }, null, 2)); diff --git a/src/gateway/server/health-state.test.ts b/src/gateway/server/health-state.test.ts index 10a8b5e5b5d..326cbe55acb 100644 --- a/src/gateway/server/health-state.test.ts +++ b/src/gateway/server/health-state.test.ts @@ -12,7 +12,7 @@ const resolveGatewayAuthMock = vi.hoisted(() => vi.fn()); const getUpdateAvailableMock = vi.hoisted(() => vi.fn()); const resolveAgentCortexModeStatusMock = vi.hoisted(() => vi.fn()); const resolveCortexChannelTargetMock = vi.hoisted(() => vi.fn()); -const getCachedLatestCortexCaptureHistoryEntryMock = vi.hoisted(() => vi.fn()); +const getLatestCortexCaptureHistoryEntryMock = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ STATE_DIR: "/tmp/openclaw-state", @@ -57,7 +57,7 @@ vi.mock("../../agents/cortex.js", () => ({ })); vi.mock("../../agents/cortex-history.js", () => ({ - getCachedLatestCortexCaptureHistoryEntry: getCachedLatestCortexCaptureHistoryEntryMock, + getLatestCortexCaptureHistoryEntry: getLatestCortexCaptureHistoryEntryMock, })); import { buildGatewaySnapshot } from "./health-state.js"; @@ -99,7 +99,7 @@ describe("buildGatewaySnapshot", () => { graphPath: ".cortex/context.json", }); resolveCortexChannelTargetMock.mockReturnValue("telegram:user-123"); - getCachedLatestCortexCaptureHistoryEntryMock.mockReturnValue({ + getLatestCortexCaptureHistoryEntryMock.mockResolvedValue({ agentId: "main", sessionId: "session-1", channelId: "telegram:user-123", @@ -129,7 +129,7 @@ describe("buildGatewaySnapshot", () => { nativeChannelId: "telegram:user-123", to: "telegram:user-123", }); - expect(getCachedLatestCortexCaptureHistoryEntryMock).toHaveBeenCalledWith({ + expect(getLatestCortexCaptureHistoryEntryMock).toHaveBeenCalledWith({ agentId: "main", sessionId: "session-1", channelId: "telegram:user-123", diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 1e51a3a80d2..9fc8c6dde74 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -1,5 +1,5 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { getCachedLatestCortexCaptureHistoryEntry } from "../../agents/cortex-history.js"; +import { getLatestCortexCaptureHistoryEntry } from "../../agents/cortex-history.js"; import { resolveAgentCortexModeStatus, resolveCortexChannelTarget } from "../../agents/cortex.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { STATE_DIR, createConfigIO, loadConfig } from "../../config/config.js"; @@ -42,11 +42,11 @@ export async function buildGatewaySnapshot(): Promise { channelId, }); const latestCortexCapture = cortex - ? getCachedLatestCortexCaptureHistoryEntry({ + ? await getLatestCortexCaptureHistoryEntry({ agentId: defaultAgentId, sessionId: mainSessionEntry?.sessionId, channelId, - }) + }).catch(() => null) : null; const scope = cfg.session?.scope ?? "per-sender"; const presence = listSystemPresence(); diff --git a/src/memory/cortex-mode-overrides.test.ts b/src/memory/cortex-mode-overrides.test.ts index fe959c94d02..96bddd4c421 100644 --- a/src/memory/cortex-mode-overrides.test.ts +++ b/src/memory/cortex-mode-overrides.test.ts @@ -22,7 +22,7 @@ describe("cortex mode overrides", () => { return path.join(dir, "cortex-mode-overrides.json"); } - it("prefers session overrides over channel overrides", async () => { + it("prefers channel overrides over session overrides", async () => { const pathname = await createStorePath(); await setCortexModeOverride({ pathname, @@ -46,8 +46,8 @@ describe("cortex mode overrides", () => { channelId: "slack", }); - expect(resolved?.mode).toBe("minimal"); - expect(resolved?.scope).toBe("session"); + expect(resolved?.mode).toBe("professional"); + expect(resolved?.scope).toBe("channel"); }); it("can clear a stored override", async () => { diff --git a/src/memory/cortex-mode-overrides.ts b/src/memory/cortex-mode-overrides.ts index 90e2f1a0cdf..9678e82838e 100644 --- a/src/memory/cortex-mode-overrides.ts +++ b/src/memory/cortex-mode-overrides.ts @@ -59,13 +59,6 @@ export async function getCortexModeOverride(params: { pathname?: string; }): Promise { const store = await readStore(params.pathname); - const sessionId = params.sessionId?.trim(); - if (sessionId) { - const session = store.session[buildKey(params.agentId, sessionId)]; - if (session) { - return session; - } - } const channelId = params.channelId?.trim(); if (channelId) { const channel = store.channel[buildKey(params.agentId, channelId)]; @@ -73,6 +66,13 @@ export async function getCortexModeOverride(params: { return channel; } } + const sessionId = params.sessionId?.trim(); + if (sessionId) { + const session = store.session[buildKey(params.agentId, sessionId)]; + if (session) { + return session; + } + } return null; } diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index 9792844fd5f..977f8c89dd2 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -687,10 +687,11 @@ export abstract class MemoryManagerSyncOps { this.settings.multimodal, ); const fileEntries = ( - await Promise.all( - files.map(async (file) => - buildFileEntry(file, this.workspaceDir, this.settings.multimodal), + await runWithConcurrency( + files.map( + (file) => async () => buildFileEntry(file, this.workspaceDir, this.settings.multimodal), ), + this.getIndexConcurrency(), ) ).filter((entry): entry is MemoryFileEntry => entry !== null); log.debug("memory sync: indexing memory files", {