From 1f52b3c54361d82b34b7a52d16511cead1ae34ee Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:56 -0400 Subject: [PATCH 001/129] feat: integrate Cortex local memory into OpenClaw --- docs/tools/slash-commands.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d792398f1fa..c81f3673900 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -76,6 +76,12 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) +- `/cortex preview|why|continuity|conflicts|resolve|sync coding|mode show|mode set|mode reset` (inspect, explain, and manage Cortex prompt context for the active conversation) + - After `/cortex mode set ...` or `/cortex mode reset`, use `/status` or `/cortex preview` to verify the active mode and source. + - `/cortex why` shows the injected Cortex context plus the active mode, source, graph, session, and channel. + - `/cortex continuity` explains which shared graph backs the current conversation so you can verify cross-channel continuity. + - `/cortex conflicts` lists memory conflicts and suggests the exact `/cortex resolve ...` command to run next. + - `/cortex sync coding` pushes the current graph into coding-tool context files (default: Claude Code, Cursor, Copilot). - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) @@ -112,6 +118,9 @@ Text + native (when enabled): Text-only: +- `/cortex preview|why|continuity|conflicts|resolve|sync coding|mode show|mode set|mode reset` (Cortex prompt preview, explanation, conflict resolution, coding-context sync, and per-conversation mode overrides) + - Recommended verification loop: `/cortex mode set minimal` then `/cortex preview` or `/status`. + - Continuity demo: run `/cortex continuity` in two channels bound to the same agent and compare the shared graph path. - `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction)) - `! ` (host-only; one at a time; use `!poll` + `!stop` for long-running jobs) - `!poll` (check output / status; accepts optional `sessionId`; `/bash poll` also works) @@ -123,7 +132,6 @@ Notes: - `/new ` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body. - For full provider usage breakdown, use `openclaw status --usage`. - `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`. -- In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`. - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. - `/restart` is enabled by default; set `commands.restart: false` to disable it. - Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text). From 1f1c6fd0294d1b91ff48c29a13468b0418205175 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:57 -0400 Subject: [PATCH 002/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/agent-scope.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 8c25f2baf97..aceaa1b7e2e 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -60,6 +60,10 @@ describe("resolveAgentConfig", () => { workspace: "~/openclaw", agentDir: "~/.openclaw/agents/main", model: "anthropic/claude-opus-4", + memorySearch: undefined, + cortex: undefined, + humanDelay: undefined, + heartbeat: undefined, identity: undefined, groupChat: undefined, subagents: undefined, From 497693419d18fbb7e85b6316dd59598de6a9f415 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:57 -0400 Subject: [PATCH 003/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/agent-scope.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5d190ce1eae..487e3969e87 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -32,6 +32,7 @@ type ResolvedAgentConfig = { model?: AgentEntry["model"]; skills?: AgentEntry["skills"]; memorySearch?: AgentEntry["memorySearch"]; + cortex?: AgentEntry["cortex"]; humanDelay?: AgentEntry["humanDelay"]; heartbeat?: AgentEntry["heartbeat"]; identity?: AgentEntry["identity"]; @@ -134,6 +135,7 @@ export function resolveAgentConfig( : undefined, skills: Array.isArray(entry.skills) ? entry.skills : undefined, memorySearch: entry.memorySearch, + cortex: entry.cortex, humanDelay: entry.humanDelay, heartbeat: entry.heartbeat, identity: entry.identity, From a5e9461b26a6bf716b7a8703f5c9346a52e2ba2e Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:58 -0400 Subject: [PATCH 004/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/cortex-history.test.ts | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/agents/cortex-history.test.ts diff --git a/src/agents/cortex-history.test.ts b/src/agents/cortex-history.test.ts new file mode 100644 index 00000000000..f98332a0206 --- /dev/null +++ b/src/agents/cortex-history.test.ts @@ -0,0 +1,82 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + appendCortexCaptureHistory, + getLatestCortexCaptureHistoryEntry, + getLatestCortexCaptureHistoryEntrySync, + readRecentCortexCaptureHistory, +} from "./cortex-history.js"; + +describe("cortex capture history", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("appends and reads recent capture history", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-history-")); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + await appendCortexCaptureHistory({ + agentId: "main", + sessionId: "session-1", + channelId: "channel-1", + captured: true, + score: 0.7, + reason: "high-signal memory candidate", + timestamp: 1_000, + }); + + const recent = await readRecentCortexCaptureHistory({ limit: 5 }); + + expect(recent).toHaveLength(1); + expect(recent[0]).toMatchObject({ + agentId: "main", + captured: true, + reason: "high-signal memory candidate", + }); + }); + + it("returns the latest matching capture entry in async and sync modes", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-history-sync-")); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + await appendCortexCaptureHistory({ + agentId: "main", + sessionId: "session-1", + channelId: "channel-1", + captured: false, + score: 0.1, + reason: "low-signal short reply", + timestamp: 1_000, + }); + await appendCortexCaptureHistory({ + agentId: "main", + sessionId: "session-1", + channelId: "channel-1", + captured: true, + score: 0.7, + reason: "high-signal memory candidate", + syncedCodingContext: true, + syncPlatforms: ["claude-code", "cursor", "copilot"], + timestamp: 2_000, + }); + + const asyncEntry = await getLatestCortexCaptureHistoryEntry({ + agentId: "main", + sessionId: "session-1", + channelId: "channel-1", + }); + const syncEntry = getLatestCortexCaptureHistoryEntrySync({ + agentId: "main", + sessionId: "session-1", + channelId: "channel-1", + }); + + expect(asyncEntry?.timestamp).toBe(2_000); + expect(asyncEntry?.syncedCodingContext).toBe(true); + expect(syncEntry?.timestamp).toBe(2_000); + expect(syncEntry?.syncPlatforms).toEqual(["claude-code", "cursor", "copilot"]); + }); +}); From 0f03c4d2560b4c1b54b04e0bc448f23b334015b2 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:40:59 -0400 Subject: [PATCH 005/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/cortex-history.ts | 112 +++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/agents/cortex-history.ts diff --git a/src/agents/cortex-history.ts b/src/agents/cortex-history.ts new file mode 100644 index 00000000000..3c6e11a6b31 --- /dev/null +++ b/src/agents/cortex-history.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +export type CortexCaptureHistoryEntry = { + agentId: string; + sessionId?: string; + channelId?: string; + captured: boolean; + score: number; + reason: string; + error?: string; + syncedCodingContext?: boolean; + syncPlatforms?: string[]; + timestamp: number; +}; + +function resolveHistoryPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), "logs", "cortex-memory-captures.jsonl"); +} + +export async function appendCortexCaptureHistory( + entry: CortexCaptureHistoryEntry, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const historyPath = resolveHistoryPath(env); + await fsp.mkdir(path.dirname(historyPath), { recursive: true }); + await fsp.appendFile(historyPath, `${JSON.stringify(entry)}\n`, "utf8"); +} + +export async function readRecentCortexCaptureHistory(params?: { + limit?: number; + env?: NodeJS.ProcessEnv; +}): Promise { + const historyPath = resolveHistoryPath(params?.env); + let raw: string; + try { + raw = await fsp.readFile(historyPath, "utf8"); + } catch { + return []; + } + const parsed = raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as CortexCaptureHistoryEntry; + } catch { + return null; + } + }) + .filter((entry): entry is CortexCaptureHistoryEntry => entry != null); + const limit = Math.max(1, params?.limit ?? 20); + return parsed.slice(-limit).toReversed(); +} + +export function getLatestCortexCaptureHistoryEntrySync(params: { + agentId: string; + sessionId?: string; + channelId?: string; + env?: NodeJS.ProcessEnv; +}): CortexCaptureHistoryEntry | null { + const historyPath = resolveHistoryPath(params.env); + let raw: string; + try { + raw = fs.readFileSync(historyPath, "utf8"); + } catch { + return null; + } + const lines = raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line) { + continue; + } + try { + const entry = JSON.parse(line) as CortexCaptureHistoryEntry; + if ( + entry.agentId === params.agentId && + (params.sessionId ? entry.sessionId === params.sessionId : true) && + (params.channelId ? entry.channelId === params.channelId : true) + ) { + return entry; + } + } catch { + continue; + } + } + return null; +} + +export async function getLatestCortexCaptureHistoryEntry(params: { + agentId: string; + sessionId?: string; + channelId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const recent = await readRecentCortexCaptureHistory({ limit: 100, env: params.env }); + return ( + recent.find( + (entry) => + entry.agentId === params.agentId && + (params.sessionId ? entry.sessionId === params.sessionId : true) && + (params.channelId ? entry.channelId === params.channelId : true), + ) ?? null + ); +} From 715e0e6fa80b6618fae452738595548ce8302552 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:00 -0400 Subject: [PATCH 006/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/cortex.test.ts | 577 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 src/agents/cortex.test.ts diff --git a/src/agents/cortex.test.ts b/src/agents/cortex.test.ts new file mode 100644 index 00000000000..e7381deb3d0 --- /dev/null +++ b/src/agents/cortex.test.ts @@ -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"); + }); +}); From 99bd165459c4c67f5e4e92f5bf4df678c9910a66 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:01 -0400 Subject: [PATCH 007/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/cortex.ts | 551 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 src/agents/cortex.ts diff --git a/src/agents/cortex.ts b/src/agents/cortex.ts new file mode 100644 index 00000000000..7d131d30452 --- /dev/null +++ b/src/agents/cortex.ts @@ -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(); +const cortexMemoryCaptureStatuses = new Map(); +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, + /\bI’m 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, + /\bI’m 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 = { + "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(); + +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 { + 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 { + 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 { + 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} `, + ].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 { + 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 { + 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, + }; +} From 020947d29cb78c5d425a06fd52b15e253a553b77 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:02 -0400 Subject: [PATCH 008/129] feat: integrate Cortex local memory into OpenClaw --- src/agents/pi-embedded-runner/run/attempt.ts | 62 ++++++++------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 2f5f3d04d5f..fd5d4033e0a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -11,10 +11,7 @@ import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; -import { - ensureGlobalUndiciEnvProxyDispatcher, - ensureGlobalUndiciStreamTimeouts, -} from "../../../infra/net/undici-global-dispatcher.js"; +import { ensureGlobalUndiciStreamTimeouts } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { @@ -46,6 +43,7 @@ import { listChannelSupportedActions, resolveChannelMessageToolHints, } from "../../channel-tools.js"; +import { resolveAgentCortexPromptContext } from "../../cortex.js"; import { ensureCustomApiRegistered } from "../../custom-api-registry.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { resolveOpenClawDocsPath } from "../../docs-path.js"; @@ -127,7 +125,6 @@ import { installToolResultContextGuard } from "../tool-result-context-guard.js"; import { splitSdkTools } from "../tool-split.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; -import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; import { selectCompactionTimeoutSnapshot, shouldFlagCompactionTimeout, @@ -233,14 +230,15 @@ export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: num ...options, onPayload: (payload: unknown) => { if (!payload || typeof payload !== "object") { - return options?.onPayload?.(payload, model); + options?.onPayload?.(payload); + return; } const payloadRecord = payload as Record; if (!payloadRecord.options || typeof payloadRecord.options !== "object") { payloadRecord.options = {}; } (payloadRecord.options as Record).num_ctx = numCtx; - return options?.onPayload?.(payload, model); + options?.onPayload?.(payload); }, }); } @@ -752,9 +750,6 @@ export async function runEmbeddedAttempt( const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); const runAbortController = new AbortController(); - // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the - // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. - ensureGlobalUndiciEnvProxyDispatcher(); ensureGlobalUndiciStreamTimeouts(); log.debug( @@ -1544,7 +1539,6 @@ export async function runEmbeddedAttempt( toolMetas, unsubscribe, waitForCompactionRetry, - isCompactionInFlight, getMessagingToolSentTexts, getMessagingToolSentMediaUrls, getMessagingToolSentTargets, @@ -1654,6 +1648,14 @@ export async function runEmbeddedAttempt( hookRunner, legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult, }); + const cortexPromptContext = await resolveAgentCortexPromptContext({ + cfg: params.config, + agentId: sessionAgentId, + workspaceDir: params.workspaceDir, + promptMode, + sessionId: params.sessionId, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, + }); { if (hookResult?.prependContext) { effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`; @@ -1661,6 +1663,9 @@ export async function runEmbeddedAttempt( `hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`, ); } + if (cortexPromptContext.error) { + log.warn(`cortex prompt context unavailable: ${cortexPromptContext.error}`); + } const legacySystemPrompt = typeof hookResult?.systemPrompt === "string" ? hookResult.systemPrompt.trim() : ""; if (legacySystemPrompt) { @@ -1670,16 +1675,22 @@ export async function runEmbeddedAttempt( } const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({ baseSystemPrompt: systemPromptText, - prependSystemContext: hookResult?.prependSystemContext, + prependSystemContext: joinPresentTextSegments([ + cortexPromptContext.context, + hookResult?.prependSystemContext, + ]), appendSystemContext: hookResult?.appendSystemContext, }); if (prependedOrAppendedSystemPrompt) { - const prependSystemLen = hookResult?.prependSystemContext?.trim().length ?? 0; + const prependSystemLen = joinPresentTextSegments([ + cortexPromptContext.context, + hookResult?.prependSystemContext, + ])?.trim().length; const appendSystemLen = hookResult?.appendSystemContext?.trim().length ?? 0; applySystemPromptOverrideToSession(activeSession, prependedOrAppendedSystemPrompt); systemPromptText = prependedOrAppendedSystemPrompt; log.debug( - `hooks: applied prependSystemContext/appendSystemContext (${prependSystemLen}+${appendSystemLen} chars)`, + `hooks: applied prependSystemContext/appendSystemContext (${prependSystemLen ?? 0}+${appendSystemLen} chars)`, ); } } @@ -1774,8 +1785,6 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, - trigger: params.trigger, - channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { @@ -1808,7 +1817,6 @@ export async function runEmbeddedAttempt( // Only trust snapshot if compaction wasn't running before or after capture const preCompactionSnapshot = wasCompactingBefore || wasCompactingAfter ? null : snapshot; const preCompactionSessionId = activeSession.sessionId; - const COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS = 60_000; try { // Flush buffered block replies before waiting for compaction so the @@ -1819,21 +1827,7 @@ export async function runEmbeddedAttempt( await params.onBlockReplyFlush(); } - const compactionRetryWait = await waitForCompactionRetryWithAggregateTimeout({ - waitForCompactionRetry, - abortable, - aggregateTimeoutMs: COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS, - isCompactionStillInFlight: isCompactionInFlight, - }); - if (compactionRetryWait.timedOut) { - timedOutDuringCompaction = true; - if (!isProbeSession) { - log.warn( - `compaction retry aggregate timeout (${COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS}ms): ` + - `proceeding with pre-compaction state runId=${params.runId} sessionId=${params.sessionId}`, - ); - } - } + await abortable(waitForCompactionRetry()); } catch (err) { if (isRunnerAbortError(err)) { if (!promptError) { @@ -1984,8 +1978,6 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, - trigger: params.trigger, - channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { @@ -2046,8 +2038,6 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, - trigger: params.trigger, - channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { From f57862831f9ab14478fae4c5b8292a28d89c413d Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:03 -0400 Subject: [PATCH 009/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/commands-registry.data.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 6a2bf205ffd..2120a1f462c 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -196,6 +196,14 @@ function buildChatCommands(): ChatCommandDefinition[] { acceptsArgs: true, category: "status", }), + defineChatCommand({ + key: "cortex", + description: "Inspect or override Cortex prompt mode for this conversation.", + textAlias: "/cortex", + acceptsArgs: true, + scope: "text", + category: "status", + }), defineChatCommand({ key: "export-session", nativeName: "export-session", From a55c5e7c7acd5683cb55ec3664e1364b7b16e34e Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:04 -0400 Subject: [PATCH 010/129] feat: integrate Cortex local memory into OpenClaw --- .../agent-runner.misc.runreplyagent.test.ts | 156 ++++++++++-------- 1 file changed, 87 insertions(+), 69 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 14731dbb0ff..02fc22cf77d 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -68,6 +68,10 @@ vi.mock("./queue.js", async () => { }); const loadCronStoreMock = vi.fn(); +const resolveAgentCortexModeStatusMock = vi.hoisted(() => vi.fn()); +const resolveAgentCortexConflictNoticeMock = vi.hoisted(() => vi.fn()); +const ingestAgentCortexMemoryCandidateMock = vi.hoisted(() => vi.fn()); +const resolveCortexChannelTargetMock = vi.hoisted(() => vi.fn()); vi.mock("../../cron/store.js", async () => { const actual = await vi.importActual("../../cron/store.js"); return { @@ -76,6 +80,18 @@ vi.mock("../../cron/store.js", async () => { }; }); +vi.mock("../../agents/cortex.js", async () => { + const actual = + await vi.importActual("../../agents/cortex.js"); + return { + ...actual, + ingestAgentCortexMemoryCandidate: ingestAgentCortexMemoryCandidateMock, + resolveAgentCortexModeStatus: resolveAgentCortexModeStatusMock, + resolveAgentCortexConflictNotice: resolveAgentCortexConflictNoticeMock, + resolveCortexChannelTarget: resolveCortexChannelTargetMock, + }; +}); + import { runReplyAgent } from "./agent-runner.js"; type RunWithModelFallbackParams = { @@ -90,6 +106,21 @@ beforeEach(() => { runWithModelFallbackMock.mockClear(); runtimeErrorMock.mockClear(); loadCronStoreMock.mockClear(); + resolveAgentCortexModeStatusMock.mockReset(); + resolveAgentCortexConflictNoticeMock.mockReset(); + ingestAgentCortexMemoryCandidateMock.mockReset(); + resolveCortexChannelTargetMock.mockReset(); + resolveAgentCortexModeStatusMock.mockResolvedValue(null); + resolveAgentCortexConflictNoticeMock.mockResolvedValue(null); + ingestAgentCortexMemoryCandidateMock.mockResolvedValue({ + captured: false, + score: 0, + reason: "below memory threshold", + }); + resolveCortexChannelTargetMock.mockImplementation( + (params: { originatingTo?: string; channel?: string }) => + params.originatingTo ?? params.channel ?? "unknown", + ); // Default: no cron jobs in store. loadCronStoreMock.mockResolvedValue({ version: 1, jobs: [] }); resetSystemEventsForTest(); @@ -215,6 +246,62 @@ describe("runReplyAgent onAgentRunStart", () => { expect(onAgentRunStart).toHaveBeenCalledWith("run-started"); expect(result).toMatchObject({ text: "ok" }); }); + + it("prepends a Cortex conflict notice when unresolved conflicts exist", async () => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + agentMeta: { + provider: "anthropic", + model: "claude", + }, + }, + }); + resolveAgentCortexConflictNoticeMock.mockResolvedValueOnce({ + conflictId: "conf_1", + severity: 0.91, + text: "⚠️ Cortex conflict detected: Hiring status changed\nResolve with: /cortex resolve conf_1 ", + }); + + const result = await createRun(); + + expect(result).toEqual([ + expect.objectContaining({ + text: expect.stringContaining("⚠️ Cortex conflict detected"), + }), + expect.objectContaining({ text: "ok" }), + ]); + }); + + it("captures high-signal user text into Cortex before checking conflicts", async () => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + agentMeta: { + provider: "anthropic", + model: "claude", + }, + }, + }); + ingestAgentCortexMemoryCandidateMock.mockResolvedValueOnce({ + captured: true, + score: 0.7, + reason: "high-signal memory candidate", + }); + + await createRun(); + + expect(ingestAgentCortexMemoryCandidateMock).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + workspaceDir: "/tmp", + commandBody: "hello", + sessionId: "session", + channelId: "session:1", + provider: "webchat", + }); + expect(resolveAgentCortexConflictNoticeMock).toHaveBeenCalled(); + }); }); describe("runReplyAgent authProfileId fallback scoping", () => { @@ -1628,72 +1715,3 @@ describe("runReplyAgent transient HTTP retry", () => { expect(payload?.text).toContain("Recovered response"); }); }); - -describe("runReplyAgent billing error classification", () => { - // Regression guard for the runner-level catch block in runAgentTurnWithFallback. - // Billing errors from providers like OpenRouter can contain token/size wording that - // matches context overflow heuristics. This test verifies the final user-visible - // message is the billing-specific one, not the "Context overflow" fallback. - it("returns billing message for mixed-signal error (billing text + overflow patterns)", async () => { - runEmbeddedPiAgentMock.mockRejectedValueOnce( - new Error("402 Payment Required: request token limit exceeded for this billing plan"), - ); - - const typing = createMockTypingController(); - const sessionCtx = { - Provider: "telegram", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - sessionId: "session", - sessionKey: "main", - messageProvider: "telegram", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - - const result = await runReplyAgent({ - commandBody: "hello", - followupRun, - queueKey: "main", - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - typing, - sessionCtx, - defaultModel: "anthropic/claude", - resolvedVerboseLevel: "off", - isNewSession: false, - blockStreamingEnabled: false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: "instant", - }); - - const payload = Array.isArray(result) ? result[0] : result; - expect(payload?.text).toContain("billing error"); - expect(payload?.text).not.toContain("Context overflow"); - }); -}); From 0a161b96fe1c15768ea579b015a08e0364cdc953 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:05 -0400 Subject: [PATCH 011/129] feat: integrate Cortex local memory into OpenClaw --- .../agent-runner.runreplyagent.e2e.test.ts | 186 ++++++------------ 1 file changed, 57 insertions(+), 129 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 6bebdc6a390..e07367345a6 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -21,14 +21,13 @@ type AgentRunParams = { onAssistantMessageStart?: () => Promise | void; onReasoningStream?: (payload: { text?: string }) => Promise | void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: ReplyPayload) => Promise | void; + onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; onAgentEvent?: (evt: { stream: string; data: Record }) => void; }; type EmbeddedRunParams = { prompt?: string; extraSystemPrompt?: string; - memoryFlushWritePath?: string; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; @@ -37,6 +36,8 @@ type EmbeddedRunParams = { const state = vi.hoisted(() => ({ runEmbeddedPiAgentMock: vi.fn(), runCliAgentMock: vi.fn(), + resolveAgentCortexModeStatusMock: vi.fn(), + resolveCortexChannelTargetMock: vi.fn(), })); let modelFallbackModule: typeof import("../../agents/model-fallback.js"); @@ -79,6 +80,17 @@ vi.mock("../../agents/cli-runner.js", () => ({ runCliAgent: (params: unknown) => state.runCliAgentMock(params), })); +vi.mock("../../agents/cortex.js", async () => { + const actual = + await vi.importActual("../../agents/cortex.js"); + return { + ...actual, + resolveAgentCortexModeStatus: (params: unknown) => + state.resolveAgentCortexModeStatusMock(params), + resolveCortexChannelTarget: (params: unknown) => state.resolveCortexChannelTargetMock(params), + }; +}); + vi.mock("./queue.js", () => ({ enqueueFollowupRun: vi.fn(), scheduleFollowupDrain: vi.fn(), @@ -94,6 +106,13 @@ beforeAll(async () => { beforeEach(() => { state.runEmbeddedPiAgentMock.mockClear(); state.runCliAgentMock.mockClear(); + state.resolveAgentCortexModeStatusMock.mockReset(); + state.resolveCortexChannelTargetMock.mockReset(); + state.resolveAgentCortexModeStatusMock.mockResolvedValue(null); + state.resolveCortexChannelTargetMock.mockImplementation( + (params: { originatingTo?: string; channel?: string }) => + params.originatingTo ?? params.channel ?? "unknown", + ); vi.mocked(enqueueFollowupRun).mockClear(); vi.mocked(scheduleFollowupDrain).mockClear(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); @@ -595,40 +614,6 @@ describe("runReplyAgent typing (heartbeat)", () => { } }); - it("preserves channelData on forwarded tool results", async () => { - const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ - text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", - channelData: { - execApproval: { - approvalId: "117ba06d-1111-2222-3333-444444444444", - approvalSlug: "117ba06d", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(onToolResult).toHaveBeenCalledWith({ - text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", - channelData: { - execApproval: { - approvalId: "117ba06d-1111-2222-3333-444444444444", - approvalSlug: "117ba06d", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }); - }); - it("retries transient HTTP failures once with timer-driven backoff", async () => { vi.useFakeTimers(); let calls = 0; @@ -737,6 +722,40 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); + it("announces active Cortex mode only when verbose mode is enabled", async () => { + const cases = [ + { name: "verbose on", verbose: "on" as const, expectNotice: true }, + { name: "verbose off", verbose: "off" as const, expectNotice: false }, + ] as const; + + for (const testCase of cases) { + state.resolveAgentCortexModeStatusMock.mockResolvedValueOnce({ + enabled: true, + mode: "minimal", + source: "session-override", + maxChars: 1500, + }); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + resolvedVerboseLevel: testCase.verbose, + }); + const res = await run(); + const payload = Array.isArray(res) + ? (res[0] as { text?: string }) + : (res as { text?: string }); + + if (testCase.expectNotice) { + expect(payload.text, testCase.name).toContain("Cortex: minimal (session override)"); + continue; + } + expect(payload.text, testCase.name).not.toContain("Cortex:"); + } + }); + it("announces model fallback only when verbose mode is enabled", async () => { const cases = [ { name: "verbose on", verbose: "on" as const, expectNotice: true }, @@ -1255,79 +1274,6 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); - it("clears stale runtime model fields when resetSession retries after compaction failure", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session-stale-model"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry: SessionEntry = { - sessionId, - updatedAt: Date.now(), - sessionFile: transcriptPath, - modelProvider: "qwencode", - model: "qwen3.5-plus-2026-02-15", - contextTokens: 123456, - systemPromptReport: { - source: "run", - generatedAt: Date.now(), - sessionId, - sessionKey: "main", - provider: "qwencode", - model: "qwen3.5-plus-2026-02-15", - workspaceDir: stateDir, - bootstrapMaxChars: 1000, - bootstrapTotalMaxChars: 2000, - systemPrompt: { - chars: 10, - projectContextChars: 5, - nonProjectContextChars: 5, - }, - injectedWorkspaceFiles: [], - skills: { - promptChars: 0, - entries: [], - }, - tools: { - listChars: 0, - schemaChars: 0, - entries: [], - }, - }, - }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - await run(); - - expect(sessionStore.main.modelProvider).toBeUndefined(); - expect(sessionStore.main.model).toBeUndefined(); - expect(sessionStore.main.contextTokens).toBeUndefined(); - expect(sessionStore.main.systemPromptReport).toBeUndefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.modelProvider).toBeUndefined(); - expect(persisted.main.model).toBeUndefined(); - expect(persisted.main.contextTokens).toBeUndefined(); - expect(persisted.main.systemPromptReport).toBeUndefined(); - }); - }); - it("surfaces overflow fallback when embedded run returns empty payloads", async () => { state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ payloads: [], @@ -1685,14 +1631,9 @@ describe("runReplyAgent memory flush", () => { const flushCall = calls[0]; expect(flushCall?.prompt).toContain("Write notes."); expect(flushCall?.prompt).toContain("NO_REPLY"); - expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); - expect(flushCall?.prompt).toContain("MEMORY.md"); - expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); expect(flushCall?.extraSystemPrompt).toContain("extra system"); expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); - expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); - expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); }); }); @@ -1780,17 +1721,9 @@ describe("runReplyAgent memory flush", () => { await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - const calls: Array<{ - prompt?: string; - extraSystemPrompt?: string; - memoryFlushWritePath?: string; - }> = []; + const calls: Array<{ prompt?: string }> = []; state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ - prompt: params.prompt, - extraSystemPrompt: params.extraSystemPrompt, - memoryFlushWritePath: params.memoryFlushWritePath, - }); + calls.push({ prompt: params.prompt }); if (params.prompt?.includes("Pre-compaction memory flush.")) { return { payloads: [], meta: {} }; } @@ -1817,10 +1750,6 @@ describe("runReplyAgent memory flush", () => { expect(calls[0]?.prompt).toContain("Pre-compaction memory flush."); expect(calls[0]?.prompt).toContain("Current time:"); expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); - expect(calls[0]?.prompt).toContain("MEMORY.md"); - expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); - expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); - expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); @@ -2077,4 +2006,3 @@ describe("runReplyAgent memory flush", () => { }); }); }); -import type { ReplyPayload } from "../types.js"; From 93f09f7d029684f539e164ae7a821f58c08ab35e Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:06 -0400 Subject: [PATCH 012/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/agent-runner.ts | 73 ++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index edc441a2552..d1dd8ca2e6f 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,5 +1,11 @@ import fs from "node:fs"; import { lookupContextTokens } from "../../agents/context.js"; +import { + ingestAgentCortexMemoryCandidate, + resolveAgentCortexConflictNotice, + resolveAgentCortexModeStatus, + resolveCortexChannelTarget, +} from "../../agents/cortex.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { isCliProvider } from "../../agents/model-selection.js"; @@ -278,10 +284,6 @@ export async function runReplyAgent(params: { updatedAt: Date.now(), systemSent: false, abortedLastRun: false, - modelProvider: undefined, - model: undefined, - contextTokens: undefined, - systemPromptReport: undefined, fallbackNoticeSelectedModel: undefined, fallbackNoticeActiveModel: undefined, fallbackNoticeReason: undefined, @@ -693,6 +695,69 @@ export async function runReplyAgent(params: { verboseNotices.push({ text: `🧹 Auto-compaction complete${suffix}.` }); } } + const cortexAgentId = + followupRun.run.agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : "main"); + const cortexChannelId = resolveCortexChannelTarget({ + channel: followupRun.run.messageProvider, + originatingChannel: String(sessionCtx.OriginatingChannel ?? ""), + originatingTo: sessionCtx.OriginatingTo, + nativeChannelId: sessionCtx.NativeChannelId, + to: sessionCtx.To, + from: sessionCtx.From, + }); + const cortexModeStatus = + verboseEnabled && cfg + ? await resolveAgentCortexModeStatus({ + cfg, + agentId: cortexAgentId, + sessionId: followupRun.run.sessionId, + channelId: cortexChannelId, + }) + : null; + const cortexMemoryCapture = cfg + ? await ingestAgentCortexMemoryCandidate({ + cfg, + agentId: cortexAgentId, + workspaceDir: followupRun.run.workspaceDir, + commandBody, + sessionId: followupRun.run.sessionId, + channelId: cortexChannelId, + provider: followupRun.run.messageProvider, + }) + : null; + const cortexConflictNotice = cfg + ? await resolveAgentCortexConflictNotice({ + cfg, + agentId: cortexAgentId, + workspaceDir: followupRun.run.workspaceDir, + sessionId: followupRun.run.sessionId, + channelId: cortexChannelId, + }) + : null; + if (verboseEnabled && cortexModeStatus) { + const sourceLabel = + cortexModeStatus.source === "session-override" + ? "session override" + : cortexModeStatus.source === "channel-override" + ? "channel override" + : "agent config"; + verboseNotices.push({ + text: `🧠 Cortex: ${cortexModeStatus.mode} (${sourceLabel})`, + }); + } + if (verboseEnabled && cortexMemoryCapture?.captured) { + verboseNotices.push({ + text: `🧠 Cortex memory updated (${cortexMemoryCapture.reason}, score ${cortexMemoryCapture.score.toFixed(2)})`, + }); + } + if (verboseEnabled && cortexMemoryCapture?.syncedCodingContext) { + verboseNotices.push({ + text: `🧠 Cortex coding sync updated (${(cortexMemoryCapture.syncPlatforms ?? []).join(", ")})`, + }); + } + if (cortexConflictNotice) { + finalPayloads = [{ text: cortexConflictNotice.text }, ...finalPayloads]; + } if (verboseNotices.length > 0) { finalPayloads = [...verboseNotices, ...finalPayloads]; } From c889cdfe5be009851466cc50bdbd4200acea1f0a Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:07 -0400 Subject: [PATCH 013/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/commands-core.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 894724bcfb0..3647089ca39 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -16,6 +16,7 @@ import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleCommandsListCommand, handleContextCommand, + handleCortexCommand, handleExportSessionCommand, handleHelpCommand, handleStatusCommand, @@ -186,6 +187,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise Date: Thu, 12 Mar 2026 18:41:08 -0400 Subject: [PATCH 014/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/commands-cortex.ts | 543 ++++++++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 src/auto-reply/reply/commands-cortex.ts diff --git a/src/auto-reply/reply/commands-cortex.ts b/src/auto-reply/reply/commands-cortex.ts new file mode 100644 index 00000000000..a1abc2f4929 --- /dev/null +++ b/src/auto-reply/reply/commands-cortex.ts @@ -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 [here|session|channel]`." }; +} + +async function buildCortexHelpReply(): Promise { + return { + text: [ + "🧠 /cortex", + "", + "Manage Cortex prompt context for the active conversation.", + "", + "Try:", + "- /cortex preview", + "- /cortex why", + "- /cortex continuity", + "- /cortex conflicts", + "- /cortex conflict ", + "- /cortex resolve ", + "- /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 { + 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 { + 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 { + 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 { + 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 for the full structured view.", + ] + .filter(Boolean) + .join("\n"), + }; +} + +async function buildCortexConflictDetailReply( + params: HandleCommandsParams, + args: string, +): Promise { + 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 ", + }; + } + 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 { + 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 ", + }; + } + 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 { + 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 { + 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 [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 [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 ...", + }; +} + +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 || ""}`, + ); + 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 | /cortex resolve ... | /cortex sync coding ... | /cortex mode ...", + }; + return { + shouldContinue: false, + reply, + }; + } catch (error) { + return { + shouldContinue: false, + reply: { + text: error instanceof Error ? error.message : String(error), + }, + }; + } +}; From a0bd7d93756adc434c093ec7c99f92329fb9ceea Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:09 -0400 Subject: [PATCH 015/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/commands-info.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 07dc5371830..88dc3c0637f 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -6,6 +6,7 @@ import { buildHelpMessage, } from "../status.js"; import { buildContextReply } from "./commands-context-report.js"; +import { handleCortexCommand } from "./commands-cortex.js"; import { buildExportSessionReply } from "./commands-export-session.js"; import { buildStatusReply } from "./commands-status.js"; import type { CommandHandler } from "./commands-types.js"; @@ -133,6 +134,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma } const reply = await buildStatusReply({ cfg: params.cfg, + ctx: params.ctx, command: params.command, sessionEntry: params.sessionEntry, sessionKey: params.sessionKey, @@ -226,3 +228,5 @@ export const handleWhoamiCommand: CommandHandler = async (params, allowTextComma } return { shouldContinue: false, reply: { text: lines.join("\n") } }; }; + +export { handleCortexCommand }; From c63286f0e06b403e1a665ca14c43660ff5cb68f1 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:10 -0400 Subject: [PATCH 016/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/commands-status.ts | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 50d007321c4..c329596451b 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -3,6 +3,7 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { resolveAgentCortexModeStatus, resolveCortexChannelTarget } from "../../agents/cortex.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; import { @@ -22,6 +23,7 @@ import type { MediaUnderstandingDecision } from "../../media-understanding/types import { normalizeGroupActivation } from "../group-activation.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import { buildStatusMessage } from "../status.js"; +import type { MsgContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import type { CommandContext } from "./commands-types.js"; @@ -30,6 +32,7 @@ import { resolveSubagentLabel } from "./subagents-utils.js"; export async function buildStatusReply(params: { cfg: OpenClawConfig; + ctx?: MsgContext; command: CommandContext; sessionEntry?: SessionEntry; sessionKey: string; @@ -50,6 +53,7 @@ export async function buildStatusReply(params: { }): Promise { const { cfg, + ctx, command, sessionEntry, sessionKey, @@ -117,6 +121,7 @@ export async function buildStatusReply(params: { ); let subagentsLine: string | undefined; + let cortexLine: string | undefined; if (sessionKey) { const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); @@ -137,6 +142,29 @@ export async function buildStatusReply(params: { } } } + const cortexStatus = await resolveAgentCortexModeStatus({ + cfg, + agentId: statusAgentId, + sessionId: sessionEntry?.sessionId ?? undefined, + channelId: resolveCortexChannelTarget({ + channel: command.channel, + channelId: command.channelId, + originatingChannel: String(ctx?.OriginatingChannel ?? command.channel), + originatingTo: ctx?.OriginatingTo, + nativeChannelId: ctx?.NativeChannelId, + to: command.to ?? ctx?.To, + from: command.from ?? ctx?.From, + }), + }); + if (cortexStatus) { + const sourceLabel = + cortexStatus.source === "session-override" + ? "session override" + : cortexStatus.source === "channel-override" + ? "channel override" + : "agent config"; + cortexLine = `🧠 Cortex: ${cortexStatus.mode} (${sourceLabel})`; + } const groupActivation = isGroup ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation()) : undefined; @@ -187,6 +215,7 @@ export async function buildStatusReply(params: { modelAuth: selectedModelAuth, activeModelAuth, usageLine: usageLine ?? undefined, + cortexLine, queue: { mode: queueSettings.mode, depth: queueDepth, From f59b864178d75d377de4fe831e6ec711eff6f189 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:11 -0400 Subject: [PATCH 017/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/reply/commands.test.ts | 904 +++++++++++++++++++------- 1 file changed, 670 insertions(+), 234 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 073cc36488c..4190014c1e7 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -105,6 +105,73 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +const previewCortexContextMock = vi.hoisted(() => vi.fn()); +const listCortexMemoryConflictsMock = vi.hoisted(() => vi.fn()); +const resolveCortexMemoryConflictMock = vi.hoisted(() => vi.fn()); +const syncCortexCodingContextMock = vi.hoisted(() => vi.fn()); +const getCortexModeOverrideMock = vi.hoisted(() => vi.fn()); +const setCortexModeOverrideMock = vi.hoisted(() => vi.fn()); +const clearCortexModeOverrideMock = vi.hoisted(() => vi.fn()); +const resolveAgentCortexModeStatusMock = vi.hoisted(() => vi.fn()); +const resolveCortexChannelTargetMock = vi.hoisted(() => vi.fn()); +const getAgentCortexMemoryCaptureStatusWithHistoryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../memory/cortex.js", async () => { + const actual = + await vi.importActual("../../memory/cortex.js"); + return { + ...actual, + previewCortexContext: previewCortexContextMock, + listCortexMemoryConflicts: listCortexMemoryConflictsMock, + resolveCortexMemoryConflict: resolveCortexMemoryConflictMock, + syncCortexCodingContext: syncCortexCodingContextMock, + }; +}); + +vi.mock("../../memory/cortex-mode-overrides.js", async () => { + const actual = await vi.importActual( + "../../memory/cortex-mode-overrides.js", + ); + return { + ...actual, + getCortexModeOverride: getCortexModeOverrideMock, + setCortexModeOverride: setCortexModeOverrideMock, + clearCortexModeOverride: clearCortexModeOverrideMock, + }; +}); + +vi.mock("../../agents/cortex.js", async () => { + const actual = + await vi.importActual("../../agents/cortex.js"); + return { + ...actual, + getAgentCortexMemoryCaptureStatusWithHistory: getAgentCortexMemoryCaptureStatusWithHistoryMock, + resolveAgentCortexModeStatus: resolveAgentCortexModeStatusMock, + resolveCortexChannelTarget: resolveCortexChannelTargetMock, + }; +}); + +type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string }; + +const resetAcpSessionInPlaceMock = vi.hoisted(() => + vi.fn( + async (_params: unknown): Promise => ({ + ok: false, + skipped: true, + }), + ), +); +vi.mock("../../acp/persistent-bindings.js", async () => { + const actual = await vi.importActual( + "../../acp/persistent-bindings.js", + ); + return { + ...actual, + resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params), + }; +}); + +import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { buildCommandContext, handleCommands } from "./commands.js"; @@ -137,6 +204,27 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } +beforeEach(() => { + resetAcpSessionInPlaceMock.mockReset(); + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const); + previewCortexContextMock.mockReset(); + listCortexMemoryConflictsMock.mockReset(); + resolveCortexMemoryConflictMock.mockReset(); + syncCortexCodingContextMock.mockReset(); + getCortexModeOverrideMock.mockReset(); + setCortexModeOverrideMock.mockReset(); + clearCortexModeOverrideMock.mockReset(); + resolveAgentCortexModeStatusMock.mockReset(); + resolveCortexChannelTargetMock.mockReset(); + getAgentCortexMemoryCaptureStatusWithHistoryMock.mockReset(); + resolveAgentCortexModeStatusMock.mockResolvedValue(null); + getAgentCortexMemoryCaptureStatusWithHistoryMock.mockResolvedValue(null); + resolveCortexChannelTargetMock.mockImplementation( + (params: { nativeChannelId?: string; to?: string; channel?: string }) => + params.nativeChannelId ?? params.to ?? params.channel ?? "unknown", + ); +}); + describe("handleCommands gating", () => { it("blocks gated commands when disabled or not elevated-allowlisted", async () => { const cases = typedCases<{ @@ -290,122 +378,6 @@ describe("/approve command", () => { ); }); - it("accepts Telegram command mentions for /approve", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve@bot abc12345 allow-once", cfg, { - BotUsername: "bot", - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - }); - - it("rejects Telegram /approve mentions targeting a different bot", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { - BotUsername: "bot", - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("targets a different Telegram bot"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("surfaces unknown or expired approval id errors", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("unknown or expired approval id"); - }); - - it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("rejects Telegram /approve from non-approvers", async () => { - const cfg = { - commands: { text: true }, - channels: { - telegram: { - allowFrom: ["*"], - execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("not authorized to approve"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - it("rejects gateway clients without approvals scope", async () => { const cfg = { commands: { text: true }, @@ -548,6 +520,368 @@ describe("/compact command", () => { }); }); +describe("/cortex command", () => { + it("shows help for bare /cortex", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/cortex", cfg); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Manage Cortex prompt context"); + expect(result.reply?.text).toContain("/cortex preview"); + expect(result.reply?.text).toContain("/cortex why"); + expect(result.reply?.text).toContain("/cortex conflicts"); + }); + + it("previews Cortex context using the active override", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + }, + }, + }, + } as OpenClawConfig; + resolveAgentCortexModeStatusMock.mockResolvedValueOnce({ + enabled: true, + mode: "minimal", + source: "session-override", + maxChars: 1500, + }); + previewCortexContextMock.mockResolvedValueOnce({ + workspaceDir: testWorkspaceDir, + graphPath: path.join(testWorkspaceDir, ".cortex", "context.json"), + policy: "minimal", + maxChars: 1500, + context: "## Cortex Context\n- Minimal", + }); + + const params = buildParams("/cortex preview", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Cortex preview (minimal, session override)"); + expect(result.reply?.text).toContain("## Cortex Context"); + expect(resolveAgentCortexModeStatusMock).toHaveBeenCalled(); + expect(previewCortexContextMock).toHaveBeenCalledWith({ + workspaceDir: testWorkspaceDir, + graphPath: undefined, + policy: "minimal", + maxChars: 1500, + }); + }); + + it("explains why Cortex context affected the reply", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + graphPath: ".cortex/context.json", + }, + }, + }, + } as OpenClawConfig; + resolveAgentCortexModeStatusMock.mockResolvedValueOnce({ + enabled: true, + mode: "professional", + source: "channel-override", + maxChars: 1500, + graphPath: path.join(testWorkspaceDir, ".cortex", "context.json"), + }); + previewCortexContextMock.mockResolvedValueOnce({ + workspaceDir: testWorkspaceDir, + graphPath: path.join(testWorkspaceDir, ".cortex", "context.json"), + policy: "professional", + maxChars: 1500, + context: "## Cortex Context\n- Work priorities", + }); + getAgentCortexMemoryCaptureStatusWithHistoryMock.mockResolvedValueOnce({ + captured: true, + score: 0.7, + reason: "high-signal memory candidate", + updatedAt: Date.now(), + }); + + const params = buildParams("/cortex why", cfg, { + SessionId: "session-1", + NativeChannelId: "C123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Why I answered this way"); + expect(result.reply?.text).toContain("Mode: professional"); + expect(result.reply?.text).toContain("Source: channel override"); + expect(result.reply?.text).toContain( + "Last memory capture: stored (high-signal memory candidate, score 0.70)", + ); + expect(result.reply?.text).toContain("Injected Cortex context:"); + }); + + it("shows continuity details for the active conversation", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + graphPath: ".cortex/context.json", + }, + }, + }, + } as OpenClawConfig; + + const params = buildParams("/cortex continuity", cfg, { + SessionId: "session-1", + NativeChannelId: "C123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Cortex continuity"); + expect(result.reply?.text).toContain("shared Cortex graph"); + expect(result.reply?.text).toContain("Try /cortex preview from another channel"); + }); + + it("lists Cortex conflicts and suggests a resolve command", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + }, + }, + }, + } as OpenClawConfig; + listCortexMemoryConflictsMock.mockResolvedValueOnce([ + { + id: "conf_1", + type: "temporal_flip", + severity: 0.91, + summary: "Hiring status changed from active hiring to not hiring", + nodeLabel: "Hiring", + oldValue: "active hiring", + newValue: "not hiring", + }, + ]); + + const params = buildParams("/cortex conflicts", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Cortex conflicts (1)"); + expect(result.reply?.text).toContain("conf_1 · temporal_flip · severity 0.91"); + expect(result.reply?.text).toContain("Node: Hiring"); + expect(result.reply?.text).toContain("Old: active hiring"); + expect(result.reply?.text).toContain("New: not hiring"); + expect(result.reply?.text).toContain("/cortex conflict conf_1"); + expect(result.reply?.text).toContain("/cortex resolve conf_1 accept-new"); + }); + + it("shows a structured Cortex conflict detail view", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + }, + }, + }, + } as OpenClawConfig; + listCortexMemoryConflictsMock.mockResolvedValueOnce([ + { + id: "conf_1", + type: "temporal_flip", + severity: 0.91, + summary: "Hiring status changed from active hiring to not hiring", + nodeLabel: "Hiring", + oldValue: "active hiring", + newValue: "not hiring", + }, + ]); + + const params = buildParams("/cortex conflict conf_1", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Cortex conflict detail"); + expect(result.reply?.text).toContain("Node: Hiring"); + expect(result.reply?.text).toContain("/cortex resolve conf_1 keep-old"); + }); + + it("resolves a Cortex conflict from chat", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + }, + }, + }, + } as OpenClawConfig; + resolveCortexMemoryConflictMock.mockResolvedValueOnce({ + status: "ok", + conflictId: "conf_1", + action: "accept-new", + nodesUpdated: 1, + nodesRemoved: 1, + commitId: "ver_123", + }); + + const params = buildParams("/cortex resolve conf_1 accept-new", cfg, { + SessionId: "session-1", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(resolveCortexMemoryConflictMock).toHaveBeenCalledWith({ + workspaceDir: testWorkspaceDir, + graphPath: undefined, + conflictId: "conf_1", + action: "accept-new", + commitMessage: "openclaw cortex resolve conf_1 accept-new", + }); + expect(result.reply?.text).toContain("Resolved Cortex conflict conf_1."); + expect(result.reply?.text).toContain("Commit: ver_123"); + expect(result.reply?.text).toContain("/cortex preview"); + }); + + it("syncs Cortex coding context from chat", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + }, + }, + }, + } as OpenClawConfig; + syncCortexCodingContextMock.mockResolvedValueOnce({ + workspaceDir: testWorkspaceDir, + graphPath: path.join(testWorkspaceDir, ".cortex", "context.json"), + policy: "technical", + platforms: ["claude-code", "cursor", "copilot"], + }); + + const params = buildParams("/cortex sync coding", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(syncCortexCodingContextMock).toHaveBeenCalledWith({ + workspaceDir: testWorkspaceDir, + graphPath: undefined, + policy: "technical", + platforms: [], + }); + expect(result.reply?.text).toContain("Synced Cortex coding context."); + expect(result.reply?.text).toContain("Platforms: claude-code, cursor, copilot"); + }); + + it("sets Cortex mode for the active session", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/cortex mode set professional", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(setCortexModeOverrideMock).toHaveBeenCalledWith({ + agentId: "main", + scope: "session", + targetId: "session-1", + mode: "professional", + }); + expect(result.reply?.text).toContain("Set Cortex mode for this session to professional."); + expect(result.reply?.text).toContain("Use /status or /cortex preview to verify."); + }); + + it("resets Cortex mode for the active channel when requested", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + clearCortexModeOverrideMock.mockResolvedValueOnce(true); + const params = buildParams("/cortex mode reset channel", cfg, { + Surface: "slack", + Provider: "slack", + NativeChannelId: "C123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(resolveCortexChannelTargetMock).toHaveBeenCalled(); + expect(clearCortexModeOverrideMock).toHaveBeenCalledWith({ + agentId: "main", + scope: "channel", + targetId: "C123", + }); + expect(result.reply?.text).toContain("Cleared Cortex mode override for this channel."); + expect(result.reply?.text).toContain("Use /status or /cortex preview to verify."); + }); + + it("shows the active Cortex mode in /status", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + resolveAgentCortexModeStatusMock.mockResolvedValueOnce({ + enabled: true, + mode: "technical", + source: "session-override", + maxChars: 1500, + }); + const params = buildParams("/status", cfg, { SessionId: "session-1" }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Cortex: technical (session override)"); + }); +}); + describe("abort trigger command", () => { beforeEach(() => { vi.clearAllMocks(); @@ -682,52 +1016,6 @@ describe("handleCommands /config configWrites gating", () => { expect(result.reply?.text).toContain("Config writes are disabled"); }); - it("blocks /config set when the target account disables writes", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { - telegram: { - configWrites: true, - accounts: { - work: { configWrites: false, enabled: true }, - }, - }, - }, - } as OpenClawConfig; - const params = buildPolicyParams( - "/config set channels.telegram.accounts.work.enabled=false", - cfg, - { - AccountId: "default", - Provider: "telegram", - Surface: "telegram", - }, - ); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); - }); - - it("blocks ambiguous channel-root /config writes from channel commands", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { config: true, text: true }, - channels: { telegram: { configWrites: true } }, - } as OpenClawConfig; - const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, { - Provider: "telegram", - Surface: "telegram", - }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain( - "cannot replace channels, channel roots, or accounts collections", - ); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); - }); - it("blocks /config set from gateway clients without operator.admin", async () => { const cfg = { commands: { config: true, text: true }, @@ -785,49 +1073,6 @@ describe("handleCommands /config configWrites gating", () => { expect(writeConfigFileMock).toHaveBeenCalledOnce(); expect(result.reply?.text).toContain("Config updated"); }); - - it("keeps /config set working for gateway operator.admin on protected account paths", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams( - "/config set channels.telegram.accounts.work.enabled=false", - { - commands: { config: true, text: true }, - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - } as OpenClawConfig, - { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], - }, - ); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; - expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); - }); }); describe("handleCommands bash alias", () => { @@ -980,35 +1225,6 @@ describe("handleCommands /allowlist", () => { }); }); - it("blocks config-targeted /allowlist edits when the target account disables writes", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - const cfg = { - commands: { text: true, config: true }, - channels: { - telegram: { - configWrites: true, - accounts: { - work: { configWrites: false, allowFrom: ["123"] }, - }, - }, - }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: structuredClone(cfg), - }); - const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, { - AccountId: "default", - Provider: "telegram", - Surface: "telegram", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); - }); - it("removes default-account entries from scoped and legacy pairing stores", async () => { removeChannelAllowFromStoreEntryMock .mockResolvedValueOnce({ @@ -1355,6 +1571,226 @@ describe("handleCommands hooks", () => { }); }); +describe("handleCommands ACP-bound /new and /reset", () => { + const discordChannelId = "1478836151241412759"; + const buildDiscordBoundConfig = (): OpenClawConfig => + ({ + commands: { text: true }, + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "discord", + accountId: "default", + peer: { + kind: "channel", + id: discordChannelId, + }, + }, + acp: { + mode: "persistent", + }, + }, + ], + channels: { + discord: { + allowFrom: ["*"], + guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } }, + }, + }, + }) as OpenClawConfig; + + const buildDiscordBoundParams = (body: string) => { + const params = buildParams(body, buildDiscordBoundConfig(), { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + AccountId: "default", + SenderId: "12345", + From: "discord:12345", + To: discordChannelId, + OriginatingTo: discordChannelId, + SessionKey: "agent:main:acp:binding:discord:default:feedface", + }); + params.sessionKey = "agent:main:acp:binding:discord:default:feedface"; + return params; + }; + + it("handles /new as ACP in-place reset for bound conversations", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); + const result = await handleCommands(buildDiscordBoundParams("/new")); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("ACP session reset in place"); + expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); + expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ + reason: "new", + }); + }); + + it("continues with trailing prompt text after successful ACP-bound /new", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); + const params = buildDiscordBoundParams("/new continue with deployment"); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + const mutableCtx = params.ctx as Record; + expect(mutableCtx.BodyStripped).toBe("continue with deployment"); + expect(mutableCtx.CommandBody).toBe("continue with deployment"); + expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true); + expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); + }); + + it("handles /reset failures without falling back to normal session reset flow", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); + const result = await handleCommands(buildDiscordBoundParams("/reset")); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("ACP session reset failed"); + expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); + expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ + reason: "reset", + }); + }); + + it("does not emit reset hooks when ACP reset fails", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + + const result = await handleCommands(buildDiscordBoundParams("/reset")); + + expect(result.shouldContinue).toBe(false); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("keeps existing /new behavior for non-ACP sessions", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const result = await handleCommands(buildParams("/new", cfg)); + + expect(result.shouldContinue).toBe(true); + expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled(); + }); + + it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => { + const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; + const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "default", + conversationId: discordChannelId, + agentId: "codex", + mode: "persistent", + }); + const params = buildDiscordBoundParams("/new"); + params.sessionKey = fallbackSessionKey; + params.ctx.SessionKey = fallbackSessionKey; + params.ctx.CommandTargetSessionKey = fallbackSessionKey; + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("ACP session reset unavailable"); + expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); + expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ + sessionKey: configuredAcpSessionKey, + reason: "new", + }); + }); + + it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); + const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; + const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ + channel: "discord", + accountId: "default", + conversationId: discordChannelId, + agentId: "codex", + mode: "persistent", + }); + const fallbackEntry = { + sessionId: "fallback-session-id", + sessionFile: "/tmp/fallback-session.jsonl", + } as SessionEntry; + const configuredEntry = { + sessionId: "configured-acp-session-id", + sessionFile: "/tmp/configured-acp-session.jsonl", + } as SessionEntry; + const params = buildDiscordBoundParams("/new"); + params.sessionKey = fallbackSessionKey; + params.ctx.SessionKey = fallbackSessionKey; + params.ctx.CommandTargetSessionKey = fallbackSessionKey; + params.sessionEntry = fallbackEntry; + params.previousSessionEntry = fallbackEntry; + params.sessionStore = { + [fallbackSessionKey]: fallbackEntry, + [configuredAcpSessionKey]: configuredEntry, + }; + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("ACP session reset in place"); + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "command", + action: "new", + sessionKey: configuredAcpSessionKey, + context: expect.objectContaining({ + sessionEntry: configuredEntry, + previousSessionEntry: configuredEntry, + }), + }), + ); + hookSpy.mockRestore(); + }); + + it("uses active ACP command target when conversation binding context is missing", async () => { + resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); + const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface"; + const params = buildParams( + "/new", + { + commands: { text: true }, + channels: { + discord: { + allowFrom: ["*"], + }, + }, + } as OpenClawConfig, + { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + AccountId: "default", + SenderId: "12345", + From: "discord:12345", + }, + ); + params.sessionKey = "discord:slash:12345"; + params.ctx.SessionKey = "discord:slash:12345"; + params.ctx.CommandSource = "native"; + params.ctx.CommandTargetSessionKey = activeAcpTarget; + params.ctx.To = "user:12345"; + params.ctx.OriginatingTo = "user:12345"; + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("ACP session reset in place"); + expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); + expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ + sessionKey: activeAcpTarget, + reason: "new", + }); + }); +}); + describe("handleCommands context", () => { it("returns expected details for /context commands", async () => { const cfg = { From 0058b4418675abc4453eefc31c5cb34b1bfebfde Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:12 -0400 Subject: [PATCH 018/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/status.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e58f03e0c13..fe4e96ddab7 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -113,6 +113,23 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Reasoning: on"); }); + it("includes Cortex mode details when provided", () => { + const text = buildStatusMessage({ + agent: { + model: "anthropic/pi:opus", + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + cortexLine: "🧠 Cortex: minimal (session override)", + }); + + expect(normalizeTestText(text)).toContain("Cortex: minimal (session override)"); + }); + it("notes channel model overrides in status output", () => { const text = buildStatusMessage({ config: { @@ -677,6 +694,12 @@ describe("buildCommandsMessage", () => { expect(text).toContain("/skill - Run a skill by name."); expect(text).toContain("/think (/thinking, /t) - Set thinking level."); expect(text).toContain("/compact - Compact the session context."); + expect(text).toContain( + "/cortex [text] - Inspect or override Cortex prompt mode for this conversation.", + ); + expect(text).toContain( + "Tip: /cortex preview shows the active Cortex context; /status shows mode and source.", + ); expect(text).not.toContain("/config"); expect(text).not.toContain("/debug"); }); @@ -703,6 +726,10 @@ describe("buildHelpMessage", () => { const text = buildHelpMessage({ commands: { config: false, debug: false }, } as unknown as OpenClawConfig); + expect(text).toContain("Cortex"); + expect(text).toContain("/cortex preview"); + expect(text).toContain("/cortex mode show"); + expect(text).toContain("/cortex mode set "); expect(text).toContain("Skills"); expect(text).toContain("/skill [input]"); expect(text).not.toContain("/config"); @@ -722,6 +749,7 @@ describe("buildCommandsMessagePaginated", () => { expect(result.text).toContain("ℹ️ Commands (1/"); expect(result.text).toContain("Session"); expect(result.text).toContain("/stop - Stop the current run."); + expect(result.text).toContain("Tip: /cortex preview shows context; /status shows mode/source."); }); it("includes plugin commands in the paginated list", () => { From ad2bfb498f825807e66b23dda801216d45a9c724 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:13 -0400 Subject: [PATCH 019/129] feat: integrate Cortex local memory into OpenClaw --- src/auto-reply/status.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d4c5e0c18bb..eed31d5d3aa 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -83,6 +83,7 @@ type StatusArgs = { modelAuth?: string; activeModelAuth?: string; usageLine?: string; + cortexLine?: string; timeLine?: string; queue?: QueueStatus; mediaDecisions?: ReadonlyArray; @@ -673,6 +674,7 @@ export function buildStatusMessage(args: StatusArgs): string { usageCostLine, cacheLine, `📚 ${contextLine}`, + args.cortexLine, mediaLine, args.usageLine, `🧵 ${sessionLine}`, @@ -743,6 +745,10 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { lines.push(" /status | /whoami | /context"); lines.push(""); + lines.push("Cortex"); + lines.push(" /cortex preview | /cortex mode show | /cortex mode set "); + lines.push(""); + lines.push("Skills"); lines.push(" /skill [input]"); @@ -868,6 +874,12 @@ export function buildCommandsMessagePaginated( if (!isTelegram) { const lines = ["ℹ️ Slash commands", ""]; lines.push(formatCommandList(items)); + if (items.some((item) => item.text.startsWith("/cortex "))) { + lines.push(""); + lines.push( + "Tip: /cortex preview shows the active Cortex context; /status shows mode and source.", + ); + } return { text: lines.join("\n").trim(), totalPages: 1, @@ -886,6 +898,10 @@ export function buildCommandsMessagePaginated( const lines = [`ℹ️ Commands (${currentPage}/${totalPages})`, ""]; lines.push(formatCommandList(pageItems)); + if (currentPage === 1 && items.some((item) => item.text.startsWith("/cortex "))) { + lines.push(""); + lines.push("Tip: /cortex preview shows context; /status shows mode/source."); + } return { text: lines.join("\n").trim(), From 5271cf5c050c3f05dd94d7cd7ddf8e02b2718e2b Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:13 -0400 Subject: [PATCH 020/129] feat: integrate Cortex local memory into OpenClaw --- src/cli/memory-cli.test.ts | 233 +++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 2405055adc6..b65fa3f5ca7 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -5,8 +5,16 @@ import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const getMemorySearchManager = vi.fn(); +const getCortexStatus = vi.fn(); +const previewCortexContext = vi.fn(); +const getCortexModeOverride = vi.fn(); +const setCortexModeOverride = vi.fn(); +const clearCortexModeOverride = vi.fn(); const loadConfig = vi.fn(() => ({})); +const readConfigFileSnapshot = vi.fn(); +const writeConfigFile = vi.fn(async () => {}); const resolveDefaultAgentId = vi.fn(() => "main"); +const resolveAgentWorkspaceDir = vi.fn(() => "/tmp/openclaw-workspace"); const resolveCommandSecretRefsViaGateway = vi.fn(async ({ config }: { config: unknown }) => ({ resolvedConfig: config, diagnostics: [] as string[], @@ -16,12 +24,26 @@ vi.mock("../memory/index.js", () => ({ getMemorySearchManager, })); +vi.mock("../memory/cortex.js", () => ({ + getCortexStatus, + previewCortexContext, +})); + +vi.mock("../memory/cortex-mode-overrides.js", () => ({ + getCortexModeOverride, + setCortexModeOverride, + clearCortexModeOverride, +})); + vi.mock("../config/config.js", () => ({ loadConfig, + readConfigFileSnapshot, + writeConfigFile, })); vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId, + resolveAgentWorkspaceDir, })); vi.mock("./command-secret-gateway.js", () => ({ @@ -42,6 +64,13 @@ beforeAll(async () => { afterEach(() => { vi.restoreAllMocks(); getMemorySearchManager.mockClear(); + getCortexStatus.mockClear(); + previewCortexContext.mockClear(); + getCortexModeOverride.mockClear(); + setCortexModeOverride.mockClear(); + clearCortexModeOverride.mockClear(); + readConfigFileSnapshot.mockClear(); + writeConfigFile.mockClear(); resolveCommandSecretRefsViaGateway.mockClear(); process.exitCode = undefined; setVerbose(false); @@ -87,6 +116,21 @@ describe("memory cli", () => { getMemorySearchManager.mockResolvedValueOnce({ manager }); } + function mockWritableConfigSnapshot(resolved: Record) { + readConfigFileSnapshot.mockResolvedValueOnce({ + exists: true, + valid: true, + config: resolved, + resolved, + issues: [], + warnings: [], + legacyIssues: [], + path: "/tmp/openclaw.json", + raw: JSON.stringify(resolved), + parsed: resolved, + }); + } + function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType) { resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ resolvedConfig: {}, @@ -252,6 +296,11 @@ describe("memory cli", () => { expect(helpText).toContain("Quick search using positional query."); expect(helpText).toContain('openclaw memory search --query "deployment" --max-results 20'); expect(helpText).toContain("Limit results for focused troubleshooting."); + expect(helpText).toContain("openclaw memory cortex status"); + expect(helpText).toContain("Check local Cortex bridge availability."); + expect(helpText).toContain("openclaw memory cortex preview --mode technical"); + expect(helpText).toContain("openclaw memory cortex enable --mode technical"); + expect(helpText).toContain("openclaw memory cortex mode set minimal --session-id abc123"); }); it("prints vector error when unavailable", async () => { @@ -565,4 +614,188 @@ describe("memory cli", () => { expect(payload.results as unknown[]).toHaveLength(1); expect(close).toHaveBeenCalled(); }); + + it("prints Cortex bridge status", async () => { + getCortexStatus.mockResolvedValueOnce({ + available: true, + workspaceDir: "/tmp/openclaw-workspace", + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + graphExists: true, + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "status"]); + + expect(getCortexStatus).toHaveBeenCalledWith({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: undefined, + }); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Cortex Bridge")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("CLI: ready")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Graph: present")); + }); + + it("prints Cortex bridge status as json", async () => { + getCortexStatus.mockResolvedValueOnce({ + available: false, + workspaceDir: "/tmp/openclaw-workspace", + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + graphExists: false, + error: "spawn cortex ENOENT", + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "status", "--json"]); + + const payload = firstLoggedJson(log); + expect(payload.agentId).toBe("main"); + expect(payload.available).toBe(false); + expect(payload.error).toBe("spawn cortex ENOENT"); + }); + + it("prints Cortex preview context", async () => { + previewCortexContext.mockResolvedValueOnce({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + policy: "technical", + maxChars: 1500, + context: "## Cortex Context\n- Python", + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "preview", "--mode", "technical"]); + + expect(previewCortexContext).toHaveBeenCalledWith({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: undefined, + policy: "technical", + maxChars: undefined, + }); + expect(log).toHaveBeenCalledWith("## Cortex Context\n- Python"); + }); + + it("fails Cortex preview when bridge errors", async () => { + previewCortexContext.mockRejectedValueOnce(new Error("Cortex graph not found")); + + const error = spyRuntimeErrors(); + await runMemoryCli(["cortex", "preview"]); + + expect(error).toHaveBeenCalledWith("Cortex graph not found"); + expect(process.exitCode).toBe(1); + }); + + it("enables Cortex prompt bridge in agent defaults", async () => { + mockWritableConfigSnapshot({}); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "enable", "--mode", "professional", "--max-chars", "2200"]); + + expect(writeConfigFile).toHaveBeenCalledWith({ + agents: { + defaults: { + cortex: { + enabled: true, + mode: "professional", + maxChars: 2200, + }, + }, + }, + }); + expect(log).toHaveBeenCalledWith( + "Enabled Cortex prompt bridge for agent defaults (professional, 2200 chars).", + ); + }); + + it("disables Cortex prompt bridge for a specific agent", async () => { + mockWritableConfigSnapshot({ + agents: { + list: [{ id: "oracle", cortex: { enabled: true, mode: "technical", maxChars: 1500 } }], + }, + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "disable", "--agent", "oracle"]); + + expect(writeConfigFile).toHaveBeenCalledWith({ + agents: { + list: [{ id: "oracle", cortex: { enabled: false, mode: "technical", maxChars: 1500 } }], + }, + }); + expect(log).toHaveBeenCalledWith("Disabled Cortex prompt bridge for agent oracle."); + }); + + it("fails Cortex enable for an unknown agent", async () => { + mockWritableConfigSnapshot({ + agents: { + list: [{ id: "main" }], + }, + }); + + const error = spyRuntimeErrors(); + await runMemoryCli(["cortex", "enable", "--agent", "oracle"]); + + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("Agent not found: oracle"); + expect(process.exitCode).toBe(1); + }); + + it("sets a session-level Cortex mode override", async () => { + setCortexModeOverride.mockResolvedValueOnce({ + agentId: "main", + scope: "session", + targetId: "session-1", + mode: "minimal", + updatedAt: "2026-03-08T23:00:00.000Z", + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "mode", "set", "minimal", "--session-id", "session-1"]); + + expect(setCortexModeOverride).toHaveBeenCalledWith({ + agentId: "main", + scope: "session", + targetId: "session-1", + mode: "minimal", + }); + expect(log).toHaveBeenCalledWith( + "Set Cortex mode override for session session-1 to minimal (main).", + ); + }); + + it("shows a stored channel-level Cortex mode override as json", async () => { + getCortexModeOverride.mockResolvedValueOnce({ + agentId: "main", + scope: "channel", + targetId: "slack", + mode: "professional", + updatedAt: "2026-03-08T23:00:00.000Z", + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "mode", "show", "--channel", "slack", "--json"]); + + const payload = firstLoggedJson(log); + expect(payload.agentId).toBe("main"); + expect(payload.scope).toBe("channel"); + expect(payload.targetId).toBe("slack"); + expect(payload.override).toMatchObject({ mode: "professional" }); + }); + + it("rejects ambiguous Cortex mode targets", async () => { + const error = spyRuntimeErrors(); + await runMemoryCli([ + "cortex", + "mode", + "set", + "technical", + "--session-id", + "session-1", + "--channel", + "slack", + ]); + + expect(setCortexModeOverride).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("Choose either --session-id or --channel, not both."); + expect(process.exitCode).toBe(1); + }); }); From c92158731ed3cce8bd4b3afd40679895d055827c Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:14 -0400 Subject: [PATCH 021/129] feat: integrate Cortex local memory into OpenClaw --- src/cli/memory-cli.ts | 414 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 412 insertions(+), 2 deletions(-) diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 14afad0c4f2..e6be9cedfe2 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -3,11 +3,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig } from "../config/config.js"; +import { resolveDefaultAgentId, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { loadConfig, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; import { setVerbose } from "../globals.js"; +import { + clearCortexModeOverride, + getCortexModeOverride, + setCortexModeOverride, + type CortexModeScope, +} from "../memory/cortex-mode-overrides.js"; +import { getCortexStatus, previewCortexContext, type CortexPolicy } from "../memory/cortex.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { defaultRuntime } from "../runtime.js"; @@ -29,6 +36,24 @@ type MemoryCommandOptions = { verbose?: boolean; }; +type CortexCommandOptions = { + agent?: string; + graph?: string; + json?: boolean; +}; + +type CortexEnableCommandOptions = CortexCommandOptions & { + mode?: CortexPolicy; + maxChars?: number; +}; + +type CortexModeCommandOptions = { + agent?: string; + sessionId?: string; + channel?: string; + json?: boolean; +}; + type MemoryManager = NonNullable; type MemoryManagerPurpose = Parameters[0]["purpose"]; @@ -307,6 +332,294 @@ async function summarizeQmdIndexArtifact(manager: MemoryManager): Promise { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const status = await getCortexStatus({ + workspaceDir, + graphPath: opts.graph, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify({ agentId, ...status }, null, 2)); + return; + } + const rich = isRich(); + const heading = (text: string) => colorize(rich, theme.heading, text); + const muted = (text: string) => colorize(rich, theme.muted, text); + const info = (text: string) => colorize(rich, theme.info, text); + const success = (text: string) => colorize(rich, theme.success, text); + const warn = (text: string) => colorize(rich, theme.warn, text); + const label = (text: string) => muted(`${text}:`); + const lines = [ + `${heading("Cortex Bridge")} ${muted(`(${agentId})`)}`, + `${label("CLI")} ${status.available ? success("ready") : warn("unavailable")}`, + `${label("Graph")} ${status.graphExists ? success("present") : warn("missing")}`, + `${label("Path")} ${info(shortenHomePath(status.graphPath))}`, + `${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`, + ]; + if (status.error) { + lines.push(`${label("Error")} ${warn(status.error)}`); + } + defaultRuntime.log(lines.join("\n")); +} + +async function runCortexPreview( + opts: CortexCommandOptions & { + mode?: CortexPolicy; + maxChars?: number; + }, +): Promise { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + try { + const preview = await previewCortexContext({ + workspaceDir, + graphPath: opts.graph, + policy: opts.mode, + maxChars: opts.maxChars, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify({ agentId, ...preview }, null, 2)); + return; + } + if (!preview.context) { + defaultRuntime.log("No Cortex context available."); + return; + } + defaultRuntime.log(preview.context); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + +async function loadWritableMemoryConfig(): Promise | null> { + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + defaultRuntime.error( + "Config invalid. Run `openclaw config validate` or `openclaw doctor` first.", + ); + process.exitCode = 1; + return null; + } + return structuredClone(snapshot.resolved) as Record; +} + +function parseCortexMode(mode?: string): CortexPolicy { + if (mode === undefined) { + return "technical"; + } + if (mode === "full" || mode === "professional" || mode === "technical" || mode === "minimal") { + return mode; + } + throw new Error(`Invalid Cortex mode: ${mode}`); +} + +function normalizeCortexMaxChars(value?: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return 1_500; + } + return Math.min(8_000, Math.max(1, Math.floor(value))); +} + +function resolveCortexModeTarget(opts: CortexModeCommandOptions): { + scope: CortexModeScope; + targetId: string; +} { + const sessionId = opts.sessionId?.trim(); + const channelId = opts.channel?.trim(); + if (sessionId && channelId) { + throw new Error("Choose either --session-id or --channel, not both."); + } + if (sessionId) { + return { scope: "session", targetId: sessionId }; + } + if (channelId) { + return { scope: "channel", targetId: channelId }; + } + throw new Error("Missing target. Use --session-id or --channel ."); +} + +function updateAgentCortexConfig(params: { + root: Record; + agentId?: string; + updater: (current: Record) => Record; +}): void { + const agents = ((params.root.agents as Record | undefined) ??= {}); + if (params.agentId?.trim()) { + const list = Array.isArray(agents.list) ? (agents.list as Record[]) : []; + const index = list.findIndex( + (entry) => typeof entry.id === "string" && entry.id === params.agentId?.trim(), + ); + if (index === -1) { + throw new Error(`Agent not found: ${params.agentId}`); + } + const entry = list[index] ?? {}; + list[index] = { + ...entry, + cortex: params.updater((entry.cortex as Record | undefined) ?? {}), + }; + agents.list = list; + return; + } + + const defaults = ((agents.defaults as Record | undefined) ??= {}); + defaults.cortex = params.updater((defaults.cortex as Record | undefined) ?? {}); +} + +async function runCortexEnable(opts: CortexEnableCommandOptions): Promise { + try { + const next = await loadWritableMemoryConfig(); + if (!next) { + return; + } + updateAgentCortexConfig({ + root: next, + agentId: opts.agent, + updater: (current) => ({ + ...current, + enabled: true, + mode: parseCortexMode(opts.mode), + maxChars: normalizeCortexMaxChars(opts.maxChars), + ...(opts.graph ? { graphPath: opts.graph } : {}), + }), + }); + await writeConfigFile(next); + + const scope = opts.agent?.trim() ? `agent ${opts.agent.trim()}` : "agent defaults"; + defaultRuntime.log( + `Enabled Cortex prompt bridge for ${scope} (${parseCortexMode(opts.mode)}, ${normalizeCortexMaxChars(opts.maxChars)} chars).`, + ); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + +async function runCortexDisable(opts: CortexCommandOptions): Promise { + try { + const next = await loadWritableMemoryConfig(); + if (!next) { + return; + } + updateAgentCortexConfig({ + root: next, + agentId: opts.agent, + updater: (current) => ({ + ...current, + enabled: false, + }), + }); + await writeConfigFile(next); + + const scope = opts.agent?.trim() ? `agent ${opts.agent.trim()}` : "agent defaults"; + defaultRuntime.log(`Disabled Cortex prompt bridge for ${scope}.`); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + +async function runCortexModeShow(opts: CortexModeCommandOptions): Promise { + try { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const target = resolveCortexModeTarget(opts); + const override = await getCortexModeOverride({ + agentId, + sessionId: target.scope === "session" ? target.targetId : undefined, + channelId: target.scope === "channel" ? target.targetId : undefined, + }); + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + agentId, + scope: target.scope, + targetId: target.targetId, + override, + }, + null, + 2, + ), + ); + return; + } + if (!override) { + defaultRuntime.log(`No Cortex mode override for ${target.scope} ${target.targetId}.`); + return; + } + defaultRuntime.log( + `Cortex mode override for ${target.scope} ${target.targetId}: ${override.mode} (${agentId})`, + ); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + +async function runCortexModeSet(mode: CortexPolicy, opts: CortexModeCommandOptions): Promise { + try { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const target = resolveCortexModeTarget(opts); + const next = await setCortexModeOverride({ + agentId, + scope: target.scope, + targetId: target.targetId, + mode: parseCortexMode(mode), + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(next, null, 2)); + return; + } + defaultRuntime.log( + `Set Cortex mode override for ${target.scope} ${target.targetId} to ${next.mode} (${agentId}).`, + ); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + +async function runCortexModeReset(opts: CortexModeCommandOptions): Promise { + try { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const target = resolveCortexModeTarget(opts); + const removed = await clearCortexModeOverride({ + agentId, + scope: target.scope, + targetId: target.targetId, + }); + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + agentId, + scope: target.scope, + targetId: target.targetId, + removed, + }, + null, + 2, + ), + ); + return; + } + if (!removed) { + defaultRuntime.log(`No Cortex mode override found for ${target.scope} ${target.targetId}.`); + return; + } + defaultRuntime.log(`Cleared Cortex mode override for ${target.scope} ${target.targetId}.`); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + async function scanMemorySources(params: { workspaceDir: string; agentId: string; @@ -590,6 +903,23 @@ export function registerMemoryCli(program: Command) { "Limit results for focused troubleshooting.", ], ["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."], + ["openclaw memory cortex status", "Check local Cortex bridge availability."], + [ + "openclaw memory cortex preview --mode technical", + "Preview filtered Cortex context for the active agent workspace.", + ], + [ + "openclaw memory cortex enable --mode technical", + "Turn on Cortex prompt injection without editing openclaw.json manually.", + ], + [ + "openclaw memory cortex mode set minimal --session-id abc123", + "Override Cortex mode for one OpenClaw session.", + ], + [ + "openclaw memory cortex mode set professional --channel slack", + "Override Cortex mode for a channel surface.", + ], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`, ); @@ -814,4 +1144,84 @@ export function registerMemoryCli(program: Command) { }); }, ); + + const cortex = memory.command("cortex").description("Inspect the local Cortex memory bridge"); + + cortex + .command("status") + .description("Check Cortex CLI and graph availability") + .option("--agent ", "Agent id (default: default agent)") + .option("--graph ", "Override Cortex graph path") + .option("--json", "Print JSON") + .action(async (opts: CortexCommandOptions) => { + await runCortexStatus(opts); + }); + + cortex + .command("preview") + .description("Preview Cortex context export for the active workspace") + .option("--agent ", "Agent id (default: default agent)") + .option("--graph ", "Override Cortex graph path") + .option("--mode ", "Context mode", "technical") + .option("--max-chars ", "Max characters", (value: string) => Number(value)) + .option("--json", "Print JSON") + .action( + async ( + opts: CortexCommandOptions & { + mode?: CortexPolicy; + maxChars?: number; + }, + ) => { + await runCortexPreview(opts); + }, + ); + + cortex + .command("enable") + .description("Enable Cortex prompt context injection in config") + .option("--agent ", "Apply to a specific agent id instead of agent defaults") + .option("--graph ", "Override Cortex graph path") + .option("--mode ", "Context mode", "technical") + .option("--max-chars ", "Max characters", (value: string) => Number(value)) + .action(async (opts: CortexEnableCommandOptions) => { + await runCortexEnable(opts); + }); + + cortex + .command("disable") + .description("Disable Cortex prompt context injection in config") + .option("--agent ", "Apply to a specific agent id instead of agent defaults") + .action(async (opts: CortexCommandOptions) => { + await runCortexDisable(opts); + }); + + const cortexMode = cortex.command("mode").description("Manage runtime Cortex mode overrides"); + + const applyModeTargetOptions = (command: Command) => + command + .option("--agent ", "Agent id (default: default agent)") + .option("--session-id ", "Apply override to a specific OpenClaw session") + .option("--channel ", "Apply override to a specific channel or surface") + .option("--json", "Print JSON"); + + applyModeTargetOptions( + cortexMode.command("show").description("Show the stored Cortex mode override for a target"), + ).action(async (opts: CortexModeCommandOptions) => { + await runCortexModeShow(opts); + }); + + applyModeTargetOptions( + cortexMode.command("reset").description("Clear the stored Cortex mode override for a target"), + ).action(async (opts: CortexModeCommandOptions) => { + await runCortexModeReset(opts); + }); + + applyModeTargetOptions( + cortexMode + .command("set") + .description("Set a runtime Cortex mode override for a target") + .argument("", "Mode (full|professional|technical|minimal)"), + ).action(async (mode: CortexPolicy, opts: CortexModeCommandOptions) => { + await runCortexModeSet(mode, opts); + }); } From c8fb1012de94d56d70f5fbc8cf3f49dccd2b258d Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:15 -0400 Subject: [PATCH 022/129] feat: integrate Cortex local memory into OpenClaw --- src/commands/configure.shared.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/configure.shared.ts b/src/commands/configure.shared.ts index 638bfc62650..d70a98435b2 100644 --- a/src/commands/configure.shared.ts +++ b/src/commands/configure.shared.ts @@ -11,6 +11,7 @@ export const CONFIGURE_WIZARD_SECTIONS = [ "workspace", "model", "web", + "memory", "gateway", "daemon", "channels", @@ -53,6 +54,11 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{ { value: "workspace", label: "Workspace", hint: "Set workspace + sessions" }, { value: "model", label: "Model", hint: "Pick provider + credentials" }, { value: "web", label: "Web tools", hint: "Configure web search (Perplexity/Brave) + fetch" }, + { + value: "memory", + label: "Cortex memory", + hint: "Enable the local Cortex prompt bridge for agent context", + }, { value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" }, { value: "daemon", From 1bc443aaa14c5c0b320a05ba3e25d3e0e7b9d582 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:16 -0400 Subject: [PATCH 023/129] feat: integrate Cortex local memory into OpenClaw --- src/commands/configure.wizard.test.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..cfcb9236e4b 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -111,6 +111,7 @@ describe("runConfigureWizard", () => { mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); mocks.summarizeExistingConfig.mockReturnValue(""); mocks.createClackPrompter.mockReturnValue({}); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); const selectQueue = ["local", "__continue"]; mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); @@ -135,6 +136,51 @@ describe("runConfigureWizard", () => { ); }); + it("configures Cortex memory through the wizard sections flow", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + + const selectQueue = ["local", "technical"]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + const confirmQueue = [true, true]; + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + mocks.clackText.mockResolvedValue("2048"); + + await runConfigureWizard( + { command: "configure", sections: ["memory"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + cortex: expect.objectContaining({ + enabled: true, + mode: "technical", + maxChars: 2048, + }), + }), + }), + }), + ); + }); + it("exits with code 1 when configure wizard is cancelled", async () => { const runtime = { log: vi.fn(), @@ -152,6 +198,7 @@ describe("runConfigureWizard", () => { mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); mocks.summarizeExistingConfig.mockReturnValue(""); mocks.createClackPrompter.mockReturnValue({}); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); mocks.clackSelect.mockRejectedValueOnce(new WizardCancelledError()); await runConfigureWizard({ command: "configure" }, runtime); From cbd861bc26e223bf3b3fd2a4d7d2e4883de1d782 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:17 -0400 Subject: [PATCH 024/129] feat: integrate Cortex local memory into OpenClaw --- src/commands/configure.wizard.ts | 130 ++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80af67043ab..c4349e0f74b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -188,10 +188,7 @@ async function promptWebToolsConfig( if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { return stored; } - return ( - SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? - SEARCH_PROVIDER_OPTIONS[0].value - ); + return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "brave"; })(); note( @@ -303,6 +300,122 @@ async function promptWebToolsConfig( }; } +async function promptCortexMemoryConfig( + nextConfig: OpenClawConfig, + runtime: RuntimeEnv, + workspaceDir: string, +): Promise { + const existing = nextConfig.agents?.defaults?.cortex; + const defaultGraphPath = nodePath.join(workspaceDir, ".cortex", "context.json"); + const graphExists = await fsPromises + .access(defaultGraphPath) + .then(() => true) + .catch(() => false); + + note( + [ + "Cortex can prepend a filtered local memory graph into agent system prompts.", + `Default workspace graph: ${defaultGraphPath}`, + graphExists + ? "A local Cortex graph was detected in this workspace." + : "No default Cortex graph was detected yet; you can still enable the bridge now.", + ].join("\n"), + "Cortex memory", + ); + + const enable = guardCancel( + await confirm({ + message: "Enable Cortex prompt bridge?", + initialValue: existing?.enabled ?? graphExists, + }), + runtime, + ); + + if (!enable) { + return { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + cortex: { + ...existing, + enabled: false, + }, + }, + }, + }; + } + + const mode = guardCancel( + await select({ + message: "Cortex prompt mode", + options: [ + { value: "technical", label: "Technical", hint: "Project and coding context" }, + { value: "professional", label: "Professional", hint: "Work-safe context slice" }, + { value: "minimal", label: "Minimal", hint: "Smallest safe context" }, + { value: "full", label: "Full", hint: "Largest context slice" }, + ], + initialValue: existing?.mode ?? "technical", + }), + runtime, + ) as NonNullable["defaults"]>["cortex"]["mode"]; + + const maxCharsInput = guardCancel( + await text({ + message: "Cortex max prompt chars", + initialValue: String(existing?.maxChars ?? 1500), + validate: (value) => { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return "Enter a positive integer"; + } + return undefined; + }, + }), + runtime, + ); + const maxChars = Number.parseInt(String(maxCharsInput), 10); + + const useDefaultGraph = guardCancel( + await confirm({ + message: "Use the default workspace Cortex graph path?", + initialValue: !existing?.graphPath, + }), + runtime, + ); + + let graphPath: string | undefined; + if (!useDefaultGraph) { + const graphInput = guardCancel( + await text({ + message: "Cortex graph path", + initialValue: existing?.graphPath ?? defaultGraphPath, + }), + runtime, + ); + const trimmed = String(graphInput ?? "").trim(); + graphPath = trimmed || defaultGraphPath; + } + + return { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + cortex: { + ...existing, + enabled: true, + mode, + maxChars, + ...(graphPath ? { graphPath } : {}), + }, + }, + }, + }; +} + export async function runConfigureWizard( opts: ConfigureWizardParams, runtime: RuntimeEnv = defaultRuntime, @@ -530,6 +643,10 @@ export async function runConfigureWizard( nextConfig = await promptWebToolsConfig(nextConfig, runtime); } + if (selected.includes("memory")) { + nextConfig = await promptCortexMemoryConfig(nextConfig, runtime, workspaceDir); + } + if (selected.includes("gateway")) { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; @@ -584,6 +701,11 @@ export async function runConfigureWizard( await persistConfig(); } + if (choice === "memory") { + nextConfig = await promptCortexMemoryConfig(nextConfig, runtime, workspaceDir); + await persistConfig(); + } + if (choice === "gateway") { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; From 985ed3a9669d12f25648e90bd2db0279be75e53a Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:18 -0400 Subject: [PATCH 025/129] feat: integrate Cortex local memory into OpenClaw --- src/config/schema.help.ts | 42 ++++++++++++++------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3db7f40fe73..967d7c9bc7b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -649,13 +649,11 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", - "tools.web.search.maxResults": "Number of results to return (1-10).", + "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", - "tools.web.search.brave.mode": - 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', @@ -672,6 +670,8 @@ export const FIELD_HELP: Record = { "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "tools.web.search.perplexity.model": 'Optional Sonar/OpenRouter model override (default: "perplexity/sonar-pro"). Setting this opts Perplexity into the legacy chat-completions compatibility path.', + "tools.web.search.brave.mode": + 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxCharsCap": @@ -773,28 +773,28 @@ export const FIELD_HELP: Record = { "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "agents.defaults.cortex": + "Optional Cortex prompt bridge that injects filtered context from a local Cortex graph into the agent system prompt. Keep this off unless you intentionally want OpenClaw to reuse Cortex-managed identity or memory context.", + "agents.defaults.cortex.enabled": + "Enables Cortex prompt-context injection for this agent profile. Keep disabled by default and enable only when a local Cortex graph is available for the workspace.", + "agents.defaults.cortex.graphPath": + "Optional Cortex graph JSON path. Relative paths resolve from the agent workspace; leave unset to use .cortex/context.json inside the workspace.", + "agents.defaults.cortex.mode": + 'Disclosure mode used when exporting Cortex context into the prompt: "technical", "professional", "minimal", or "full". Use narrower modes unless you intentionally want broader context sharing.', + "agents.defaults.cortex.maxChars": + "Maximum number of Cortex-exported characters injected into the system prompt. Keep this bounded so prompt overhead stays predictable.", "agents.defaults.memorySearch.enabled": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", "agents.defaults.memorySearch.sources": 'Chooses which sources are indexed: "memory" reads MEMORY.md + memory files, and "sessions" includes transcript history. Keep ["memory"] unless you need recall from prior chat transcripts.', "agents.defaults.memorySearch.extraPaths": - "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", - "agents.defaults.memorySearch.multimodal": - 'Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memorySearch.fallback` to "none" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.', - "agents.defaults.memorySearch.multimodal.enabled": - "Enables image/audio memory indexing from extraPaths. This currently requires Gemini embedding-2, keeps the default memory roots Markdown-only, disables memory-search fallback providers, and uploads matching binary content to the configured remote embedding provider.", - "agents.defaults.memorySearch.multimodal.modalities": - 'Selects which multimodal file types are indexed from extraPaths: "image", "audio", or "all". Keep this narrow to avoid indexing large binary corpora unintentionally.', - "agents.defaults.memorySearch.multimodal.maxFileBytes": - "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", + "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; keep paths small and intentional to avoid noisy recall.", "agents.defaults.memorySearch.experimental.sessionMemory": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "agents.defaults.memorySearch.provider": 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', "agents.defaults.memorySearch.model": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", - "agents.defaults.memorySearch.outputDimensionality": - "Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.", "agents.defaults.memorySearch.remote.baseUrl": "Overrides the embedding API endpoint, such as an OpenAI-compatible proxy or custom Gemini base URL. Use this only when routing through your own gateway or vendor endpoint; keep provider defaults otherwise.", "agents.defaults.memorySearch.remote.apiKey": @@ -1393,18 +1393,6 @@ export const FIELD_HELP: Record = { "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "channels.telegram.capabilities.inlineButtons": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", - "channels.telegram.execApprovals": - "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", - "channels.telegram.execApprovals.enabled": - "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", - "channels.telegram.execApprovals.approvers": - "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", - "channels.telegram.execApprovals.agentFilter": - 'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.', - "channels.telegram.execApprovals.sessionFilter": - "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", - "channels.telegram.execApprovals.target": - 'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.', "channels.slack.configWrites": "Allow Slack to write config in response to channel events/commands (default: true).", "channels.slack.botToken": From 8c63f3e0b7ad7697788b57e75d9e4375197bb88a Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:19 -0400 Subject: [PATCH 026/129] feat: integrate Cortex local memory into OpenClaw --- src/config/schema.labels.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 01b8d0f57dd..8b104361240 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -218,17 +218,17 @@ export const FIELD_LABELS: Record = { "tools.web.search.maxResults": "Web Search Max Results", "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", - "tools.web.search.brave.mode": "Brave Search Mode", + "tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret + "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", + "tools.web.search.perplexity.model": "Perplexity Model", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret "tools.web.search.grok.model": "Grok Search Model", + "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", "tools.web.search.kimi.model": "Kimi Search Model", - "tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret - "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", - "tools.web.search.perplexity.model": "Perplexity Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars", @@ -315,14 +315,15 @@ export const FIELD_LABELS: Record = { "agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimestamp": "Envelope Timestamp", "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.cortex": "Cortex Prompt Bridge", + "agents.defaults.cortex.enabled": "Enable Cortex Prompt Bridge", + "agents.defaults.cortex.graphPath": "Cortex Graph Path", + "agents.defaults.cortex.mode": "Cortex Prompt Mode", + "agents.defaults.cortex.maxChars": "Cortex Prompt Max Chars", "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", - "agents.defaults.memorySearch.multimodal": "Memory Search Multimodal", - "agents.defaults.memorySearch.multimodal.enabled": "Enable Memory Search Multimodal", - "agents.defaults.memorySearch.multimodal.modalities": "Memory Search Multimodal Modalities", - "agents.defaults.memorySearch.multimodal.maxFileBytes": "Memory Search Multimodal Max File Bytes", "agents.defaults.memorySearch.experimental.sessionMemory": "Memory Search Session Index (Experimental)", "agents.defaults.memorySearch.provider": "Memory Search Provider", @@ -335,7 +336,6 @@ export const FIELD_LABELS: Record = { "agents.defaults.memorySearch.remote.batch.pollIntervalMs": "Remote Batch Poll Interval (ms)", "agents.defaults.memorySearch.remote.batch.timeoutMinutes": "Remote Batch Timeout (min)", "agents.defaults.memorySearch.model": "Memory Search Model", - "agents.defaults.memorySearch.outputDimensionality": "Memory Search Output Dimensionality", "agents.defaults.memorySearch.fallback": "Memory Search Fallback", "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", "agents.defaults.memorySearch.store.path": "Memory Search Index Path", @@ -724,12 +724,6 @@ export const FIELD_LABELS: Record = { "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", - "channels.telegram.execApprovals": "Telegram Exec Approvals", - "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", - "channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers", - "channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter", - "channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter", - "channels.telegram.execApprovals.target": "Telegram Exec Approval Target", "channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled", "channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)", "channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)", From 70ec16cfceceda30831fb93a677d3ab0a2571fb3 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:20 -0400 Subject: [PATCH 027/129] feat: integrate Cortex local memory into OpenClaw --- src/config/types.agent-defaults.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9124e4084d8..c2e5456bad2 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -8,6 +8,17 @@ import type { } from "./types.base.js"; import type { MemorySearchConfig } from "./types.tools.js"; +export type AgentCortexConfig = { + /** Enable Cortex-backed prompt context injection for this agent. */ + enabled?: boolean; + /** Optional Cortex graph path (absolute or relative to the agent workspace). */ + graphPath?: string; + /** Disclosure mode used when exporting Cortex context. */ + mode?: "full" | "professional" | "technical" | "minimal"; + /** Max characters exported into the system prompt. */ + maxChars?: number; +}; + export type AgentModelEntryConfig = { alias?: string; /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ @@ -183,6 +194,8 @@ export type AgentDefaultsConfig = { }; /** Vector memory search configuration (per-agent overrides supported). */ memorySearch?: MemorySearchConfig; + /** Optional Cortex-backed prompt context injection (per-agent overrides supported). */ + cortex?: AgentCortexConfig; /** Default thinking level when no /think directive is present. */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; /** Default verbose level when no /verbose directive is present. */ From 2c4dfd61d4ce2ee7d31096b74950ff3550dd67a7 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:21 -0400 Subject: [PATCH 028/129] feat: integrate Cortex local memory into OpenClaw --- src/config/types.agents.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..7e58ca06a34 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -1,5 +1,5 @@ import type { ChatType } from "../channels/chat-type.js"; -import type { AgentDefaultsConfig } from "./types.agent-defaults.js"; +import type { AgentCortexConfig, AgentDefaultsConfig } from "./types.agent-defaults.js"; import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js"; import type { HumanDelayConfig, IdentityConfig } from "./types.base.js"; import type { GroupChatConfig } from "./types.messages.js"; @@ -68,6 +68,7 @@ export type AgentConfig = { /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ skills?: string[]; memorySearch?: MemorySearchConfig; + cortex?: AgentCortexConfig; /** Human-like delay between block replies for this agent. */ humanDelay?: HumanDelayConfig; /** Optional per-agent heartbeat overrides. */ From 3612141d03e5f8903fa9eeeed11ae6ba6c573136 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:21 -0400 Subject: [PATCH 029/129] feat: integrate Cortex local memory into OpenClaw --- src/config/zod-schema.agent-defaults.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 242d6959729..9541aa3f474 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -51,6 +51,22 @@ export const AgentDefaultsSchema = z contextTokens: z.number().int().positive().optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(), memorySearch: MemorySearchSchema, + cortex: z + .object({ + enabled: z.boolean().optional(), + graphPath: z.string().optional(), + mode: z + .union([ + z.literal("full"), + z.literal("professional"), + z.literal("technical"), + z.literal("minimal"), + ]) + .optional(), + maxChars: z.number().int().positive().optional(), + }) + .strict() + .optional(), contextPruning: z .object({ mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(), From 7ac23cd186d77b28ffbffd558abf89d577aebb2a Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:22 -0400 Subject: [PATCH 030/129] feat: integrate Cortex local memory into OpenClaw --- src/config/zod-schema.agent-runtime.ts | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d5b9eeedb16..1e393e1559b 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -553,16 +553,6 @@ export const MemorySearchSchema = z enabled: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), extraPaths: z.array(z.string()).optional(), - multimodal: z - .object({ - enabled: z.boolean().optional(), - modalities: z - .array(z.union([z.literal("image"), z.literal("audio"), z.literal("all")])) - .optional(), - maxFileBytes: z.number().int().positive().optional(), - }) - .strict() - .optional(), experimental: z .object({ sessionMemory: z.boolean().optional(), @@ -609,7 +599,6 @@ export const MemorySearchSchema = z ]) .optional(), model: z.string().optional(), - outputDimensionality: z.number().int().positive().optional(), local: z .object({ modelPath: z.string().optional(), @@ -733,6 +722,22 @@ export const AgentEntrySchema = z model: AgentModelSchema.optional(), skills: z.array(z.string()).optional(), memorySearch: MemorySearchSchema, + cortex: z + .object({ + enabled: z.boolean().optional(), + graphPath: z.string().optional(), + mode: z + .union([ + z.literal("full"), + z.literal("professional"), + z.literal("technical"), + z.literal("minimal"), + ]) + .optional(), + maxChars: z.number().int().positive().optional(), + }) + .strict() + .optional(), humanDelay: HumanDelaySchema.optional(), heartbeat: HeartbeatSchema, identity: IdentitySchema, From baa142fd513c7a8a4b60b4cf55f5d1a7c111b57c Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:23 -0400 Subject: [PATCH 031/129] feat: integrate Cortex local memory into OpenClaw --- src/gateway/protocol/schema/snapshot.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index 98e31826045..6cad9202162 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -67,6 +67,20 @@ export const SnapshotSchema = Type.Object( channel: NonEmptyString, }), ), + cortex: Type.Optional( + Type.Object( + { + enabled: Type.Boolean(), + mode: Type.Optional(NonEmptyString), + graphPath: Type.Optional(NonEmptyString), + lastCaptureAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + lastCaptureReason: Type.Optional(Type.String()), + lastCaptureStored: Type.Optional(Type.Boolean()), + lastSyncPlatforms: Type.Optional(Type.Array(NonEmptyString)), + }, + { additionalProperties: false }, + ), + ), }, { additionalProperties: false }, ); From c1347ca2ac1e1346c9db1078f66986be6daa8bee Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:24 -0400 Subject: [PATCH 032/129] feat: integrate Cortex local memory into OpenClaw --- src/gateway/server/health-state.test.ts | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/gateway/server/health-state.test.ts diff --git a/src/gateway/server/health-state.test.ts b/src/gateway/server/health-state.test.ts new file mode 100644 index 00000000000..d6ebeaf1a94 --- /dev/null +++ b/src/gateway/server/health-state.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); +const createConfigIOMock = vi.hoisted(() => vi.fn()); +const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); +const resolveMainSessionKeyMock = vi.hoisted(() => vi.fn()); +const normalizeMainKeyMock = vi.hoisted(() => vi.fn()); +const listSystemPresenceMock = vi.hoisted(() => vi.fn()); +const resolveGatewayAuthMock = vi.hoisted(() => vi.fn()); +const getUpdateAvailableMock = vi.hoisted(() => vi.fn()); +const resolveAgentCortexConfigMock = vi.hoisted(() => vi.fn()); +const getLatestCortexCaptureHistoryEntrySyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../config/config.js", () => ({ + STATE_DIR: "/tmp/openclaw-state", + createConfigIO: createConfigIOMock, + loadConfig: loadConfigMock, +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: resolveDefaultAgentIdMock, +})); + +vi.mock("../../config/sessions.js", () => ({ + resolveMainSessionKey: resolveMainSessionKeyMock, +})); + +vi.mock("../../routing/session-key.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DEFAULT_ACCOUNT_ID: "default", + normalizeMainKey: normalizeMainKeyMock, + }; +}); + +vi.mock("../../infra/system-presence.js", () => ({ + listSystemPresence: listSystemPresenceMock, +})); + +vi.mock("../auth.js", () => ({ + resolveGatewayAuth: resolveGatewayAuthMock, +})); + +vi.mock("../../infra/update-startup.js", () => ({ + getUpdateAvailable: getUpdateAvailableMock, +})); + +vi.mock("../../agents/cortex.js", () => ({ + resolveAgentCortexConfig: resolveAgentCortexConfigMock, +})); + +vi.mock("../../agents/cortex-history.js", () => ({ + getLatestCortexCaptureHistoryEntrySync: getLatestCortexCaptureHistoryEntrySyncMock, +})); + +import { buildGatewaySnapshot } from "./health-state.js"; + +describe("buildGatewaySnapshot", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("includes Cortex snapshot details when the prompt bridge is enabled", () => { + loadConfigMock.mockReturnValue({ + session: { mainKey: "main", scope: "per-sender" }, + }); + createConfigIOMock.mockReturnValue({ configPath: "/tmp/openclaw/openclaw.json" }); + resolveDefaultAgentIdMock.mockReturnValue("main"); + resolveMainSessionKeyMock.mockReturnValue("agent:main:main"); + normalizeMainKeyMock.mockReturnValue("main"); + listSystemPresenceMock.mockReturnValue([]); + resolveGatewayAuthMock.mockReturnValue({ mode: "token" }); + getUpdateAvailableMock.mockReturnValue(undefined); + resolveAgentCortexConfigMock.mockReturnValue({ + enabled: true, + mode: "technical", + maxChars: 1500, + graphPath: ".cortex/context.json", + }); + getLatestCortexCaptureHistoryEntrySyncMock.mockReturnValue({ + agentId: "main", + captured: true, + score: 0.7, + reason: "high-signal memory candidate", + syncPlatforms: ["claude-code", "cursor", "copilot"], + timestamp: 1234, + }); + + const snapshot = buildGatewaySnapshot(); + + expect(snapshot.cortex).toEqual({ + enabled: true, + mode: "technical", + graphPath: ".cortex/context.json", + lastCaptureAtMs: 1234, + lastCaptureReason: "high-signal memory candidate", + lastCaptureStored: true, + lastSyncPlatforms: ["claude-code", "cursor", "copilot"], + }); + }); +}); From 16546b2c797d2a039306e08c137e27dcdb9f3b0e Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:25 -0400 Subject: [PATCH 033/129] feat: integrate Cortex local memory into OpenClaw --- src/gateway/server/health-state.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 0c14d6e0ad9..ee673426a33 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -1,4 +1,6 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { getLatestCortexCaptureHistoryEntrySync } from "../../agents/cortex-history.js"; +import { resolveAgentCortexConfig } from "../../agents/cortex.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { STATE_DIR, createConfigIO, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; @@ -18,6 +20,10 @@ export function buildGatewaySnapshot(): Snapshot { const cfg = loadConfig(); const configPath = createConfigIO().configPath; const defaultAgentId = resolveDefaultAgentId(cfg); + const cortex = resolveAgentCortexConfig(cfg, defaultAgentId); + const latestCortexCapture = cortex + ? getLatestCortexCaptureHistoryEntrySync({ agentId: defaultAgentId }) + : null; const mainKey = normalizeMainKey(cfg.session?.mainKey); const mainSessionKey = resolveMainSessionKey(cfg); const scope = cfg.session?.scope ?? "per-sender"; @@ -43,6 +49,17 @@ export function buildGatewaySnapshot(): Snapshot { }, authMode: auth.mode, updateAvailable, + cortex: cortex + ? { + enabled: true, + mode: cortex.mode, + graphPath: cortex.graphPath, + lastCaptureAtMs: latestCortexCapture?.timestamp, + lastCaptureReason: latestCortexCapture?.reason, + lastCaptureStored: latestCortexCapture?.captured, + lastSyncPlatforms: latestCortexCapture?.syncPlatforms, + } + : undefined, }; } From 731a7d82d02fd69a3a5ff6d0724e5017108555e3 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:26 -0400 Subject: [PATCH 034/129] feat: integrate Cortex local memory into OpenClaw --- src/memory/cortex-mode-overrides.test.ts | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/memory/cortex-mode-overrides.test.ts diff --git a/src/memory/cortex-mode-overrides.test.ts b/src/memory/cortex-mode-overrides.test.ts new file mode 100644 index 00000000000..fe959c94d02 --- /dev/null +++ b/src/memory/cortex-mode-overrides.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clearCortexModeOverride, + getCortexModeOverride, + setCortexModeOverride, +} from "./cortex-mode-overrides.js"; + +describe("cortex mode overrides", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + tempDirs.length = 0; + }); + + async function createStorePath() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-mode-")); + tempDirs.push(dir); + return path.join(dir, "cortex-mode-overrides.json"); + } + + it("prefers session overrides over channel overrides", async () => { + const pathname = await createStorePath(); + await setCortexModeOverride({ + pathname, + agentId: "main", + scope: "channel", + targetId: "slack", + mode: "professional", + }); + await setCortexModeOverride({ + pathname, + agentId: "main", + scope: "session", + targetId: "session-1", + mode: "minimal", + }); + + const resolved = await getCortexModeOverride({ + pathname, + agentId: "main", + sessionId: "session-1", + channelId: "slack", + }); + + expect(resolved?.mode).toBe("minimal"); + expect(resolved?.scope).toBe("session"); + }); + + it("can clear a stored override", async () => { + const pathname = await createStorePath(); + await setCortexModeOverride({ + pathname, + agentId: "main", + scope: "channel", + targetId: "telegram", + mode: "minimal", + }); + + const removed = await clearCortexModeOverride({ + pathname, + agentId: "main", + scope: "channel", + targetId: "telegram", + }); + + const resolved = await getCortexModeOverride({ + pathname, + agentId: "main", + channelId: "telegram", + }); + + expect(removed).toBe(true); + expect(resolved).toBeNull(); + }); +}); From 3f97d71891efa58b784618d9f9fc50452b3eed76 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:27 -0400 Subject: [PATCH 035/129] feat: integrate Cortex local memory into OpenClaw --- src/memory/cortex-mode-overrides.ts | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/memory/cortex-mode-overrides.ts diff --git a/src/memory/cortex-mode-overrides.ts b/src/memory/cortex-mode-overrides.ts new file mode 100644 index 00000000000..90e2f1a0cdf --- /dev/null +++ b/src/memory/cortex-mode-overrides.ts @@ -0,0 +1,113 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import type { CortexPolicy } from "./cortex.js"; + +export type CortexModeScope = "session" | "channel"; + +export type CortexModeOverride = { + agentId: string; + scope: CortexModeScope; + targetId: string; + mode: CortexPolicy; + updatedAt: string; +}; + +type CortexModeOverrideStore = { + session: Record; + channel: Record; +}; + +function buildKey(agentId: string, targetId: string): string { + return `${agentId}:${targetId}`; +} + +export function resolveCortexModeOverridesPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), "cortex-mode-overrides.json"); +} + +async function readStore( + pathname = resolveCortexModeOverridesPath(), +): Promise { + try { + const raw = await fs.readFile(pathname, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { + session: parsed.session ?? {}, + channel: parsed.channel ?? {}, + }; + } catch { + return { + session: {}, + channel: {}, + }; + } +} + +async function writeStore( + store: CortexModeOverrideStore, + pathname = resolveCortexModeOverridesPath(), +): Promise { + await fs.mkdir(path.dirname(pathname), { recursive: true }); + await fs.writeFile(pathname, JSON.stringify(store, null, 2)); +} + +export async function getCortexModeOverride(params: { + agentId: string; + sessionId?: string; + channelId?: string; + 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)]; + if (channel) { + return channel; + } + } + return null; +} + +export async function setCortexModeOverride(params: { + agentId: string; + scope: CortexModeScope; + targetId: string; + mode: CortexPolicy; + pathname?: string; +}): Promise { + const store = await readStore(params.pathname); + const next: CortexModeOverride = { + agentId: params.agentId, + scope: params.scope, + targetId: params.targetId, + mode: params.mode, + updatedAt: new Date().toISOString(), + }; + store[params.scope][buildKey(params.agentId, params.targetId)] = next; + await writeStore(store, params.pathname); + return next; +} + +export async function clearCortexModeOverride(params: { + agentId: string; + scope: CortexModeScope; + targetId: string; + pathname?: string; +}): Promise { + const store = await readStore(params.pathname); + const key = buildKey(params.agentId, params.targetId); + if (!store[params.scope][key]) { + return false; + } + delete store[params.scope][key]; + await writeStore(store, params.pathname); + return true; +} From b3be74d1dd739c1061086b77a36f98ee8cc45de2 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:28 -0400 Subject: [PATCH 036/129] feat: integrate Cortex local memory into OpenClaw --- src/memory/cortex.test.ts | 220 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/memory/cortex.test.ts diff --git a/src/memory/cortex.test.ts b/src/memory/cortex.test.ts new file mode 100644 index 00000000000..8cc7ce9252f --- /dev/null +++ b/src/memory/cortex.test.ts @@ -0,0 +1,220 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { runExec } = vi.hoisted(() => ({ + runExec: vi.fn(), +})); + +vi.mock("../process/exec.js", () => ({ + runExec, +})); + +import { + getCortexStatus, + ingestCortexMemoryFromText, + listCortexMemoryConflicts, + previewCortexContext, + resolveCortexGraphPath, + resolveCortexMemoryConflict, + syncCortexCodingContext, +} from "./cortex.js"; + +afterEach(() => { + vi.restoreAllMocks(); + runExec.mockReset(); +}); + +describe("cortex bridge", () => { + it("resolves the default graph path inside the workspace", () => { + expect(resolveCortexGraphPath("/tmp/workspace")).toBe("/tmp/workspace/.cortex/context.json"); + }); + + it("resolves relative graph overrides against the workspace", () => { + expect(resolveCortexGraphPath("/tmp/workspace", "graphs/main.json")).toBe( + path.normalize("/tmp/workspace/graphs/main.json"), + ); + }); + + it("reports availability and graph presence", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-status-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, "{}", "utf8"); + runExec.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + const status = await getCortexStatus({ workspaceDir: tmpDir }); + + expect(status.available).toBe(true); + expect(status.graphExists).toBe(true); + expect(status.graphPath).toBe(graphPath); + }); + + it("surfaces Cortex CLI errors in status", async () => { + runExec.mockRejectedValueOnce(new Error("spawn cortex ENOENT")); + + const status = await getCortexStatus({ workspaceDir: "/tmp/workspace" }); + + expect(status.available).toBe(false); + expect(status.error).toContain("spawn cortex ENOENT"); + }); + + it("exports preview context", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-preview-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, "{}", "utf8"); + runExec + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ stdout: "## Cortex Context\n- Python\n", stderr: "" }); + + const preview = await previewCortexContext({ + workspaceDir: tmpDir, + policy: "technical", + maxChars: 500, + }); + + expect(preview.graphPath).toBe(graphPath); + expect(preview.policy).toBe("technical"); + expect(preview.maxChars).toBe(500); + expect(preview.context).toBe("## Cortex Context\n- Python"); + }); + + it("fails preview when graph is missing", async () => { + runExec.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await expect(previewCortexContext({ workspaceDir: "/tmp/workspace" })).rejects.toThrow( + "Cortex graph not found", + ); + }); + + it("lists memory conflicts from Cortex JSON output", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-conflicts-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, "{}", "utf8"); + runExec.mockResolvedValueOnce({ stdout: "", stderr: "" }).mockResolvedValueOnce({ + stdout: JSON.stringify({ + conflicts: [ + { + id: "conf_1", + type: "temporal_flip", + severity: 0.91, + summary: "Hiring status changed", + }, + ], + }), + stderr: "", + }); + + const conflicts = await listCortexMemoryConflicts({ workspaceDir: tmpDir }); + + expect(conflicts).toEqual([ + { + id: "conf_1", + type: "temporal_flip", + severity: 0.91, + summary: "Hiring status changed", + nodeLabel: undefined, + oldValue: undefined, + newValue: undefined, + }, + ]); + }); + + it("resolves memory conflicts from Cortex JSON output", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-resolve-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, "{}", "utf8"); + runExec.mockResolvedValueOnce({ stdout: "", stderr: "" }).mockResolvedValueOnce({ + stdout: JSON.stringify({ + status: "ok", + conflict_id: "conf_1", + nodes_updated: 1, + nodes_removed: 1, + commit_id: "ver_123", + }), + stderr: "", + }); + + const result = await resolveCortexMemoryConflict({ + workspaceDir: tmpDir, + conflictId: "conf_1", + action: "accept-new", + }); + + expect(result).toEqual({ + status: "ok", + conflictId: "conf_1", + action: "accept-new", + nodesUpdated: 1, + nodesRemoved: 1, + commitId: "ver_123", + message: undefined, + }); + }); + + it("syncs coding context to default coding platforms", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-sync-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, "{}", "utf8"); + runExec + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ stdout: "", stderr: "" }); + + const result = await syncCortexCodingContext({ + workspaceDir: tmpDir, + policy: "technical", + }); + + expect(result.policy).toBe("technical"); + expect(result.platforms).toEqual(["claude-code", "cursor", "copilot"]); + expect(runExec).toHaveBeenLastCalledWith( + "cortex", + [ + "context-write", + graphPath, + "--platforms", + "claude-code", + "cursor", + "copilot", + "--policy", + "technical", + ], + expect.any(Object), + ); + }); + + it("ingests high-signal text into the Cortex graph with merge", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cortex-ingest-")); + const graphPath = path.join(tmpDir, ".cortex", "context.json"); + runExec + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ stdout: "", stderr: "" }); + + const result = await ingestCortexMemoryFromText({ + workspaceDir: tmpDir, + event: { + actor: "user", + text: "I prefer concise answers and I am focused on fundraising this quarter.", + sessionId: "session-1", + channelId: "channel-1", + agentId: "main", + }, + }); + + expect(result).toEqual({ + workspaceDir: tmpDir, + graphPath, + stored: true, + }); + expect(runExec).toHaveBeenLastCalledWith( + "cortex", + expect.arrayContaining(["extract", "-o", graphPath, "--merge", graphPath]), + expect.any(Object), + ); + }); +}); From cf98c3f2094b06d0d1135a14820e6c8d30297bf9 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:28 -0400 Subject: [PATCH 037/129] feat: integrate Cortex local memory into OpenClaw --- src/memory/cortex.ts | 406 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 src/memory/cortex.ts diff --git a/src/memory/cortex.ts b/src/memory/cortex.ts new file mode 100644 index 00000000000..e37ed20bb52 --- /dev/null +++ b/src/memory/cortex.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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> }>(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 { + 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>(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 { + 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 { + 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(() => {}); + } +} From f07efd4d70ec32da717dd258913ba4c337861909 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:29 -0400 Subject: [PATCH 038/129] feat: integrate Cortex local memory into OpenClaw --- ui/src/ui/app-render.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1214bcc93a6..220cc5807a0 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -372,6 +372,18 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onOpenCortexPreview: () => { + state.tab = "chat"; + state.chatMessage = "/cortex preview"; + }, + onOpenCortexConflicts: () => { + state.tab = "chat"; + state.chatMessage = "/cortex conflicts"; + }, + onOpenCortexSync: () => { + state.tab = "chat"; + state.chatMessage = "/cortex sync coding"; + }, }) : nothing } @@ -1081,7 +1093,6 @@ export function renderApp(state: AppViewState) { models: state.debugModels, heartbeat: state.debugHeartbeat, eventLog: state.eventLog, - methods: (state.hello?.features?.methods ?? []).toSorted(), callMethod: state.debugCallMethod, callParams: state.debugCallParams, callResult: state.debugCallResult, From 9343f3b15bf6695cba2de5f98c390113aae937cd Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:30 -0400 Subject: [PATCH 039/129] feat: integrate Cortex local memory into OpenClaw --- ui/src/ui/views/agents-utils.test.ts | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index eea9bec03c8..e9a57242ecf 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -98,3 +98,63 @@ describe("sortLocaleStrings", () => { expect(sortLocaleStrings(new Set(["beta", "alpha"]))).toEqual(["alpha", "beta"]); }); }); + +describe("renderOverview", () => { + it("includes Cortex status details from the gateway snapshot", async () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, + }); + const { renderOverview } = await import("./overview.ts"); + const template = renderOverview({ + connected: true, + hello: { + snapshot: { + cortex: { + enabled: true, + mode: "technical", + graphPath: ".cortex/context.json", + lastCaptureAtMs: Date.now() - 5_000, + lastCaptureReason: "high-signal memory candidate", + lastCaptureStored: true, + lastSyncPlatforms: ["claude-code", "cursor"], + }, + }, + } as never, + settings: { + gatewayUrl: "ws://127.0.0.1:18789", + token: "", + sessionKey: "main", + }, + password: "", + lastError: null, + lastErrorCode: null, + presenceCount: 1, + sessionsCount: 1, + cronEnabled: null, + cronNext: null, + lastChannelsRefresh: null, + onSettingsChange: () => {}, + onPasswordChange: () => {}, + onSessionKeyChange: () => {}, + onConnect: () => {}, + onRefresh: () => {}, + onOpenCortexPreview: () => {}, + onOpenCortexConflicts: () => {}, + onOpenCortexSync: () => {}, + }) as { strings: TemplateStringsArray; values: unknown[] }; + + expect(template.strings.join("")).toContain("Cortex"); + const renderedValues = JSON.stringify(template.values); + + expect(renderedValues).toContain("Preview in chat"); + expect(renderedValues).toContain("Conflicts in chat"); + expect(renderedValues).toContain("Sync coding"); + expect(renderedValues).toContain("technical · stored"); + expect(renderedValues).toContain("high-signal memory candidate"); + }); +}); From 804f03bfb4d9821ac58e68edf4f547f3b26f2224 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:41:31 -0400 Subject: [PATCH 040/129] feat: integrate Cortex local memory into OpenClaw --- ui/src/ui/views/overview.ts | 49 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6ebcb884ff6..1f74135a578 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -25,6 +25,9 @@ export type OverviewProps = { onSessionKeyChange: (next: string) => void; onConnect: () => void; onRefresh: () => void; + onOpenCortexPreview: () => void; + onOpenCortexConflicts: () => void; + onOpenCortexSync: () => void; }; export function renderOverview(props: OverviewProps) { @@ -33,6 +36,15 @@ export function renderOverview(props: OverviewProps) { uptimeMs?: number; policy?: { tickIntervalMs?: number }; authMode?: "none" | "token" | "password" | "trusted-proxy"; + cortex?: { + enabled?: boolean; + mode?: string; + graphPath?: string; + lastCaptureAtMs?: number; + lastCaptureReason?: string; + lastCaptureStored?: boolean; + lastSyncPlatforms?: string[]; + }; } | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); @@ -41,6 +53,15 @@ export function renderOverview(props: OverviewProps) { : t("common.na"); const authMode = snapshot?.authMode; const isTrustedProxy = authMode === "trusted-proxy"; + const cortex = snapshot?.cortex; + const cortexSummary = !cortex?.enabled + ? t("common.disabled") + : `${cortex.mode ?? t("common.enabled")} · ${cortex.lastCaptureStored ? "stored" : "idle"}`; + const cortexDetail = !cortex?.enabled + ? "Prompt bridge not enabled" + : `Last capture ${ + cortex.lastCaptureAtMs ? formatRelativeTimestamp(cortex.lastCaptureAtMs) : t("common.na") + }${cortex.lastCaptureReason ? ` · ${cortex.lastCaptureReason}` : ""}`; const pairingHint = (() => { if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) { @@ -205,11 +226,7 @@ export function renderOverview(props: OverviewProps) { .value=${props.settings.gatewayUrl} @input=${(e: Event) => { const v = (e.target as HTMLInputElement).value; - props.onSettingsChange({ - ...props.settings, - gatewayUrl: v, - token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "", - }); + props.onSettingsChange({ ...props.settings, gatewayUrl: v }); }} placeholder="ws://100.x.y.z:18789" /> @@ -303,6 +320,28 @@ export function renderOverview(props: OverviewProps) { ${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : t("common.na")} +
+
Cortex
+
${cortexSummary}
+
${cortexDetail}
+ ${ + cortex?.enabled + ? html` +
+ + + +
+ ` + : "" + } +
${ props.lastError From 78030b17516df217f8902367245a0efad539f98e Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:54:21 -0400 Subject: [PATCH 041/129] fix: resolve attempt runner merge conflict --- src/agents/pi-embedded-runner/run/attempt.ts | 815 ++++++++++++++++++- 1 file changed, 772 insertions(+), 43 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index fd5d4033e0a..50fce712a61 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -11,7 +11,10 @@ import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; -import { ensureGlobalUndiciStreamTimeouts } from "../../../infra/net/undici-global-dispatcher.js"; +import { + ensureGlobalUndiciEnvProxyDispatcher, + ensureGlobalUndiciStreamTimeouts, +} from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { @@ -125,6 +128,7 @@ import { installToolResultContextGuard } from "../tool-result-context-guard.js"; import { splitSdkTools } from "../tool-split.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; +import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; import { selectCompactionTimeoutSnapshot, shouldFlagCompactionTimeout, @@ -145,6 +149,186 @@ type PromptBuildHookRunner = { ) => Promise; }; +const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt"; +const SESSIONS_YIELD_CONTEXT_CUSTOM_TYPE = "openclaw.sessions_yield"; + +// Persist a hidden context reminder so the next turn knows why the runner stopped. +function buildSessionsYieldContextMessage(message: string): string { + return `${message}\n\n[Context: The previous turn ended intentionally via sessions_yield while waiting for a follow-up event.]`; +} + +// Return a synthetic aborted response so pi-agent-core unwinds without a real provider call. +function createYieldAbortedResponse(model: { api?: string; provider?: string; id?: string }): { + [Symbol.asyncIterator]: () => AsyncGenerator; + result: () => Promise<{ + role: "assistant"; + content: Array<{ type: "text"; text: string }>; + stopReason: "aborted"; + api: string; + provider: string; + model: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + }; + timestamp: number; + }>; +} { + const message = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "" }], + stopReason: "aborted" as const, + api: model.api ?? "", + provider: model.provider ?? "", + model: model.id ?? "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + timestamp: Date.now(), + }; + return { + async *[Symbol.asyncIterator]() {}, + result: async () => message, + }; +} + +// Queue a hidden steering message so pi-agent-core skips any remaining tool calls. +function queueSessionsYieldInterruptMessage(activeSession: { + agent: { steer: (message: AgentMessage) => void }; +}) { + activeSession.agent.steer({ + role: "custom", + customType: SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + content: "[sessions_yield interrupt]", + display: false, + details: { source: "sessions_yield" }, + timestamp: Date.now(), + }); +} + +// Append the caller-provided yield payload as a hidden session message once the run is idle. +async function persistSessionsYieldContextMessage( + activeSession: { + sendCustomMessage: ( + message: { + customType: string; + content: string; + display: boolean; + details?: Record; + }, + options?: { triggerTurn?: boolean }, + ) => Promise; + }, + message: string, +) { + await activeSession.sendCustomMessage( + { + customType: SESSIONS_YIELD_CONTEXT_CUSTOM_TYPE, + content: buildSessionsYieldContextMessage(message), + display: false, + details: { source: "sessions_yield", message }, + }, + { triggerTurn: false }, + ); +} + +// Remove the synthetic yield interrupt + aborted assistant entry from the live transcript. +function stripSessionsYieldArtifacts(activeSession: { + messages: AgentMessage[]; + agent: { replaceMessages: (messages: AgentMessage[]) => void }; + sessionManager?: unknown; +}) { + const strippedMessages = activeSession.messages.slice(); + while (strippedMessages.length > 0) { + const last = strippedMessages.at(-1) as + | AgentMessage + | { role?: string; customType?: string; stopReason?: string }; + if (last?.role === "assistant" && "stopReason" in last && last.stopReason === "aborted") { + strippedMessages.pop(); + continue; + } + if ( + last?.role === "custom" && + "customType" in last && + last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE + ) { + strippedMessages.pop(); + continue; + } + break; + } + if (strippedMessages.length !== activeSession.messages.length) { + activeSession.agent.replaceMessages(strippedMessages); + } + + const sessionManager = activeSession.sessionManager as + | { + fileEntries?: Array<{ + type?: string; + id?: string; + parentId?: string | null; + message?: { role?: string; stopReason?: string }; + customType?: string; + }>; + byId?: Map; + leafId?: string | null; + _rewriteFile?: () => void; + } + | undefined; + const fileEntries = sessionManager?.fileEntries; + const byId = sessionManager?.byId; + if (!fileEntries || !byId) { + return; + } + + let changed = false; + while (fileEntries.length > 1) { + const last = fileEntries.at(-1); + if (!last || last.type === "session") { + break; + } + const isYieldAbortAssistant = + last.type === "message" && + last.message?.role === "assistant" && + last.message?.stopReason === "aborted"; + const isYieldInterruptMessage = + last.type === "custom_message" && last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE; + if (!isYieldAbortAssistant && !isYieldInterruptMessage) { + break; + } + fileEntries.pop(); + if (last.id) { + byId.delete(last.id); + } + sessionManager.leafId = last.parentId ?? null; + changed = true; + } + if (changed) { + sessionManager._rewriteFile?.(); + } +} + export function isOllamaCompatProvider(model: { provider?: string; baseUrl?: string; @@ -230,32 +414,83 @@ export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: num ...options, onPayload: (payload: unknown) => { if (!payload || typeof payload !== "object") { - options?.onPayload?.(payload); - return; + return options?.onPayload?.(payload, model); } const payloadRecord = payload as Record; if (!payloadRecord.options || typeof payloadRecord.options !== "object") { payloadRecord.options = {}; } (payloadRecord.options as Record).num_ctx = numCtx; - options?.onPayload?.(payload); + return options?.onPayload?.(payload, model); }, }); } -function normalizeToolCallNameForDispatch(rawName: string, allowedToolNames?: Set): string { - const trimmed = rawName.trim(); - if (!trimmed) { - // Keep whitespace-only placeholders unchanged so they do not collapse to - // empty names (which can later surface as toolName="" loops). +function resolveCaseInsensitiveAllowedToolName( + rawName: string, + allowedToolNames?: Set, +): string | null { + if (!allowedToolNames || allowedToolNames.size === 0) { + return null; + } + const folded = rawName.toLowerCase(); + let caseInsensitiveMatch: string | null = null; + for (const name of allowedToolNames) { + if (name.toLowerCase() !== folded) { + continue; + } + if (caseInsensitiveMatch && caseInsensitiveMatch !== name) { + return null; + } + caseInsensitiveMatch = name; + } + return caseInsensitiveMatch; +} + +function resolveExactAllowedToolName( + rawName: string, + allowedToolNames?: Set, +): string | null { + if (!allowedToolNames || allowedToolNames.size === 0) { + return null; + } + if (allowedToolNames.has(rawName)) { return rawName; } - if (!allowedToolNames || allowedToolNames.size === 0) { - return trimmed; + const normalized = normalizeToolName(rawName); + if (allowedToolNames.has(normalized)) { + return normalized; + } + return ( + resolveCaseInsensitiveAllowedToolName(rawName, allowedToolNames) ?? + resolveCaseInsensitiveAllowedToolName(normalized, allowedToolNames) + ); +} + +function buildStructuredToolNameCandidates(rawName: string): string[] { + const trimmed = rawName.trim(); + if (!trimmed) { + return []; } - const candidateNames = new Set([trimmed, normalizeToolName(trimmed)]); + const candidates: string[] = []; + const seen = new Set(); + const addCandidate = (value: string) => { + const candidate = value.trim(); + if (!candidate || seen.has(candidate)) { + return; + } + seen.add(candidate); + candidates.push(candidate); + }; + + addCandidate(trimmed); + addCandidate(normalizeToolName(trimmed)); + const normalizedDelimiter = trimmed.replace(/\//g, "."); + addCandidate(normalizedDelimiter); + addCandidate(normalizeToolName(normalizedDelimiter)); + const segments = normalizedDelimiter .split(".") .map((segment) => segment.trim()) @@ -263,11 +498,23 @@ function normalizeToolCallNameForDispatch(rawName: string, allowedToolNames?: Se if (segments.length > 1) { for (let index = 1; index < segments.length; index += 1) { const suffix = segments.slice(index).join("."); - candidateNames.add(suffix); - candidateNames.add(normalizeToolName(suffix)); + addCandidate(suffix); + addCandidate(normalizeToolName(suffix)); } } + return candidates; +} + +function resolveStructuredAllowedToolName( + rawName: string, + allowedToolNames?: Set, +): string | null { + if (!allowedToolNames || allowedToolNames.size === 0) { + return null; + } + + const candidateNames = buildStructuredToolNameCandidates(rawName); for (const candidate of candidateNames) { if (allowedToolNames.has(candidate)) { return candidate; @@ -275,23 +522,116 @@ function normalizeToolCallNameForDispatch(rawName: string, allowedToolNames?: Se } for (const candidate of candidateNames) { - const folded = candidate.toLowerCase(); - let caseInsensitiveMatch: string | null = null; - for (const name of allowedToolNames) { - if (name.toLowerCase() !== folded) { - continue; - } - if (caseInsensitiveMatch && caseInsensitiveMatch !== name) { - return candidate; - } - caseInsensitiveMatch = name; - } + const caseInsensitiveMatch = resolveCaseInsensitiveAllowedToolName(candidate, allowedToolNames); if (caseInsensitiveMatch) { return caseInsensitiveMatch; } } - return trimmed; + return null; +} + +function inferToolNameFromToolCallId( + rawId: string | undefined, + allowedToolNames?: Set, +): string | null { + if (!rawId || !allowedToolNames || allowedToolNames.size === 0) { + return null; + } + const id = rawId.trim(); + if (!id) { + return null; + } + + const candidateTokens = new Set(); + const addToken = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + candidateTokens.add(trimmed); + candidateTokens.add(trimmed.replace(/[:._/-]\d+$/, "")); + candidateTokens.add(trimmed.replace(/\d+$/, "")); + + const normalizedDelimiter = trimmed.replace(/\//g, "."); + candidateTokens.add(normalizedDelimiter); + candidateTokens.add(normalizedDelimiter.replace(/[:._-]\d+$/, "")); + candidateTokens.add(normalizedDelimiter.replace(/\d+$/, "")); + + for (const prefixPattern of [/^functions?[._-]?/i, /^tools?[._-]?/i]) { + const stripped = normalizedDelimiter.replace(prefixPattern, ""); + if (stripped !== normalizedDelimiter) { + candidateTokens.add(stripped); + candidateTokens.add(stripped.replace(/[:._-]\d+$/, "")); + candidateTokens.add(stripped.replace(/\d+$/, "")); + } + } + }; + + const preColon = id.split(":")[0] ?? id; + for (const seed of [id, preColon]) { + addToken(seed); + } + + let singleMatch: string | null = null; + for (const candidate of candidateTokens) { + const matched = resolveStructuredAllowedToolName(candidate, allowedToolNames); + if (!matched) { + continue; + } + if (singleMatch && singleMatch !== matched) { + return null; + } + singleMatch = matched; + } + + return singleMatch; +} + +function looksLikeMalformedToolNameCounter(rawName: string): boolean { + const normalizedDelimiter = rawName.trim().replace(/\//g, "."); + return ( + /^(?:functions?|tools?)[._-]?/i.test(normalizedDelimiter) && + /(?:[:._-]\d+|\d+)$/.test(normalizedDelimiter) + ); +} + +function normalizeToolCallNameForDispatch( + rawName: string, + allowedToolNames?: Set, + rawToolCallId?: string, +): string { + const trimmed = rawName.trim(); + if (!trimmed) { + // Keep whitespace-only placeholders unchanged unless we can safely infer + // a canonical name from toolCallId and allowlist. + return inferToolNameFromToolCallId(rawToolCallId, allowedToolNames) ?? rawName; + } + if (!allowedToolNames || allowedToolNames.size === 0) { + return trimmed; + } + + const exact = resolveExactAllowedToolName(trimmed, allowedToolNames); + if (exact) { + return exact; + } + // Some providers put malformed toolCallId-like strings into `name` + // itself (for example `functionsread3`). Recover conservatively from the + // name token before consulting the separate id so explicit names like + // `someOtherTool` are preserved. + const inferredFromName = inferToolNameFromToolCallId(trimmed, allowedToolNames); + if (inferredFromName) { + return inferredFromName; + } + + // If the explicit name looks like a provider-mangled tool-call id with a + // numeric suffix, fail closed when inference is ambiguous instead of routing + // to whichever structured candidate happens to match. + if (looksLikeMalformedToolNameCounter(trimmed)) { + return trimmed; + } + + return resolveStructuredAllowedToolName(trimmed, allowedToolNames) ?? trimmed; } function isToolCallBlockType(type: unknown): boolean { @@ -367,13 +707,21 @@ function trimWhitespaceFromToolCallNamesInMessage( if (!block || typeof block !== "object") { continue; } - const typedBlock = block as { type?: unknown; name?: unknown }; - if (!isToolCallBlockType(typedBlock.type) || typeof typedBlock.name !== "string") { + const typedBlock = block as { type?: unknown; name?: unknown; id?: unknown }; + if (!isToolCallBlockType(typedBlock.type)) { continue; } - const normalized = normalizeToolCallNameForDispatch(typedBlock.name, allowedToolNames); - if (normalized !== typedBlock.name) { - typedBlock.name = normalized; + const rawId = typeof typedBlock.id === "string" ? typedBlock.id : undefined; + if (typeof typedBlock.name === "string") { + const normalized = normalizeToolCallNameForDispatch(typedBlock.name, allowedToolNames, rawId); + if (normalized !== typedBlock.name) { + typedBlock.name = normalized; + } + continue; + } + const inferred = inferToolNameFromToolCallId(rawId, allowedToolNames); + if (inferred) { + typedBlock.name = inferred; } } normalizeToolCallIdsInMessage(message); @@ -434,6 +782,281 @@ export function wrapStreamFnTrimToolCallNames( }; } +function extractBalancedJsonPrefix(raw: string): string | null { + let start = 0; + while (start < raw.length && /\s/.test(raw[start] ?? "")) { + start += 1; + } + const startChar = raw[start]; + if (startChar !== "{" && startChar !== "[") { + return null; + } + + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === undefined) { + break; + } + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{" || char === "[") { + depth += 1; + continue; + } + if (char === "}" || char === "]") { + depth -= 1; + if (depth === 0) { + return raw.slice(start, i + 1); + } + } + } + return null; +} + +const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000; +const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3; +const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/; + +function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean { + if (/[}\]]/.test(delta)) { + return true; + } + const trimmedDelta = delta.trim(); + return ( + trimmedDelta.length > 0 && + trimmedDelta.length <= MAX_TOOLCALL_REPAIR_TRAILING_CHARS && + /[}\]]/.test(partialJson) + ); +} + +type ToolCallArgumentRepair = { + args: Record; + trailingSuffix: string; +}; + +function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair | undefined { + if (!raw.trim()) { + return undefined; + } + try { + JSON.parse(raw); + return undefined; + } catch { + const jsonPrefix = extractBalancedJsonPrefix(raw); + if (!jsonPrefix) { + return undefined; + } + const suffix = raw.slice(raw.indexOf(jsonPrefix) + jsonPrefix.length).trim(); + if ( + suffix.length === 0 || + suffix.length > MAX_TOOLCALL_REPAIR_TRAILING_CHARS || + !TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix) + ) { + return undefined; + } + try { + const parsed = JSON.parse(jsonPrefix) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? { args: parsed as Record, trailingSuffix: suffix } + : undefined; + } catch { + return undefined; + } + } +} + +function repairToolCallArgumentsInMessage( + message: unknown, + contentIndex: number, + repairedArgs: Record, +): void { + if (!message || typeof message !== "object") { + return; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return; + } + const block = content[contentIndex]; + if (!block || typeof block !== "object") { + return; + } + const typedBlock = block as { type?: unknown; arguments?: unknown }; + if (!isToolCallBlockType(typedBlock.type)) { + return; + } + typedBlock.arguments = repairedArgs; +} + +function clearToolCallArgumentsInMessage(message: unknown, contentIndex: number): void { + if (!message || typeof message !== "object") { + return; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return; + } + const block = content[contentIndex]; + if (!block || typeof block !== "object") { + return; + } + const typedBlock = block as { type?: unknown; arguments?: unknown }; + if (!isToolCallBlockType(typedBlock.type)) { + return; + } + typedBlock.arguments = {}; +} + +function repairMalformedToolCallArgumentsInMessage( + message: unknown, + repairedArgsByIndex: Map>, +): void { + if (!message || typeof message !== "object") { + return; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return; + } + for (const [index, repairedArgs] of repairedArgsByIndex.entries()) { + repairToolCallArgumentsInMessage(message, index, repairedArgs); + } +} + +function wrapStreamRepairMalformedToolCallArguments( + stream: ReturnType, +): ReturnType { + const partialJsonByIndex = new Map(); + const repairedArgsByIndex = new Map>(); + const disabledIndices = new Set(); + const loggedRepairIndices = new Set(); + const originalResult = stream.result.bind(stream); + stream.result = async () => { + const message = await originalResult(); + repairMalformedToolCallArgumentsInMessage(message, repairedArgsByIndex); + partialJsonByIndex.clear(); + repairedArgsByIndex.clear(); + disabledIndices.clear(); + loggedRepairIndices.clear(); + return message; + }; + + const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); + (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = + function () { + const iterator = originalAsyncIterator(); + return { + async next() { + const result = await iterator.next(); + if (!result.done && result.value && typeof result.value === "object") { + const event = result.value as { + type?: unknown; + contentIndex?: unknown; + delta?: unknown; + partial?: unknown; + message?: unknown; + toolCall?: unknown; + }; + if ( + typeof event.contentIndex === "number" && + Number.isInteger(event.contentIndex) && + event.type === "toolcall_delta" && + typeof event.delta === "string" + ) { + if (disabledIndices.has(event.contentIndex)) { + return result; + } + const nextPartialJson = + (partialJsonByIndex.get(event.contentIndex) ?? "") + event.delta; + if (nextPartialJson.length > MAX_TOOLCALL_REPAIR_BUFFER_CHARS) { + partialJsonByIndex.delete(event.contentIndex); + repairedArgsByIndex.delete(event.contentIndex); + disabledIndices.add(event.contentIndex); + return result; + } + partialJsonByIndex.set(event.contentIndex, nextPartialJson); + if (shouldAttemptMalformedToolCallRepair(nextPartialJson, event.delta)) { + const repair = tryParseMalformedToolCallArguments(nextPartialJson); + if (repair) { + repairedArgsByIndex.set(event.contentIndex, repair.args); + repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repair.args); + repairToolCallArgumentsInMessage(event.message, event.contentIndex, repair.args); + if (!loggedRepairIndices.has(event.contentIndex)) { + loggedRepairIndices.add(event.contentIndex); + log.warn( + `repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`, + ); + } + } else { + repairedArgsByIndex.delete(event.contentIndex); + clearToolCallArgumentsInMessage(event.partial, event.contentIndex); + clearToolCallArgumentsInMessage(event.message, event.contentIndex); + } + } + } + if ( + typeof event.contentIndex === "number" && + Number.isInteger(event.contentIndex) && + event.type === "toolcall_end" + ) { + const repairedArgs = repairedArgsByIndex.get(event.contentIndex); + if (repairedArgs) { + if (event.toolCall && typeof event.toolCall === "object") { + (event.toolCall as { arguments?: unknown }).arguments = repairedArgs; + } + repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repairedArgs); + repairToolCallArgumentsInMessage(event.message, event.contentIndex, repairedArgs); + } + partialJsonByIndex.delete(event.contentIndex); + disabledIndices.delete(event.contentIndex); + loggedRepairIndices.delete(event.contentIndex); + } + } + return result; + }, + async return(value?: unknown) { + return iterator.return?.(value) ?? { done: true as const, value: undefined }; + }, + async throw(error?: unknown) { + return iterator.throw?.(error) ?? { done: true as const, value: undefined }; + }, + }; + }; + + return stream; +} + +export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn): StreamFn { + return (model, context, options) => { + const maybeStream = baseFn(model, context, options); + if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) { + return Promise.resolve(maybeStream).then((stream) => + wrapStreamRepairMalformedToolCallArguments(stream), + ); + } + return wrapStreamRepairMalformedToolCallArguments(maybeStream); + }; +} + +function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean { + return normalizeProviderId(provider ?? "") === "kimi-coding"; +} + // --------------------------------------------------------------------------- // xAI / Grok: decode HTML entities in tool call arguments // --------------------------------------------------------------------------- @@ -750,6 +1373,9 @@ export async function runEmbeddedAttempt( const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); const runAbortController = new AbortController(); + // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the + // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. + ensureGlobalUndiciEnvProxyDispatcher(); ensureGlobalUndiciStreamTimeouts(); log.debug( @@ -841,6 +1467,13 @@ export async function runEmbeddedAttempt( config: params.config, sessionAgentId, }); + // Track sessions_yield tool invocation (callback pattern, like clientToolCallDetected) + let yieldDetected = false; + let yieldMessage: string | null = null; + // Late-binding reference so onYield can abort the session (declared after tool creation) + let abortSessionForYield: (() => void) | null = null; + let queueYieldInterruptForSession: (() => void) | null = null; + let yieldAbortSettled: Promise | null = null; // Check if the model supports native image input const modelHasVision = params.model.input?.includes("image") ?? false; const toolsRaw = params.disableTools @@ -870,6 +1503,10 @@ export async function runEmbeddedAttempt( runId: params.runId, agentDir, workspaceDir: effectiveWorkspace, + // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points + // at the sandbox copy. Spawned subagents should inherit the real workspace instead. + spawnWorkspaceDir: + sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined, config: params.config, abortSignal: runAbortController.signal, modelProvider: params.model.provider, @@ -885,6 +1522,13 @@ export async function runEmbeddedAttempt( requireExplicitMessageTarget: params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), disableMessageTool: params.disableMessageTool, + onYield: (message) => { + yieldDetected = true; + yieldMessage = message; + queueYieldInterruptForSession?.(); + runAbortController.abort("sessions_yield"); + abortSessionForYield?.(); + }, }); const toolsEnabled = supportsModelTools(params.model); const tools = sanitizeToolsForGoogle({ @@ -1098,6 +1742,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.bootstrap({ sessionId: params.sessionId, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, }); } catch (bootstrapErr) { @@ -1195,6 +1840,12 @@ export async function runEmbeddedAttempt( throw new Error("Embedded agent session missing"); } const activeSession = session; + abortSessionForYield = () => { + yieldAbortSettled = Promise.resolve(activeSession.abort()); + }; + queueYieldInterruptForSession = () => { + queueSessionsYieldInterruptMessage(activeSession); + }; removeToolResultContextGuard = installToolResultContextGuard({ agent: activeSession.agent, contextWindowTokens: Math.max( @@ -1366,6 +2017,17 @@ export async function runEmbeddedAttempt( }; } + const innerStreamFn = activeSession.agent.streamFn; + activeSession.agent.streamFn = (model, context, options) => { + const signal = runAbortController.signal as AbortSignal & { reason?: unknown }; + if (yieldDetected && signal.aborted && signal.reason === "sessions_yield") { + return createYieldAbortedResponse(model) as unknown as Awaited< + ReturnType + >; + } + return innerStreamFn(model, context, options); + }; + // Some models emit tool names with surrounding whitespace (e.g. " read "). // pi-agent-core dispatches tool calls with exact string matching, so normalize // names on the live response stream before tool execution. @@ -1374,6 +2036,15 @@ export async function runEmbeddedAttempt( allowedToolNames, ); + if ( + params.model.api === "anthropic-messages" && + shouldRepairMalformedAnthropicToolCallArguments(params.provider) + ) { + activeSession.agent.streamFn = wrapStreamFnRepairMalformedToolCallArguments( + activeSession.agent.streamFn, + ); + } + if (isXaiProvider(params.provider, params.modelId)) { activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments( activeSession.agent.streamFn, @@ -1424,6 +2095,7 @@ export async function runEmbeddedAttempt( try { const assembled = await params.contextEngine.assemble({ sessionId: params.sessionId, + sessionKey: params.sessionKey, messages: activeSession.messages, tokenBudget: params.contextTokenBudget, }); @@ -1457,6 +2129,7 @@ export async function runEmbeddedAttempt( } let aborted = Boolean(params.abortSignal?.aborted); + let yieldAborted = false; let timedOut = false; let timedOutDuringCompaction = false; const getAbortReason = (signal: AbortSignal): unknown => @@ -1539,6 +2212,7 @@ export async function runEmbeddedAttempt( toolMetas, unsubscribe, waitForCompactionRetry, + isCompactionInFlight, getMessagingToolSentTexts, getMessagingToolSentMediaUrls, getMessagingToolSentTargets, @@ -1648,15 +2322,15 @@ export async function runEmbeddedAttempt( hookRunner, legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult, }); - const cortexPromptContext = await resolveAgentCortexPromptContext({ - cfg: params.config, - agentId: sessionAgentId, - workspaceDir: params.workspaceDir, - promptMode, - sessionId: params.sessionId, - channelId: params.messageChannel ?? params.messageProvider ?? undefined, - }); { + const cortexPromptContext = await resolveAgentCortexPromptContext({ + cfg: params.config, + agentId: sessionAgentId, + workspaceDir: params.workspaceDir, + promptMode, + sessionId: params.sessionId, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, + }); if (hookResult?.prependContext) { effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`; log.debug( @@ -1785,6 +2459,8 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { @@ -1800,8 +2476,29 @@ export async function runEmbeddedAttempt( await abortable(activeSession.prompt(effectivePrompt)); } } catch (err) { - promptError = err; - promptErrorSource = "prompt"; + // Yield-triggered abort is intentional — treat as clean stop, not error. + // Check the abort reason to distinguish from external aborts (timeout, user cancel) + // that may race after yieldDetected is set. + yieldAborted = + yieldDetected && + isRunnerAbortError(err) && + err instanceof Error && + err.cause === "sessions_yield"; + if (yieldAborted) { + aborted = false; + // Ensure the session abort has fully settled before proceeding. + if (yieldAbortSettled) { + // eslint-disable-next-line @typescript-eslint/await-thenable -- abort() returns Promise per AgentSession.d.ts + await yieldAbortSettled; + } + stripSessionsYieldArtifacts(activeSession); + if (yieldMessage) { + await persistSessionsYieldContextMessage(activeSession, yieldMessage); + } + } else { + promptError = err; + promptErrorSource = "prompt"; + } } finally { log.debug( `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, @@ -1817,6 +2514,7 @@ export async function runEmbeddedAttempt( // Only trust snapshot if compaction wasn't running before or after capture const preCompactionSnapshot = wasCompactingBefore || wasCompactingAfter ? null : snapshot; const preCompactionSessionId = activeSession.sessionId; + const COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS = 60_000; try { // Flush buffered block replies before waiting for compaction so the @@ -1827,7 +2525,25 @@ export async function runEmbeddedAttempt( await params.onBlockReplyFlush(); } - await abortable(waitForCompactionRetry()); + // Skip compaction wait when yield aborted the run — the signal is + // already tripped and abortable() would immediately reject. + const compactionRetryWait = yieldAborted + ? { timedOut: false } + : await waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable, + aggregateTimeoutMs: COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS, + isCompactionStillInFlight: isCompactionInFlight, + }); + if (compactionRetryWait.timedOut) { + timedOutDuringCompaction = true; + if (!isProbeSession) { + log.warn( + `compaction retry aggregate timeout (${COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS}ms): ` + + `proceeding with pre-compaction state runId=${params.runId} sessionId=${params.sessionId}`, + ); + } + } } catch (err) { if (isRunnerAbortError(err)) { if (!promptError) { @@ -1844,14 +2560,19 @@ export async function runEmbeddedAttempt( } } + // Check if ANY compaction occurred during the entire attempt (prompt + retry). + // Using a cumulative count (> 0) instead of a delta check avoids missing + // compactions that complete during activeSession.prompt() before the delta + // baseline is sampled. const compactionOccurredThisAttempt = getCompactionCount() > 0; - // Append cache-TTL timestamp AFTER prompt + compaction retry completes. // Previously this was before the prompt, which caused a custom entry to be // inserted between compaction and the next prompt — breaking the // prepareCompaction() guard that checks the last entry type, leading to // double-compaction. See: https://github.com/openclaw/openclaw/issues/9282 // Skip when timed out during compaction — session state may be inconsistent. + // Also skip when compaction ran this attempt — appending a custom entry + // after compaction would break the guard again. See: #28491 if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) { const shouldTrackCacheTtl = params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" && @@ -1912,6 +2633,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.afterTurn({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, messages: messagesSnapshot, prePromptMessageCount, @@ -1929,6 +2651,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.ingestBatch({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, messages: newMessages, }); } catch (ingestErr) { @@ -1939,6 +2662,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.ingest({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, message: msg, }); } catch (ingestErr) { @@ -1978,6 +2702,8 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { @@ -2038,6 +2764,8 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, }, ) .catch((err) => { @@ -2071,6 +2799,7 @@ export async function runEmbeddedAttempt( compactionCount: getCompactionCount(), // Client tool call detected (OpenResponses hosted tools) clientToolCall: clientToolCallDetected ?? undefined, + yieldDetected: yieldDetected || undefined, }; } finally { // Always tear down the session (and release the lock) before we leave this attempt. From 30b5cbd18133f8b744a1a05dca4864d9acb971e7 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:54:22 -0400 Subject: [PATCH 042/129] fix: align cortex command session id handling --- src/auto-reply/reply/commands-cortex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto-reply/reply/commands-cortex.ts b/src/auto-reply/reply/commands-cortex.ts index a1abc2f4929..705effe802f 100644 --- a/src/auto-reply/reply/commands-cortex.ts +++ b/src/auto-reply/reply/commands-cortex.ts @@ -53,7 +53,7 @@ function parseResolveAction(value?: string): CortexMemoryResolveAction | null { } function resolveActiveSessionId(params: HandleCommandsParams): string | undefined { - return params.sessionEntry?.sessionId ?? params.ctx.SessionId; + return params.sessionEntry?.sessionId; } function resolveActiveChannelId(params: HandleCommandsParams): string { From dac4df18ce389b2715b4e534c9b6946994c05b39 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:57:02 -0400 Subject: [PATCH 043/129] fix: resolve remaining PR test merge conflict --- src/auto-reply/reply/commands.test.ts | 531 +++++++++++++++----------- 1 file changed, 309 insertions(+), 222 deletions(-) diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 4190014c1e7..4056a0bd124 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -171,7 +171,6 @@ vi.mock("../../acp/persistent-bindings.js", async () => { }; }); -import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { buildCommandContext, handleCommands } from "./commands.js"; @@ -224,7 +223,6 @@ beforeEach(() => { params.nativeChannelId ?? params.to ?? params.channel ?? "unknown", ); }); - describe("handleCommands gating", () => { it("blocks gated commands when disabled or not elevated-allowlisted", async () => { const cases = typedCases<{ @@ -269,6 +267,9 @@ describe("handleCommands gating", () => { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, }) as OpenClawConfig, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/config is disabled", }, { @@ -279,6 +280,9 @@ describe("handleCommands gating", () => { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, }) as OpenClawConfig, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/debug is disabled", }, { @@ -311,6 +315,9 @@ describe("handleCommands gating", () => { channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; }, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/config is disabled", }, { @@ -327,6 +334,9 @@ describe("handleCommands gating", () => { channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; }, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/debug is disabled", }, ]); @@ -378,6 +388,122 @@ describe("/approve command", () => { ); }); + it("accepts Telegram command mentions for /approve", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@bot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("rejects Telegram /approve mentions targeting a different bot", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("targets a different Telegram bot"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("surfaces unknown or expired approval id errors", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("unknown or expired approval id"); + }); + + it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects Telegram /approve from non-approvers", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("not authorized to approve"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("rejects gateway clients without approvals scope", async () => { const cfg = { commands: { text: true }, @@ -1004,6 +1130,36 @@ describe("extractMessageText", () => { }); }); +describe("handleCommands /config owner gating", () => { + it("blocks /config show from authorized non-owner senders", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/config show", cfg); + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("keeps /config show working for owners", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackreaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", cfg); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackreaction"); + }); +}); + describe("handleCommands /config configWrites gating", () => { it("blocks /config set when channel config writes are disabled", async () => { const cfg = { @@ -1011,11 +1167,60 @@ describe("handleCommands /config configWrites gating", () => { channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, } as OpenClawConfig; const params = buildParams('/config set messages.ackReaction=":)"', cfg); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config writes are disabled"); }); + it("blocks /config set when the target account disables writes", async () => { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + const cfg = { + commands: { config: true, text: true }, + channels: { + telegram: { + configWrites: true, + accounts: { + work: { configWrites: false, enabled: true }, + }, + }, + }, + } as OpenClawConfig; + const params = buildPolicyParams( + "/config set channels.telegram.accounts.work.enabled=false", + cfg, + { + AccountId: "default", + Provider: "telegram", + Surface: "telegram", + }, + ); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); + expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + }); + + it("blocks ambiguous channel-root /config writes from channel commands", async () => { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + const cfg = { + commands: { config: true, text: true }, + channels: { telegram: { configWrites: true } }, + } as OpenClawConfig; + const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, { + Provider: "telegram", + Surface: "telegram", + }); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain( + "cannot replace channels, channel roots, or accounts collections", + ); + expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + }); + it("blocks /config set from gateway clients without operator.admin", async () => { const cfg = { commands: { config: true, text: true }, @@ -1026,6 +1231,7 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("requires operator.admin"); @@ -1045,6 +1251,7 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = false; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config messages.ackreaction"); @@ -1068,11 +1275,82 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write", "operator.admin"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(writeConfigFileMock).toHaveBeenCalledOnce(); expect(result.reply?.text).toContain("Config updated"); }); + + it("keeps /config set working for gateway operator.admin on protected account paths", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }, + ); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; + expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }); +}); + +describe("handleCommands /debug owner gating", () => { + it("blocks /debug show from authorized non-owner senders", async () => { + const cfg = { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/debug show", cfg); + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("keeps /debug show working for owners", async () => { + const cfg = { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/debug show", cfg); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Debug overrides"); + }); }); describe("handleCommands bash alias", () => { @@ -1225,6 +1503,35 @@ describe("handleCommands /allowlist", () => { }); }); + it("blocks config-targeted /allowlist edits when the target account disables writes", async () => { + const previousWriteCount = writeConfigFileMock.mock.calls.length; + const cfg = { + commands: { text: true, config: true }, + channels: { + telegram: { + configWrites: true, + accounts: { + work: { configWrites: false, allowFrom: ["123"] }, + }, + }, + }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(cfg), + }); + const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, { + AccountId: "default", + Provider: "telegram", + Surface: "telegram", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); + expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + }); + it("removes default-account entries from scoped and legacy pairing stores", async () => { removeChannelAllowFromStoreEntryMock .mockResolvedValueOnce({ @@ -1571,226 +1878,6 @@ describe("handleCommands hooks", () => { }); }); -describe("handleCommands ACP-bound /new and /reset", () => { - const discordChannelId = "1478836151241412759"; - const buildDiscordBoundConfig = (): OpenClawConfig => - ({ - commands: { text: true }, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { - kind: "channel", - id: discordChannelId, - }, - }, - acp: { - mode: "persistent", - }, - }, - ], - channels: { - discord: { - allowFrom: ["*"], - guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } }, - }, - }, - }) as OpenClawConfig; - - const buildDiscordBoundParams = (body: string) => { - const params = buildParams(body, buildDiscordBoundConfig(), { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - To: discordChannelId, - OriginatingTo: discordChannelId, - SessionKey: "agent:main:acp:binding:discord:default:feedface", - }); - params.sessionKey = "agent:main:acp:binding:discord:default:feedface"; - return params; - }; - - it("handles /new as ACP in-place reset for bound conversations", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const result = await handleCommands(buildDiscordBoundParams("/new")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "new", - }); - }); - - it("continues with trailing prompt text after successful ACP-bound /new", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const params = buildDiscordBoundParams("/new continue with deployment"); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - const mutableCtx = params.ctx as Record; - expect(mutableCtx.BodyStripped).toBe("continue with deployment"); - expect(mutableCtx.CommandBody).toBe("continue with deployment"); - expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - }); - - it("handles /reset failures without falling back to normal session reset flow", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset failed"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "reset", - }); - }); - - it("does not emit reset hooks when ACP reset fails", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("keeps existing /new behavior for non-ACP sessions", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const result = await handleCommands(buildParams("/new", cfg)); - - expect(result.shouldContinue).toBe(true); - expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled(); - }); - - it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => { - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset unavailable"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: configuredAcpSessionKey, - reason: "new", - }); - }); - - it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const fallbackEntry = { - sessionId: "fallback-session-id", - sessionFile: "/tmp/fallback-session.jsonl", - } as SessionEntry; - const configuredEntry = { - sessionId: "configured-acp-session-id", - sessionFile: "/tmp/configured-acp-session.jsonl", - } as SessionEntry; - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - params.sessionEntry = fallbackEntry; - params.previousSessionEntry = fallbackEntry; - params.sessionStore = { - [fallbackSessionKey]: fallbackEntry, - [configuredAcpSessionKey]: configuredEntry, - }; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(hookSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "command", - action: "new", - sessionKey: configuredAcpSessionKey, - context: expect.objectContaining({ - sessionEntry: configuredEntry, - previousSessionEntry: configuredEntry, - }), - }), - ); - hookSpy.mockRestore(); - }); - - it("uses active ACP command target when conversation binding context is missing", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface"; - const params = buildParams( - "/new", - { - commands: { text: true }, - channels: { - discord: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig, - { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - }, - ); - params.sessionKey = "discord:slash:12345"; - params.ctx.SessionKey = "discord:slash:12345"; - params.ctx.CommandSource = "native"; - params.ctx.CommandTargetSessionKey = activeAcpTarget; - params.ctx.To = "user:12345"; - params.ctx.OriginatingTo = "user:12345"; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: activeAcpTarget, - reason: "new", - }); - }); -}); - describe("handleCommands context", () => { it("returns expected details for /context commands", async () => { const cfg = { From 2d10d725244a812a4d9f9670044c815a44a0b949 Mon Sep 17 00:00:00 2001 From: Marc J Saint-jour <82672745+Junebugg1214@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:57:02 -0400 Subject: [PATCH 044/129] fix: resolve control UI app render merge conflict --- ui/src/ui/app-render.ts | 2119 +++++++++++++++++++++++++++------------ 1 file changed, 1452 insertions(+), 667 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 220cc5807a0..a8e26a05039 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,9 +1,17 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; +import { + renderChatControls, + renderChatSessionSelect, + renderTab, + renderTopbarThemeModeToggle, +} from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; @@ -16,6 +24,7 @@ import { ensureAgentConfigEntry, findAgentConfigEntryIndex, loadConfig, + openConfigFile, runUpdate, saveConfig, updateConfigFormValue, @@ -65,6 +74,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; @@ -75,23 +85,53 @@ import { resolveModelPrimary, sortLocaleStrings, } from "./views/agents-utils.ts"; -import { renderAgents } from "./views/agents.ts"; -import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; -import { renderCron } from "./views/cron.ts"; -import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; -import { renderInstances } from "./views/instances.ts"; -import { renderLogs } from "./views/logs.ts"; -import { renderNodes } from "./views/nodes.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderOverview } from "./views/overview.ts"; -import { renderSessions } from "./views/sessions.ts"; -import { renderSkills } from "./views/skills.ts"; -const AVATAR_DATA_RE = /^data:/i; -const AVATAR_HTTP_RE = /^https?:\/\//i; +// Lazy-loaded view modules – deferred so the initial bundle stays small. +// Each loader resolves once; subsequent calls return the cached module. +type LazyState = { mod: T | null; promise: Promise | null }; + +let _pendingUpdate: (() => void) | undefined; + +function createLazy(loader: () => Promise): () => T | null { + const s: LazyState = { mod: null, promise: null }; + return () => { + if (s.mod) { + return s.mod; + } + if (!s.promise) { + s.promise = loader().then((m) => { + s.mod = m; + _pendingUpdate?.(); + return m; + }); + } + return null; + }; +} + +const lazyAgents = createLazy(() => import("./views/agents.ts")); +const lazyChannels = createLazy(() => import("./views/channels.ts")); +const lazyCron = createLazy(() => import("./views/cron.ts")); +const lazyDebug = createLazy(() => import("./views/debug.ts")); +const lazyInstances = createLazy(() => import("./views/instances.ts")); +const lazyLogs = createLazy(() => import("./views/logs.ts")); +const lazyNodes = createLazy(() => import("./views/nodes.ts")); +const lazySessions = createLazy(() => import("./views/sessions.ts")); +const lazySkills = createLazy(() => import("./views/skills.ts")); + +function lazyRender(getter: () => M | null, render: (mod: M) => unknown) { + const mod = getter(); + return mod ? render(mod) : nothing; +} + +const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; const CRON_TIMEZONE_SUGGESTIONS = [ "UTC", @@ -130,6 +170,126 @@ function uniquePreserveOrder(values: string[]): string[] { return output; } +type DismissedUpdateBanner = { + latestVersion: string; + channel: string | null; + dismissedAtMs: number; +}; + +function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { + try { + const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.latestVersion !== "string") { + return null; + } + return { + latestVersion: parsed.latestVersion, + channel: typeof parsed.channel === "string" ? parsed.channel : null, + dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(), + }; + } catch { + return null; + } +} + +function isUpdateBannerDismissed(updateAvailable: unknown): boolean { + const dismissed = loadDismissedUpdateBanner(); + if (!dismissed) { + return false; + } + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + const channel = info && typeof info.channel === "string" ? info.channel : null; + return Boolean( + latestVersion && dismissed.latestVersion === latestVersion && dismissed.channel === channel, + ); +} + +function dismissUpdateBanner(updateAvailable: unknown) { + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + if (!latestVersion) { + return; + } + const channel = info && typeof info.channel === "string" ? info.channel : null; + const payload: DismissedUpdateBanner = { + latestVersion, + channel, + dismissedAtMs: Date.now(), + }; + try { + localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + } catch { + // ignore + } +} + +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; +const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const; +const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; +const AUTOMATION_SECTION_KEYS = [ + "commands", + "hooks", + "bindings", + "cron", + "approvals", + "plugins", +] as const; +const INFRASTRUCTURE_SECTION_KEYS = [ + "gateway", + "web", + "browser", + "nodeHost", + "canvasHost", + "discovery", + "media", +] as const; +const AI_AGENTS_SECTION_KEYS = [ + "agents", + "models", + "skills", + "tools", + "memory", + "session", +] as const; +type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number]; +type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number]; +type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; +type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; +type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; + +const NAV_WIDTH_MIN = 200; +const NAV_WIDTH_MAX = 400; + +function handleNavResizeStart(e: MouseEvent, state: AppViewState) { + e.preventDefault(); + const startX = e.clientX; + const startWidth = state.settings.navWidth; + + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); + state.applySettings({ ...state.settings, navWidth: next }); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -147,16 +307,22 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { - const openClawVersion = - (typeof state.hello?.server?.version === "string" && state.hello.server.version.trim()) || - state.updateAvailable?.currentVersion || - t("common.na"); - const availableUpdate = - state.updateAvailable && - state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion - ? state.updateAvailable - : null; - const versionStatusClass = availableUpdate ? "warn" : "ok"; + const updatableState = state as AppViewState & { requestUpdate?: () => void }; + const requestHostUpdate = + typeof updatableState.requestUpdate === "function" + ? () => updatableState.requestUpdate?.() + : undefined; + _pendingUpdate = requestHostUpdate; + + // Gate: require successful gateway connection before showing the dashboard. + // The gateway URL confirmation overlay is always rendered so URL-param flows still work. + if (!state.connected) { + return html` + ${renderLoginGate(state)} + ${renderGatewayUrlConfirmation(state)} + `; + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -234,77 +400,116 @@ export function renderApp(state: AppViewState) { : rawDeliveryToSuggestions; return html` -
+ ${renderCommandPalette({ + open: state.paletteOpen, + query: state.paletteQuery, + activeIndex: state.paletteActiveIndex, + onToggle: () => { + state.paletteOpen = !state.paletteOpen; + }, + onQueryChange: (q) => { + state.paletteQuery = q; + }, + onActiveIndexChange: (i) => { + state.paletteActiveIndex = i; + }, + onNavigate: (tab) => { + state.setTab(tab as import("./navigation.ts").Tab); + }, + onSlashCommand: (cmd) => { + state.setTab("chat" as import("./navigation.ts").Tab); + state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `; + }, + })} +
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.version")} - ${openClawVersion} -
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} -
- ${renderThemeToggle(state)} + ${renderTopbarThemeModeToggle(state)}
-