From e955d574b237a68632ebaa899d37928270d0171d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 21:58:21 +0000 Subject: [PATCH] refactor: move memory tooling into memory-core extension --- extensions/memory-core/index.test.ts | 31 +- extensions/memory-core/index.ts | 18 +- .../tools/memory-tool.citations.test.ts | 153 ---- src/agents/tools/memory-tool.runtime.ts | 3 - src/agents/tools/memory-tool.test-helpers.ts | 63 -- src/agents/tools/memory-tool.test.ts | 43 - src/agents/tools/memory-tool.ts | 320 -------- src/cli/argv.test.ts | 1 - src/cli/argv.ts | 3 - ...command-secret-resolution.coverage.test.ts | 2 +- src/cli/command-secret-targets.test.ts | 11 - src/cli/command-secret-targets.ts | 8 - src/cli/memory-cli.runtime.ts | 747 ------------------ src/cli/memory-cli.test.ts | 584 -------------- src/cli/memory-cli.ts | 85 -- src/cli/memory-cli.types.ts | 14 - src/cli/program.smoke.test.ts | 3 +- src/cli/program/command-registry.test.ts | 1 - src/cli/program/command-registry.ts | 13 - src/cli/program/core-command-descriptors.ts | 5 - src/cli/program/root-help.ts | 6 + src/cli/program/routes.test.ts | 4 - src/cli/program/routes.ts | 18 - src/infra/tsdown-config.test.ts | 1 - src/plugin-sdk/memory-core.ts | 23 + src/plugins/cli.ts | 49 +- src/plugins/loader.ts | 1 - src/plugins/registry.ts | 19 +- src/plugins/runtime/index.ts | 2 - src/plugins/runtime/runtime-tools.ts | 11 - src/plugins/runtime/types-core.ts | 5 - src/plugins/types.ts | 14 +- .../helpers/extensions/plugin-runtime-mock.ts | 6 - test/helpers/memory-tool-manager-mock.ts | 2 +- tsdown.config.ts | 4 - 35 files changed, 136 insertions(+), 2137 deletions(-) delete mode 100644 src/agents/tools/memory-tool.citations.test.ts delete mode 100644 src/agents/tools/memory-tool.runtime.ts delete mode 100644 src/agents/tools/memory-tool.test-helpers.ts delete mode 100644 src/agents/tools/memory-tool.test.ts delete mode 100644 src/agents/tools/memory-tool.ts delete mode 100644 src/cli/memory-cli.runtime.ts delete mode 100644 src/cli/memory-cli.test.ts delete mode 100644 src/cli/memory-cli.ts delete mode 100644 src/cli/memory-cli.types.ts delete mode 100644 src/plugins/runtime/runtime-tools.ts diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index 044b3923292..a811830838b 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -1,3 +1,4 @@ +import { Command } from "commander"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core"; import { describe, expect, it, vi } from "vitest"; import plugin, { @@ -52,25 +53,16 @@ describe("buildPromptSection", () => { }); describe("plugin registration", () => { - it("registers memory tools independently so one unavailable tool does not suppress the other", () => { + it("registers memory tools + cli through extension-local modules", () => { const registerTool = vi.fn(); const registerMemoryPromptSection = vi.fn(); const registerMemoryFlushPlan = vi.fn(); const registerCli = vi.fn(); - const searchTool = { name: "memory_search" }; - const getTool = null; const api = { registerTool, registerMemoryPromptSection, registerMemoryFlushPlan, registerCli, - runtime: { - tools: { - createMemorySearchTool: vi.fn(() => searchTool), - createMemoryGetTool: vi.fn(() => getTool), - registerMemoryCli: vi.fn(), - }, - }, }; plugin.register(api as never); @@ -80,15 +72,30 @@ describe("plugin registration", () => { expect(registerTool).toHaveBeenCalledTimes(2); expect(registerTool.mock.calls[0]?.[1]).toEqual({ names: ["memory_search"] }); expect(registerTool.mock.calls[1]?.[1]).toEqual({ names: ["memory_get"] }); + expect(registerCli).toHaveBeenCalledWith(expect.any(Function), { + descriptors: [ + { + name: "memory", + description: "Search, inspect, and reindex memory files", + hasSubcommands: true, + }, + ], + }); const searchFactory = registerTool.mock.calls[0]?.[0] as | ((ctx: unknown) => unknown) | undefined; const getFactory = registerTool.mock.calls[1]?.[0] as ((ctx: unknown) => unknown) | undefined; + const cliRegistrar = registerCli.mock.calls[0]?.[0] as + | ((ctx: { program: unknown }) => void) + | undefined; const ctx = { config: { plugins: {} }, sessionKey: "agent:main:slack:dm:u123" }; + const program = new Command(); - expect(searchFactory?.(ctx)).toBe(searchTool); - expect(getFactory?.(ctx)).toBeNull(); + expect((searchFactory?.(ctx) as { name?: string } | null)?.name).toBe("memory_search"); + expect((getFactory?.(ctx) as { name?: string } | null)?.name).toBe("memory_get"); + expect(() => cliRegistrar?.({ program } as never)).not.toThrow(); + expect(program.commands.map((command) => command.name())).toContain("memory"); }); }); diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 2ee526daad3..17ed707d1ad 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -10,6 +10,8 @@ import { SILENT_REPLY_TOKEN, } from "openclaw/plugin-sdk/memory-core"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { registerMemoryCli } from "./src/cli.js"; +import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js"; export const buildPromptSection: MemoryPromptSectionBuilder = ({ availableTools, @@ -190,7 +192,7 @@ export default definePluginEntry({ api.registerTool( (ctx) => - api.runtime.tools.createMemorySearchTool({ + createMemorySearchTool({ config: ctx.config, agentSessionKey: ctx.sessionKey, }), @@ -199,7 +201,7 @@ export default definePluginEntry({ api.registerTool( (ctx) => - api.runtime.tools.createMemoryGetTool({ + createMemoryGetTool({ config: ctx.config, agentSessionKey: ctx.sessionKey, }), @@ -208,9 +210,17 @@ export default definePluginEntry({ api.registerCli( ({ program }) => { - api.runtime.tools.registerMemoryCli(program); + registerMemoryCli(program); + }, + { + descriptors: [ + { + name: "memory", + description: "Search, inspect, and reindex memory files", + hasSubcommands: true, + }, + ], }, - { commands: ["memory"] }, ); }, }); diff --git a/src/agents/tools/memory-tool.citations.test.ts b/src/agents/tools/memory-tool.citations.test.ts deleted file mode 100644 index 3315ac991d9..00000000000 --- a/src/agents/tools/memory-tool.citations.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - getMemorySearchManagerMockCalls, - getReadAgentMemoryFileMockCalls, - resetMemoryToolMockState, - setMemoryBackend, - setMemoryReadFileImpl, - setMemorySearchImpl, - type MemoryReadParams, -} from "../../../test/helpers/memory-tool-manager-mock.js"; -import { - asOpenClawConfig, - createAutoCitationsMemorySearchTool, - createDefaultMemoryToolConfig, - createMemoryGetToolOrThrow, - createMemorySearchToolOrThrow, - expectUnavailableMemorySearchDetails, -} from "./memory-tool.test-helpers.js"; - -beforeEach(() => { - resetMemoryToolMockState({ - backend: "builtin", - searchImpl: async () => [ - { - path: "MEMORY.md", - startLine: 5, - endLine: 7, - score: 0.9, - snippet: "@@ -5,3 @@\nAssistant: noted", - source: "memory" as const, - }, - ], - readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }), - }); -}); - -describe("memory search citations", () => { - it("appends source information when citations are enabled", async () => { - setMemoryBackend("builtin"); - const cfg = asOpenClawConfig({ - memory: { citations: "on" }, - agents: { list: [{ id: "main", default: true }] }, - }); - const tool = createMemorySearchToolOrThrow({ config: cfg }); - const result = await tool.execute("call_citations_on", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/); - expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7"); - }); - - it("leaves snippet untouched when citations are off", async () => { - setMemoryBackend("builtin"); - const cfg = asOpenClawConfig({ - memory: { citations: "off" }, - agents: { list: [{ id: "main", default: true }] }, - }); - const tool = createMemorySearchToolOrThrow({ config: cfg }); - const result = await tool.execute("call_citations_off", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); - expect(details.results[0]?.citation).toBeUndefined(); - }); - - it("clamps decorated snippets to qmd injected budget", async () => { - setMemoryBackend("qmd"); - const cfg = asOpenClawConfig({ - memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } }, - agents: { list: [{ id: "main", default: true }] }, - }); - const tool = createMemorySearchToolOrThrow({ config: cfg }); - const result = await tool.execute("call_citations_qmd", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20); - }); - - it("honors auto mode for direct chats", async () => { - setMemoryBackend("builtin"); - const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123"); - const result = await tool.execute("auto_mode_direct", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source:/); - }); - - it("suppresses citations for auto mode in group chats", async () => { - setMemoryBackend("builtin"); - const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123"); - const result = await tool.execute("auto_mode_group", { query: "notes" }); - const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); - }); -}); - -describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { - setMemorySearchImpl(async () => { - throw new Error("openai embeddings failed: 429 insufficient_quota"); - }); - - const cfg = createDefaultMemoryToolConfig(); - const tool = createMemorySearchToolOrThrow({ config: cfg }); - - const result = await tool.execute("call_1", { query: "hello" }); - expectUnavailableMemorySearchDetails(result.details, { - error: "openai embeddings failed: 429 insufficient_quota", - warning: "Memory search is unavailable because the embedding provider quota is exhausted.", - action: "Top up or switch embedding provider, then retry memory_search.", - }); - }); - - it("does not throw when memory_get fails", async () => { - setMemoryReadFileImpl(async (_params: MemoryReadParams) => { - throw new Error("path required"); - }); - - const tool = createMemoryGetToolOrThrow(); - - const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); - expect(result.details).toEqual({ - path: "memory/NOPE.md", - text: "", - disabled: true, - error: "path required", - }); - }); - - it("returns empty text without error when file does not exist (ENOENT)", async () => { - setMemoryReadFileImpl(async (_params: MemoryReadParams) => { - return { text: "", path: "memory/2026-02-19.md" }; - }); - - const tool = createMemoryGetToolOrThrow(); - - const result = await tool.execute("call_enoent", { path: "memory/2026-02-19.md" }); - expect(result.details).toEqual({ - text: "", - path: "memory/2026-02-19.md", - }); - }); - - it("uses the builtin direct memory file path for memory_get", async () => { - setMemoryBackend("builtin"); - const tool = createMemoryGetToolOrThrow(); - - const result = await tool.execute("call_builtin_fast_path", { path: "memory/2026-02-19.md" }); - - expect(result.details).toEqual({ - text: "", - path: "memory/2026-02-19.md", - }); - expect(getReadAgentMemoryFileMockCalls()).toBe(1); - expect(getMemorySearchManagerMockCalls()).toBe(0); - }); -}); diff --git a/src/agents/tools/memory-tool.runtime.ts b/src/agents/tools/memory-tool.runtime.ts deleted file mode 100644 index d0f70c09146..00000000000 --- a/src/agents/tools/memory-tool.runtime.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { resolveMemoryBackendConfig } from "../../memory/backend-config.js"; -export { getMemorySearchManager } from "../../memory/index.js"; -export { readAgentMemoryFile } from "../../memory/read-file.js"; diff --git a/src/agents/tools/memory-tool.test-helpers.ts b/src/agents/tools/memory-tool.test-helpers.ts deleted file mode 100644 index 9a1d0e455f3..00000000000 --- a/src/agents/tools/memory-tool.test-helpers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; - -export function asOpenClawConfig(config: Partial): OpenClawConfig { - return config as OpenClawConfig; -} - -export function createDefaultMemoryToolConfig(): OpenClawConfig { - return asOpenClawConfig({ agents: { list: [{ id: "main", default: true }] } }); -} - -export function createMemorySearchToolOrThrow(params?: { - config?: OpenClawConfig; - agentSessionKey?: string; -}) { - const tool = createMemorySearchTool({ - config: params?.config ?? createDefaultMemoryToolConfig(), - ...(params?.agentSessionKey ? { agentSessionKey: params.agentSessionKey } : {}), - }); - if (!tool) { - throw new Error("tool missing"); - } - return tool; -} - -export function createMemoryGetToolOrThrow( - config: OpenClawConfig = createDefaultMemoryToolConfig(), -) { - const tool = createMemoryGetTool({ config }); - if (!tool) { - throw new Error("tool missing"); - } - return tool; -} - -export function createAutoCitationsMemorySearchTool(agentSessionKey: string) { - return createMemorySearchToolOrThrow({ - config: asOpenClawConfig({ - memory: { citations: "auto" }, - agents: { list: [{ id: "main", default: true }] }, - }), - agentSessionKey, - }); -} - -export function expectUnavailableMemorySearchDetails( - details: unknown, - params: { - error: string; - warning: string; - action: string; - }, -) { - expect(details).toEqual({ - results: [], - disabled: true, - unavailable: true, - error: params.error, - warning: params.warning, - action: params.action, - }); -} diff --git a/src/agents/tools/memory-tool.test.ts b/src/agents/tools/memory-tool.test.ts deleted file mode 100644 index e8764bd9f46..00000000000 --- a/src/agents/tools/memory-tool.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { beforeEach, describe, it } from "vitest"; -import { - resetMemoryToolMockState, - setMemorySearchImpl, -} from "../../../test/helpers/memory-tool-manager-mock.js"; -import { - createMemorySearchToolOrThrow, - expectUnavailableMemorySearchDetails, -} from "./memory-tool.test-helpers.js"; - -describe("memory_search unavailable payloads", () => { - beforeEach(() => { - resetMemoryToolMockState({ searchImpl: async () => [] }); - }); - - it("returns explicit unavailable metadata for quota failures", async () => { - setMemorySearchImpl(async () => { - throw new Error("openai embeddings failed: 429 insufficient_quota"); - }); - - const tool = createMemorySearchToolOrThrow(); - const result = await tool.execute("quota", { query: "hello" }); - expectUnavailableMemorySearchDetails(result.details, { - error: "openai embeddings failed: 429 insufficient_quota", - warning: "Memory search is unavailable because the embedding provider quota is exhausted.", - action: "Top up or switch embedding provider, then retry memory_search.", - }); - }); - - it("returns explicit unavailable metadata for non-quota failures", async () => { - setMemorySearchImpl(async () => { - throw new Error("embedding provider timeout"); - }); - - const tool = createMemorySearchToolOrThrow(); - const result = await tool.execute("generic", { query: "hello" }); - expectUnavailableMemorySearchDetails(result.details, { - error: "embedding provider timeout", - warning: "Memory search is unavailable due to an embedding/provider error.", - action: "Check embedding provider configuration and retry memory_search.", - }); - }); -}); diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts deleted file mode 100644 index 67b5addada1..00000000000 --- a/src/agents/tools/memory-tool.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { MemoryCitationsMode } from "../../config/types.memory.js"; -import type { MemorySearchResult } from "../../memory/types.js"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; -import { resolveSessionAgentId } from "../agent-scope.js"; -import { resolveMemorySearchConfig } from "../memory-search.js"; -import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readNumberParam, readStringParam } from "./common.js"; - -type MemoryToolRuntime = typeof import("./memory-tool.runtime.js"); -type MemorySearchManagerResult = Awaited< - ReturnType<(typeof import("../../memory/index.js"))["getMemorySearchManager"]> ->; - -let memoryToolRuntimePromise: Promise | null = null; - -async function loadMemoryToolRuntime(): Promise { - memoryToolRuntimePromise ??= import("./memory-tool.runtime.js"); - return await memoryToolRuntimePromise; -} - -const MemorySearchSchema = Type.Object({ - query: Type.String(), - maxResults: Type.Optional(Type.Number()), - minScore: Type.Optional(Type.Number()), -}); - -const MemoryGetSchema = Type.Object({ - path: Type.String(), - from: Type.Optional(Type.Number()), - lines: Type.Optional(Type.Number()), -}); - -function resolveMemoryToolContext(options: { config?: OpenClawConfig; agentSessionKey?: string }) { - const cfg = options.config; - if (!cfg) { - return null; - } - const agentId = resolveSessionAgentId({ - sessionKey: options.agentSessionKey, - config: cfg, - }); - if (!resolveMemorySearchConfig(cfg, agentId)) { - return null; - } - return { cfg, agentId }; -} - -async function getMemoryManagerContext(params: { cfg: OpenClawConfig; agentId: string }): Promise< - | { - manager: NonNullable; - } - | { - error: string | undefined; - } -> { - return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined }); -} - -async function getMemoryManagerContextWithPurpose(params: { - cfg: OpenClawConfig; - agentId: string; - purpose?: "default" | "status"; -}): Promise< - | { - manager: NonNullable; - } - | { - error: string | undefined; - } -> { - const { getMemorySearchManager } = await loadMemoryToolRuntime(); - const { manager, error } = await getMemorySearchManager({ - cfg: params.cfg, - agentId: params.agentId, - purpose: params.purpose, - }); - return manager ? { manager } : { error }; -} - -function createMemoryTool(params: { - options: { - config?: OpenClawConfig; - agentSessionKey?: string; - }; - label: string; - name: string; - description: string; - parameters: typeof MemorySearchSchema | typeof MemoryGetSchema; - execute: (ctx: { cfg: OpenClawConfig; agentId: string }) => AnyAgentTool["execute"]; -}): AnyAgentTool | null { - const ctx = resolveMemoryToolContext(params.options); - if (!ctx) { - return null; - } - return { - label: params.label, - name: params.name, - description: params.description, - parameters: params.parameters, - execute: params.execute(ctx), - }; -} - -export function createMemorySearchTool(options: { - config?: OpenClawConfig; - agentSessionKey?: string; -}): AnyAgentTool | null { - return createMemoryTool({ - options, - label: "Memory Search", - name: "memory_search", - description: - "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.", - parameters: MemorySearchSchema, - execute: - ({ cfg, agentId }) => - async (_toolCallId, params) => { - const query = readStringParam(params, "query", { required: true }); - const maxResults = readNumberParam(params, "maxResults"); - const minScore = readNumberParam(params, "minScore"); - const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); - const memory = await getMemoryManagerContext({ cfg, agentId }); - if ("error" in memory) { - return jsonResult(buildMemorySearchUnavailableResult(memory.error)); - } - try { - const citationsMode = resolveMemoryCitationsMode(cfg); - const includeCitations = shouldIncludeCitations({ - mode: citationsMode, - sessionKey: options.agentSessionKey, - }); - const rawResults = await memory.manager.search(query, { - maxResults, - minScore, - sessionKey: options.agentSessionKey, - }); - const status = memory.manager.status(); - const decorated = decorateCitations(rawResults, includeCitations); - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); - const results = - status.backend === "qmd" - ? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars) - : decorated; - const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode; - return jsonResult({ - results, - provider: status.provider, - model: status.model, - fallback: status.fallback, - citations: citationsMode, - mode: searchMode, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResult(buildMemorySearchUnavailableResult(message)); - } - }, - }); -} - -export function createMemoryGetTool(options: { - config?: OpenClawConfig; - agentSessionKey?: string; -}): AnyAgentTool | null { - return createMemoryTool({ - options, - label: "Memory Get", - name: "memory_get", - description: - "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", - parameters: MemoryGetSchema, - execute: - ({ cfg, agentId }) => - async (_toolCallId, params) => { - const relPath = readStringParam(params, "path", { required: true }); - const from = readNumberParam(params, "from", { integer: true }); - const lines = readNumberParam(params, "lines", { integer: true }); - const { readAgentMemoryFile, resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); - if (resolved.backend === "builtin") { - try { - const result = await readAgentMemoryFile({ - cfg, - agentId, - relPath, - from: from ?? undefined, - lines: lines ?? undefined, - }); - return jsonResult(result); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResult({ path: relPath, text: "", disabled: true, error: message }); - } - } - const memory = await getMemoryManagerContextWithPurpose({ - cfg, - agentId, - purpose: "status", - }); - if ("error" in memory) { - return jsonResult({ path: relPath, text: "", disabled: true, error: memory.error }); - } - try { - const result = await memory.manager.readFile({ - relPath, - from: from ?? undefined, - lines: lines ?? undefined, - }); - return jsonResult(result); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResult({ path: relPath, text: "", disabled: true, error: message }); - } - }, - }); -} - -function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode { - const mode = cfg.memory?.citations; - if (mode === "on" || mode === "off" || mode === "auto") { - return mode; - } - return "auto"; -} - -function decorateCitations(results: MemorySearchResult[], include: boolean): MemorySearchResult[] { - if (!include) { - return results.map((entry) => ({ ...entry, citation: undefined })); - } - return results.map((entry) => { - const citation = formatCitation(entry); - const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`; - return { ...entry, citation, snippet }; - }); -} - -function formatCitation(entry: MemorySearchResult): string { - const lineRange = - entry.startLine === entry.endLine - ? `#L${entry.startLine}` - : `#L${entry.startLine}-L${entry.endLine}`; - return `${entry.path}${lineRange}`; -} - -function clampResultsByInjectedChars( - results: MemorySearchResult[], - budget?: number, -): MemorySearchResult[] { - if (!budget || budget <= 0) { - return results; - } - let remaining = budget; - const clamped: MemorySearchResult[] = []; - for (const entry of results) { - if (remaining <= 0) { - break; - } - const snippet = entry.snippet ?? ""; - if (snippet.length <= remaining) { - clamped.push(entry); - remaining -= snippet.length; - } else { - const trimmed = snippet.slice(0, Math.max(0, remaining)); - clamped.push({ ...entry, snippet: trimmed }); - break; - } - } - return clamped; -} - -function buildMemorySearchUnavailableResult(error: string | undefined) { - const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable"; - const isQuotaError = /insufficient_quota|quota|429/.test(reason.toLowerCase()); - const warning = isQuotaError - ? "Memory search is unavailable because the embedding provider quota is exhausted." - : "Memory search is unavailable due to an embedding/provider error."; - const action = isQuotaError - ? "Top up or switch embedding provider, then retry memory_search." - : "Check embedding provider configuration and retry memory_search."; - return { - results: [], - disabled: true, - unavailable: true, - error: reason, - warning, - action, - }; -} - -function shouldIncludeCitations(params: { - mode: MemoryCitationsMode; - sessionKey?: string; -}): boolean { - if (params.mode === "on") { - return true; - } - if (params.mode === "off") { - return false; - } - // auto: show citations in direct chats; suppress in groups/channels by default. - const chatType = deriveChatTypeFromSessionKey(params.sessionKey); - return chatType === "direct"; -} - -function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" { - const parsed = parseAgentSessionKey(sessionKey); - if (!parsed?.rest) { - return "direct"; - } - const tokens = new Set(parsed.rest.toLowerCase().split(":").filter(Boolean)); - if (tokens.has("channel")) { - return "channel"; - } - if (tokens.has("group")) { - return "group"; - } - return "direct"; -} diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index db4f4d39d26..45caaab3704 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -398,7 +398,6 @@ describe("argv helpers", () => { ["node", "openclaw", "config", "unset", "update"], ["node", "openclaw", "models", "list"], ["node", "openclaw", "models", "status"], - ["node", "openclaw", "memory", "status"], ["node", "openclaw", "update", "status", "--json"], ["node", "openclaw", "agent", "--message", "hi"], ] as const; diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 2b9675bd23b..02d40656222 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -317,9 +317,6 @@ export function shouldMigrateStateFromPath(path: string[]): boolean { if (primary === "models" && (secondary === "list" || secondary === "status")) { return false; } - if (primary === "memory" && secondary === "status") { - return false; - } if (primary === "agent") { return false; } diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index bc2e1109f75..e31d10df28a 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { readCommandSource } from "./command-source.test-helpers.js"; const SECRET_TARGET_CALLSITES = [ - "src/cli/memory-cli.runtime.ts", + "extensions/memory-core/src/cli.runtime.ts", "src/cli/qr-cli.ts", "src/commands/agent.ts", "src/commands/channels/resolve.ts", diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 5f6a98b70bc..46593d18a76 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, - getMemoryCommandSecretTargetIds, getScopedChannelsCommandSecretTargets, getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; @@ -14,16 +13,6 @@ describe("command secret target ids", () => { expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true); }); - it("keeps memory command target set focused on memorySearch remote credentials", () => { - const ids = getMemoryCommandSecretTargetIds(); - expect(ids).toEqual( - new Set([ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - ]), - ); - }); - it("includes gateway auth and channel targets for security audit", () => { const ids = getSecurityAuditCommandSecretTargetIds(); expect(ids.has("channels.discord.token")).toBe(true); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 89284892f34..9c009d62859 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -13,10 +13,6 @@ function idsByPrefix(prefixes: readonly string[]): string[] { } const COMMAND_SECRET_TARGETS = { - memory: [ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - ], qrRemote: ["gateway.remote.token", "gateway.remote.password"], channels: idsByPrefix(["channels."]), models: idsByPrefix(["models.providers."]), @@ -101,10 +97,6 @@ export function getScopedChannelsCommandSecretTargets(params: { return { targetIds, allowedPaths }; } -export function getMemoryCommandSecretTargetIds(): Set { - return toTargetIdSet(COMMAND_SECRET_TARGETS.memory); -} - export function getQrRemoteCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.qrRemote); } diff --git a/src/cli/memory-cli.runtime.ts b/src/cli/memory-cli.runtime.ts deleted file mode 100644 index f7145c24572..00000000000 --- a/src/cli/memory-cli.runtime.ts +++ /dev/null @@ -1,747 +0,0 @@ -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; -import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; -import { setVerbose } from "../globals.js"; -import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; -import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; -import { defaultRuntime } from "../runtime.js"; -import { colorize, isRich, theme } from "../terminal/theme.js"; -import { shortenHomeInString, shortenHomePath } from "../utils.js"; -import { formatErrorMessage, withManager } from "./cli-utils.js"; -import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; -import { getMemoryCommandSecretTargetIds } from "./command-secret-targets.js"; -import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./memory-cli.types.js"; -import { withProgress, withProgressTotals } from "./progress.js"; -export { registerMemoryCli } from "./memory-cli.js"; - -type MemoryManager = NonNullable; -type MemoryManagerPurpose = Parameters[0]["purpose"]; - -type MemorySourceName = "memory" | "sessions"; - -type SourceScan = { - source: MemorySourceName; - totalFiles: number | null; - issues: string[]; -}; - -type MemorySourceScan = { - sources: SourceScan[]; - totalFiles: number | null; - issues: string[]; -}; - -type LoadedMemoryCommandConfig = { - config: ReturnType; - diagnostics: string[]; -}; - -async function loadMemoryCommandConfig(commandName: string): Promise { - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ - config: loadConfig(), - commandName, - targetIds: getMemoryCommandSecretTargetIds(), - }); - return { - config: resolvedConfig, - diagnostics, - }; -} - -function emitMemorySecretResolveDiagnostics( - diagnostics: string[], - params?: { json?: boolean }, -): void { - if (diagnostics.length === 0) { - return; - } - const toStderr = params?.json === true; - for (const entry of diagnostics) { - const message = theme.warn(`[secrets] ${entry}`); - if (toStderr) { - defaultRuntime.error(message); - } else { - defaultRuntime.log(message); - } - } -} - -function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string { - if (source === "memory") { - return shortenHomeInString( - `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`, - ); - } - if (source === "sessions") { - const stateDir = resolveStateDir(process.env, os.homedir); - return shortenHomeInString( - `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`, - ); - } - return source; -} - -function resolveAgent(cfg: ReturnType, agent?: string) { - const trimmed = agent?.trim(); - if (trimmed) { - return trimmed; - } - return resolveDefaultAgentId(cfg); -} - -function resolveAgentIds(cfg: ReturnType, agent?: string): string[] { - const trimmed = agent?.trim(); - if (trimmed) { - return [trimmed]; - } - const list = cfg.agents?.list ?? []; - if (list.length > 0) { - return list.map((entry) => entry.id).filter(Boolean); - } - return [resolveDefaultAgentId(cfg)]; -} - -function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] { - return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry)); -} - -async function withMemoryManagerForAgent(params: { - cfg: ReturnType; - agentId: string; - purpose?: MemoryManagerPurpose; - run: (manager: MemoryManager) => Promise; -}): Promise { - const managerParams: Parameters[0] = { - cfg: params.cfg, - agentId: params.agentId, - }; - if (params.purpose) { - managerParams.purpose = params.purpose; - } - await withManager({ - getManager: () => getMemorySearchManager(managerParams), - onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), - onCloseError: (err) => - defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), - close: async (manager) => { - await manager.close?.(); - }, - run: params.run, - }); -} - -async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> { - try { - await fs.access(pathname, fsSync.constants.R_OK); - return { exists: true }; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { exists: false }; - } - return { - exists: true, - issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`, - }; - } -} - -async function scanSessionFiles(agentId: string): Promise { - const issues: string[] = []; - const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); - try { - const entries = await fs.readdir(sessionsDir, { withFileTypes: true }); - const totalFiles = entries.filter( - (entry) => entry.isFile() && entry.name.endsWith(".jsonl"), - ).length; - return { source: "sessions", totalFiles, issues }; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`); - return { source: "sessions", totalFiles: 0, issues }; - } - issues.push( - `sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`, - ); - return { source: "sessions", totalFiles: null, issues }; - } -} - -async function scanMemoryFiles( - workspaceDir: string, - extraPaths: string[] = [], -): Promise { - const issues: string[] = []; - const memoryFile = path.join(workspaceDir, "MEMORY.md"); - const altMemoryFile = path.join(workspaceDir, "memory.md"); - const memoryDir = path.join(workspaceDir, "memory"); - - const primary = await checkReadableFile(memoryFile); - const alt = await checkReadableFile(altMemoryFile); - if (primary.issue) { - issues.push(primary.issue); - } - if (alt.issue) { - issues.push(alt.issue); - } - - const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); - for (const extraPath of resolvedExtraPaths) { - try { - const stat = await fs.lstat(extraPath); - if (stat.isSymbolicLink()) { - continue; - } - const extraCheck = await checkReadableFile(extraPath); - if (extraCheck.issue) { - issues.push(extraCheck.issue); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`); - } else { - issues.push( - `additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`, - ); - } - } - } - - let dirReadable: boolean | null = null; - try { - await fs.access(memoryDir, fsSync.constants.R_OK); - dirReadable = true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`); - dirReadable = false; - } else { - issues.push( - `memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`, - ); - dirReadable = null; - } - } - - let listed: string[] = []; - let listedOk = false; - try { - listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths); - listedOk = true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (dirReadable !== null) { - issues.push( - `memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`, - ); - dirReadable = null; - } - } - - let totalFiles: number | null = 0; - if (dirReadable === null) { - totalFiles = null; - } else { - const files = new Set(listedOk ? listed : []); - if (!listedOk) { - if (primary.exists) { - files.add(memoryFile); - } - if (alt.exists) { - files.add(altMemoryFile); - } - } - totalFiles = files.size; - } - - if ((totalFiles ?? 0) === 0 && issues.length === 0) { - issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`); - } - - return { source: "memory", totalFiles, issues }; -} - -async function summarizeQmdIndexArtifact(manager: MemoryManager): Promise { - const status = manager.status?.(); - if (!status || status.backend !== "qmd") { - return null; - } - const dbPath = status.dbPath?.trim(); - if (!dbPath) { - return null; - } - let stat: fsSync.Stats; - try { - stat = await fs.stat(dbPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`QMD index file not found: ${shortenHomePath(dbPath)}`, { cause: err }); - } - throw new Error( - `QMD index file check failed: ${shortenHomePath(dbPath)} (${code ?? "error"})`, - { cause: err }, - ); - } - if (!stat.isFile() || stat.size <= 0) { - throw new Error(`QMD index file is empty: ${shortenHomePath(dbPath)}`); - } - return `QMD index: ${shortenHomePath(dbPath)} (${stat.size} bytes)`; -} - -async function scanMemorySources(params: { - workspaceDir: string; - agentId: string; - sources: MemorySourceName[]; - extraPaths?: string[]; -}): Promise { - const scans: SourceScan[] = []; - const extraPaths = params.extraPaths ?? []; - for (const source of params.sources) { - if (source === "memory") { - scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths)); - } - if (source === "sessions") { - scans.push(await scanSessionFiles(params.agentId)); - } - } - const issues = scans.flatMap((scan) => scan.issues); - const totals = scans.map((scan) => scan.totalFiles); - const numericTotals = totals.filter((total): total is number => total !== null); - const totalFiles = totals.some((total) => total === null) - ? null - : numericTotals.reduce((sum, total) => sum + total, 0); - return { sources: scans, totalFiles, issues }; -} - -export async function runMemoryStatus(opts: MemoryCommandOptions) { - setVerbose(Boolean(opts.verbose)); - const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory status"); - emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) }); - const agentIds = resolveAgentIds(cfg, opts.agent); - const allResults: Array<{ - agentId: string; - status: ReturnType; - embeddingProbe?: Awaited>; - indexError?: string; - scan?: MemorySourceScan; - }> = []; - - for (const agentId of agentIds) { - const managerPurpose = opts.index ? "default" : "status"; - await withMemoryManagerForAgent({ - cfg, - agentId, - purpose: managerPurpose, - run: async (manager) => { - const deep = Boolean(opts.deep || opts.index); - let embeddingProbe: - | Awaited> - | undefined; - let indexError: string | undefined; - const syncFn = manager.sync ? manager.sync.bind(manager) : undefined; - if (deep) { - await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { - progress.setLabel("Probing vector…"); - await manager.probeVectorAvailability(); - progress.tick(); - progress.setLabel("Probing embeddings…"); - embeddingProbe = await manager.probeEmbeddingAvailability(); - progress.tick(); - }); - if (opts.index && syncFn) { - await withProgressTotals( - { - label: "Indexing memory…", - total: 0, - fallback: opts.verbose ? "line" : undefined, - }, - async (update, progress) => { - try { - await syncFn({ - reason: "cli", - force: Boolean(opts.force), - progress: (syncUpdate) => { - update({ - completed: syncUpdate.completed, - total: syncUpdate.total, - label: syncUpdate.label, - }); - if (syncUpdate.label) { - progress.setLabel(syncUpdate.label); - } - }, - }); - } catch (err) { - indexError = formatErrorMessage(err); - defaultRuntime.error(`Memory index failed: ${indexError}`); - process.exitCode = 1; - } - }, - ); - } else if (opts.index && !syncFn) { - defaultRuntime.log("Memory backend does not support manual reindex."); - } - } else { - await manager.probeVectorAvailability(); - } - const status = manager.status(); - const sources = ( - status.sources?.length ? status.sources : ["memory"] - ) as MemorySourceName[]; - const workspaceDir = status.workspaceDir; - const scan = workspaceDir - ? await scanMemorySources({ - workspaceDir, - agentId, - sources, - extraPaths: status.extraPaths, - }) - : undefined; - allResults.push({ agentId, status, embeddingProbe, indexError, scan }); - }, - }); - } - - if (opts.json) { - defaultRuntime.writeJson(allResults); - 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 accent = (text: string) => colorize(rich, theme.accent, text); - const label = (text: string) => muted(`${text}:`); - - for (const result of allResults) { - const { agentId, status, embeddingProbe, indexError, scan } = result; - const filesIndexed = status.files ?? 0; - const chunksIndexed = status.chunks ?? 0; - const totalFiles = scan?.totalFiles ?? null; - const indexedLabel = - totalFiles === null - ? `${filesIndexed}/? files · ${chunksIndexed} chunks` - : `${filesIndexed}/${totalFiles} files · ${chunksIndexed} chunks`; - if (opts.index) { - const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; - defaultRuntime.log(line); - } - const requestedProvider = status.requestedProvider ?? status.provider; - const modelLabel = status.model ?? status.provider; - const storePath = status.dbPath ? shortenHomePath(status.dbPath) : ""; - const workspacePath = status.workspaceDir ? shortenHomePath(status.workspaceDir) : ""; - const sourceList = status.sources?.length ? status.sources.join(", ") : null; - const extraPaths = status.workspaceDir - ? formatExtraPaths(status.workspaceDir, status.extraPaths ?? []) - : []; - const lines = [ - `${heading("Memory Search")} ${muted(`(${agentId})`)}`, - `${label("Provider")} ${info(status.provider)} ${muted(`(requested: ${requestedProvider})`)}`, - `${label("Model")} ${info(modelLabel)}`, - sourceList ? `${label("Sources")} ${info(sourceList)}` : null, - extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null, - `${label("Indexed")} ${success(indexedLabel)}`, - `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, - `${label("Store")} ${info(storePath)}`, - `${label("Workspace")} ${info(workspacePath)}`, - ].filter(Boolean) as string[]; - if (embeddingProbe) { - const state = embeddingProbe.ok ? "ready" : "unavailable"; - const stateColor = embeddingProbe.ok ? theme.success : theme.warn; - lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`); - if (embeddingProbe.error) { - lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`); - } - } - if (status.sourceCounts?.length) { - lines.push(label("By source")); - for (const entry of status.sourceCounts) { - const total = scan?.sources?.find( - (scanEntry) => scanEntry.source === entry.source, - )?.totalFiles; - const counts = - total === null - ? `${entry.files}/? files · ${entry.chunks} chunks` - : `${entry.files}/${total} files · ${entry.chunks} chunks`; - lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`); - } - } - if (status.fallback) { - lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); - } - if (status.vector) { - const vectorState = status.vector.enabled - ? status.vector.available === undefined - ? "unknown" - : status.vector.available - ? "ready" - : "unavailable" - : "disabled"; - const vectorColor = - vectorState === "ready" - ? theme.success - : vectorState === "unavailable" - ? theme.warn - : theme.muted; - lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`); - if (status.vector.dims) { - lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); - } - if (status.vector.extensionPath) { - lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`); - } - if (status.vector.loadError) { - lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); - } - } - if (status.fts) { - const ftsState = status.fts.enabled - ? status.fts.available - ? "ready" - : "unavailable" - : "disabled"; - const ftsColor = - ftsState === "ready" - ? theme.success - : ftsState === "unavailable" - ? theme.warn - : theme.muted; - lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`); - if (status.fts.error) { - lines.push(`${label("FTS error")} ${warn(status.fts.error)}`); - } - } - if (status.cache) { - const cacheState = status.cache.enabled ? "enabled" : "disabled"; - const cacheColor = status.cache.enabled ? theme.success : theme.muted; - const suffix = - status.cache.enabled && typeof status.cache.entries === "number" - ? ` (${status.cache.entries} entries)` - : ""; - lines.push(`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`); - if (status.cache.enabled && typeof status.cache.maxEntries === "number") { - lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`); - } - } - if (status.batch) { - const batchState = status.batch.enabled ? "enabled" : "disabled"; - const batchColor = status.batch.enabled ? theme.success : theme.warn; - const batchSuffix = ` (failures ${status.batch.failures}/${status.batch.limit})`; - lines.push( - `${label("Batch")} ${colorize(rich, batchColor, batchState)}${muted(batchSuffix)}`, - ); - if (status.batch.lastError) { - lines.push(`${label("Batch error")} ${warn(status.batch.lastError)}`); - } - } - if (status.fallback?.reason) { - lines.push(muted(status.fallback.reason)); - } - if (indexError) { - lines.push(`${label("Index error")} ${warn(indexError)}`); - } - if (scan?.issues.length) { - lines.push(label("Issues")); - for (const issue of scan.issues) { - lines.push(` ${warn(issue)}`); - } - } - defaultRuntime.log(lines.join("\n")); - defaultRuntime.log(""); - } -} - -export async function runMemoryIndex(opts: MemoryCommandOptions) { - setVerbose(Boolean(opts.verbose)); - const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory index"); - emitMemorySecretResolveDiagnostics(diagnostics); - const agentIds = resolveAgentIds(cfg, opts.agent); - for (const agentId of agentIds) { - await withMemoryManagerForAgent({ - cfg, - agentId, - run: async (manager) => { - try { - const syncFn = manager.sync ? manager.sync.bind(manager) : undefined; - if (opts.verbose) { - const status = manager.status(); - 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 warn = (text: string) => colorize(rich, theme.warn, text); - const label = (text: string) => muted(`${text}:`); - const sourceLabels = (status.sources ?? []).map((source) => - formatSourceLabel(source, status.workspaceDir ?? "", agentId), - ); - const extraPaths = status.workspaceDir - ? formatExtraPaths(status.workspaceDir, status.extraPaths ?? []) - : []; - const requestedProvider = status.requestedProvider ?? status.provider; - const modelLabel = status.model ?? status.provider; - const lines = [ - `${heading("Memory Index")} ${muted(`(${agentId})`)}`, - `${label("Provider")} ${info(status.provider)} ${muted( - `(requested: ${requestedProvider})`, - )}`, - `${label("Model")} ${info(modelLabel)}`, - sourceLabels.length ? `${label("Sources")} ${info(sourceLabels.join(", "))}` : null, - extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null, - ].filter(Boolean) as string[]; - if (status.fallback) { - lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); - } - defaultRuntime.log(lines.join("\n")); - defaultRuntime.log(""); - } - const startedAt = Date.now(); - let lastLabel = "Indexing memory…"; - let lastCompleted = 0; - let lastTotal = 0; - const formatElapsed = () => { - const elapsedMs = Math.max(0, Date.now() - startedAt); - const seconds = Math.floor(elapsedMs / 1000); - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`; - }; - const formatEta = () => { - if (lastTotal <= 0 || lastCompleted <= 0) { - return null; - } - const elapsedMs = Math.max(1, Date.now() - startedAt); - const rate = lastCompleted / elapsedMs; - if (!Number.isFinite(rate) || rate <= 0) { - return null; - } - const remainingMs = Math.max(0, (lastTotal - lastCompleted) / rate); - const seconds = Math.floor(remainingMs / 1000); - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`; - }; - const buildLabel = () => { - const elapsed = formatElapsed(); - const eta = formatEta(); - return eta - ? `${lastLabel} · elapsed ${elapsed} · eta ${eta}` - : `${lastLabel} · elapsed ${elapsed}`; - }; - if (!syncFn) { - defaultRuntime.log("Memory backend does not support manual reindex."); - return; - } - await withProgressTotals( - { - label: "Indexing memory…", - total: 0, - fallback: opts.verbose ? "line" : undefined, - }, - async (update, progress) => { - const interval = setInterval(() => { - progress.setLabel(buildLabel()); - }, 1000); - try { - await syncFn({ - reason: "cli", - force: Boolean(opts.force), - progress: (syncUpdate) => { - if (syncUpdate.label) { - lastLabel = syncUpdate.label; - } - lastCompleted = syncUpdate.completed; - lastTotal = syncUpdate.total; - update({ - completed: syncUpdate.completed, - total: syncUpdate.total, - label: buildLabel(), - }); - progress.setLabel(buildLabel()); - }, - }); - } finally { - clearInterval(interval); - } - }, - ); - const qmdIndexSummary = await summarizeQmdIndexArtifact(manager); - if (qmdIndexSummary) { - defaultRuntime.log(qmdIndexSummary); - } - defaultRuntime.log(`Memory index updated (${agentId}).`); - } catch (err) { - const message = formatErrorMessage(err); - defaultRuntime.error(`Memory index failed (${agentId}): ${message}`); - process.exitCode = 1; - } - }, - }); - } -} - -export async function runMemorySearch( - queryArg: string | undefined, - opts: MemorySearchCommandOptions, -) { - const query = opts.query ?? queryArg; - if (!query) { - defaultRuntime.error("Missing search query. Provide a positional query or use --query ."); - process.exitCode = 1; - return; - } - const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search"); - emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) }); - const agentId = resolveAgent(cfg, opts.agent); - await withMemoryManagerForAgent({ - cfg, - agentId, - run: async (manager) => { - let results: Awaited>; - try { - results = await manager.search(query, { - maxResults: opts.maxResults, - minScore: opts.minScore, - }); - } catch (err) { - const message = formatErrorMessage(err); - defaultRuntime.error(`Memory search failed: ${message}`); - process.exitCode = 1; - return; - } - if (opts.json) { - defaultRuntime.writeJson({ results }); - return; - } - if (results.length === 0) { - defaultRuntime.log("No matches."); - return; - } - const rich = isRich(); - const lines: string[] = []; - for (const result of results) { - lines.push( - `${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize( - rich, - theme.accent, - `${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`, - )}`, - ); - lines.push(colorize(rich, theme.muted, result.snippet)); - lines.push(""); - } - defaultRuntime.log(lines.join("\n").trim()); - }, - }); -} diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts deleted file mode 100644 index 2a07682391e..00000000000 --- a/src/cli/memory-cli.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Command } from "commander"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - firstWrittenJsonArg, - spyRuntimeErrors, - spyRuntimeJson, - spyRuntimeLogs, -} from "./test-runtime-capture.js"; - -const getMemorySearchManager = vi.hoisted(() => vi.fn()); -const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); -const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main")); -const resolveCommandSecretRefsViaGateway = vi.hoisted(() => - vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], - })), -); - -vi.mock("../memory/index.js", () => ({ - getMemorySearchManager, -})); - -vi.mock("../config/config.js", () => ({ - loadConfig, -})); - -vi.mock("../agents/agent-scope.js", () => ({ - resolveDefaultAgentId, -})); - -vi.mock("./command-secret-gateway.js", () => ({ - resolveCommandSecretRefsViaGateway, -})); - -let registerMemoryCli: typeof import("./memory-cli.js").registerMemoryCli; -let defaultRuntime: typeof import("../runtime.js").defaultRuntime; -let isVerbose: typeof import("../globals.js").isVerbose; -let setVerbose: typeof import("../globals.js").setVerbose; - -beforeAll(async () => { - ({ registerMemoryCli } = await import("./memory-cli.js")); - ({ defaultRuntime } = await import("../runtime.js")); - ({ isVerbose, setVerbose } = await import("../globals.js")); -}); - -beforeEach(() => { - getMemorySearchManager.mockReset(); - loadConfig.mockReset().mockReturnValue({}); - resolveDefaultAgentId.mockReset().mockReturnValue("main"); - resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ - resolvedConfig: config, - diagnostics: [] as string[], - })); -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.exitCode = undefined; - setVerbose(false); -}); - -describe("memory cli", () => { - const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret - - function expectCliSync(sync: ReturnType) { - expect(sync).toHaveBeenCalledWith( - expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }), - ); - } - - function makeMemoryStatus(overrides: Record = {}) { - return { - files: 0, - chunks: 0, - dirty: false, - workspaceDir: "/tmp/openclaw", - dbPath: "/tmp/memory.sqlite", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - vector: { enabled: true, available: true }, - ...overrides, - }; - } - - function mockManager(manager: Record) { - getMemorySearchManager.mockResolvedValueOnce({ manager }); - } - - function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType) { - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ - resolvedConfig: {}, - diagnostics: [inactiveMemorySecretDiagnostic] as string[], - }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); - } - - function hasLoggedInactiveSecretDiagnostic(spy: ReturnType) { - return spy.mock.calls.some( - (call: unknown[]) => - typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic), - ); - } - - async function runMemoryCli(args: string[]) { - const program = new Command(); - program.name("test"); - registerMemoryCli(program); - await program.parseAsync(["memory", ...args], { from: "user" }); - } - - function captureHelpOutput(command: Command | undefined) { - let output = ""; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((( - chunk: string | Uint8Array, - ) => { - output += String(chunk); - return true; - }) as typeof process.stdout.write); - try { - command?.outputHelp(); - return output; - } finally { - writeSpy.mockRestore(); - } - } - - function getMemoryHelpText() { - const program = new Command(); - registerMemoryCli(program); - const memoryCommand = program.commands.find((command) => command.name() === "memory"); - return captureHelpOutput(memoryCommand); - } - - async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise) { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-")); - const dbPath = path.join(tmpDir, "index.sqlite"); - try { - await fs.writeFile(dbPath, content, "utf-8"); - await run(dbPath); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - } - - async function expectCloseFailureAfterCommand(params: { - args: string[]; - manager: Record; - beforeExpect?: () => void; - }) { - const close = vi.fn(async () => { - throw new Error("close boom"); - }); - mockManager({ ...params.manager, close }); - - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(params.args); - - params.beforeExpect?.(); - expect(close).toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory manager close failed: close boom"), - ); - expect(process.exitCode).toBeUndefined(); - } - - it("prints vector status when available", async () => { - const close = vi.fn(async () => {}); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => - makeMemoryStatus({ - files: 2, - chunks: 5, - cache: { enabled: true, entries: 123, maxEntries: 50000 }, - fts: { enabled: true, available: true }, - vector: { - enabled: true, - available: true, - extensionPath: "/opt/sqlite-vec.dylib", - dims: 1024, - }, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status"]); - - expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("FTS: ready")); - expect(log).toHaveBeenCalledWith( - expect.stringContaining("Embedding cache: enabled (123 entries)"), - ); - expect(close).toHaveBeenCalled(); - }); - - it("resolves configured memory SecretRefs through gateway snapshot", async () => { - loadConfig.mockReturnValue({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, - }, - }, - }, - }, - }); - const close = vi.fn(async () => {}); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus(), - close, - }); - - await runMemoryCli(["status"]); - - expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( - expect.objectContaining({ - commandName: "memory status", - targetIds: new Set([ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - ]), - }), - ); - }); - - it("logs gateway secret diagnostics for non-json status output", async () => { - const close = vi.fn(async () => {}); - setupMemoryStatusWithInactiveSecretDiagnostics(close); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status"]); - - expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true); - }); - - it("documents memory help examples", () => { - const helpText = getMemoryHelpText(); - - expect(helpText).toContain("openclaw memory status --deep"); - expect(helpText).toContain("Probe embedding provider readiness."); - expect(helpText).toContain('openclaw memory search "meeting notes"'); - 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."); - }); - - it("prints vector error when unavailable", async () => { - const close = vi.fn(async () => {}); - mockManager({ - probeVectorAvailability: vi.fn(async () => false), - status: () => - makeMemoryStatus({ - dirty: true, - vector: { - enabled: true, - available: false, - loadError: "load failed", - }, - }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status", "--agent", "main"]); - - expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector error: load failed")); - expect(close).toHaveBeenCalled(); - }); - - it("prints embeddings status when deep", async () => { - const close = vi.fn(async () => {}); - const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true })); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - probeEmbeddingAvailability, - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status", "--deep"]); - - expect(probeEmbeddingAvailability).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Embeddings: ready")); - expect(close).toHaveBeenCalled(); - }); - - it("enables verbose logging with --verbose", async () => { - const close = vi.fn(async () => {}); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus(), - close, - }); - - await runMemoryCli(["status", "--verbose"]); - - expect(isVerbose()).toBe(true); - }); - - it("logs close failure after status", async () => { - await expectCloseFailureAfterCommand({ - args: ["status"], - manager: { - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), - }, - }); - }); - - it("reindexes on status --index", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true })); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - probeEmbeddingAvailability, - sync, - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), - close, - }); - - spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status", "--index"]); - - expectCliSync(sync); - expect(probeEmbeddingAvailability).toHaveBeenCalled(); - expect(close).toHaveBeenCalled(); - }); - - it("closes manager after index", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - mockManager({ sync, close }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expect(close).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith("Memory index updated (main)."); - }); - - it("logs qmd index file path and size after index", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - await withQmdIndexDb("sqlite-bytes", async (dbPath) => { - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: ")); - expect(log).toHaveBeenCalledWith("Memory index updated (main)."); - expect(close).toHaveBeenCalled(); - }); - }); - - it("fails index when qmd db file is empty", async () => { - const close = vi.fn(async () => {}); - const sync = vi.fn(async () => {}); - await withQmdIndexDb("", async (dbPath) => { - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory index failed (main): QMD index file is empty"), - ); - expect(close).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - }); - }); - - it("logs close failures without failing the command", async () => { - const sync = vi.fn(async () => {}); - await expectCloseFailureAfterCommand({ - args: ["index"], - manager: { sync }, - beforeExpect: () => { - expectCliSync(sync); - }, - }); - }); - - it("logs close failure after search", async () => { - const search = vi.fn(async () => [ - { - path: "memory/2026-01-12.md", - startLine: 1, - endLine: 2, - score: 0.5, - snippet: "Hello", - }, - ]); - await expectCloseFailureAfterCommand({ - args: ["search", "hello"], - manager: { search }, - beforeExpect: () => { - expect(search).toHaveBeenCalled(); - }, - }); - }); - - it("closes manager after search error", async () => { - const close = vi.fn(async () => {}); - const search = vi.fn(async () => { - throw new Error("boom"); - }); - mockManager({ search, close }); - - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(["search", "oops"]); - - expect(search).toHaveBeenCalled(); - expect(close).toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith(expect.stringContaining("Memory search failed: boom")); - expect(process.exitCode).toBe(1); - }); - - it("prints status json output when requested", async () => { - const close = vi.fn(async () => {}); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ workspaceDir: undefined }), - close, - }); - - const writeJson = spyRuntimeJson(defaultRuntime); - await runMemoryCli(["status", "--json"]); - - const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload)).toBe(true); - expect((payload[0] as Record)?.agentId).toBe("main"); - expect(close).toHaveBeenCalled(); - }); - - it("routes gateway secret diagnostics to stderr for json status output", async () => { - const close = vi.fn(async () => {}); - setupMemoryStatusWithInactiveSecretDiagnostics(close); - - const writeJson = spyRuntimeJson(defaultRuntime); - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(["status", "--json"]); - - const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload)).toBe(true); - expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); - }); - - it("logs default message when memory manager is missing", async () => { - getMemorySearchManager.mockResolvedValueOnce({ manager: null }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["status"]); - - expect(log).toHaveBeenCalledWith("Memory search disabled."); - }); - - it("logs backend unsupported message when index has no sync", async () => { - const close = vi.fn(async () => {}); - mockManager({ - status: () => makeMemoryStatus(), - close, - }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["index"]); - - expect(log).toHaveBeenCalledWith("Memory backend does not support manual reindex."); - expect(close).toHaveBeenCalled(); - }); - - it("prints no matches for empty search results", async () => { - const close = vi.fn(async () => {}); - const search = vi.fn(async () => []); - mockManager({ search, close }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["search", "hello"]); - - expect(search).toHaveBeenCalledWith("hello", { - maxResults: undefined, - minScore: undefined, - }); - expect(log).toHaveBeenCalledWith("No matches."); - expect(close).toHaveBeenCalled(); - }); - - it("accepts --query for memory search", async () => { - const close = vi.fn(async () => {}); - const search = vi.fn(async () => []); - mockManager({ search, close }); - - const log = spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["search", "--query", "deployment notes"]); - - expect(search).toHaveBeenCalledWith("deployment notes", { - maxResults: undefined, - minScore: undefined, - }); - expect(log).toHaveBeenCalledWith("No matches."); - expect(close).toHaveBeenCalled(); - expect(process.exitCode).toBeUndefined(); - }); - - it("prefers --query when positional and flag are both provided", async () => { - const close = vi.fn(async () => {}); - const search = vi.fn(async () => []); - mockManager({ search, close }); - - spyRuntimeLogs(defaultRuntime); - await runMemoryCli(["search", "positional", "--query", "flagged"]); - - expect(search).toHaveBeenCalledWith("flagged", { - maxResults: undefined, - minScore: undefined, - }); - expect(close).toHaveBeenCalled(); - }); - - it("fails when neither positional query nor --query is provided", async () => { - const error = spyRuntimeErrors(defaultRuntime); - await runMemoryCli(["search"]); - - expect(error).toHaveBeenCalledWith( - "Missing search query. Provide a positional query or use --query .", - ); - expect(getMemorySearchManager).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - }); - - it("prints search results as json when requested", async () => { - const close = vi.fn(async () => {}); - const search = vi.fn(async () => [ - { - path: "memory/2026-01-12.md", - startLine: 1, - endLine: 2, - score: 0.5, - snippet: "Hello", - }, - ]); - mockManager({ search, close }); - - const writeJson = spyRuntimeJson(defaultRuntime); - await runMemoryCli(["search", "hello", "--json"]); - - const payload = firstWrittenJsonArg<{ results: unknown[] }>(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload.results)).toBe(true); - expect(payload.results).toHaveLength(1); - expect(close).toHaveBeenCalled(); - }); -}); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts deleted file mode 100644 index bdd04f1a54c..00000000000 --- a/src/cli/memory-cli.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { Command } from "commander"; -import { formatDocsLink } from "../terminal/links.js"; -import { theme } from "../terminal/theme.js"; -import { formatHelpExamples } from "./help-format.js"; -import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./memory-cli.types.js"; - -type MemoryCliRuntime = typeof import("./memory-cli.runtime.js"); - -let memoryCliRuntimePromise: Promise | null = null; - -async function loadMemoryCliRuntime(): Promise { - memoryCliRuntimePromise ??= import("./memory-cli.runtime.js"); - return await memoryCliRuntimePromise; -} - -export async function runMemoryStatus(opts: MemoryCommandOptions) { - const runtime = await loadMemoryCliRuntime(); - await runtime.runMemoryStatus(opts); -} - -async function runMemoryIndex(opts: MemoryCommandOptions) { - const runtime = await loadMemoryCliRuntime(); - await runtime.runMemoryIndex(opts); -} - -async function runMemorySearch(queryArg: string | undefined, opts: MemorySearchCommandOptions) { - const runtime = await loadMemoryCliRuntime(); - await runtime.runMemorySearch(queryArg, opts); -} - -export function registerMemoryCli(program: Command) { - const memory = program - .command("memory") - .description("Search, inspect, and reindex memory files") - .addHelpText( - "after", - () => - `\n${theme.heading("Examples:")}\n${formatHelpExamples([ - ["openclaw memory status", "Show index and provider status."], - ["openclaw memory status --deep", "Probe embedding provider readiness."], - ["openclaw memory index --force", "Force a full reindex."], - ['openclaw memory search "meeting notes"', "Quick search using positional query."], - [ - 'openclaw memory search --query "deployment" --max-results 20', - "Limit results for focused troubleshooting.", - ], - ["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."], - ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`, - ); - - memory - .command("status") - .description("Show memory search index status") - .option("--agent ", "Agent id (default: default agent)") - .option("--json", "Print JSON") - .option("--deep", "Probe embedding provider availability") - .option("--index", "Reindex if dirty (implies --deep)") - .option("--verbose", "Verbose logging", false) - .action(async (opts: MemoryCommandOptions & { force?: boolean }) => { - await runMemoryStatus(opts); - }); - - memory - .command("index") - .description("Reindex memory files") - .option("--agent ", "Agent id (default: default agent)") - .option("--force", "Force full reindex", false) - .option("--verbose", "Verbose logging", false) - .action(async (opts: MemoryCommandOptions) => { - await runMemoryIndex(opts); - }); - - memory - .command("search") - .description("Search memory files") - .argument("[query]", "Search query") - .option("--query ", "Search query (alternative to positional argument)") - .option("--agent ", "Agent id (default: default agent)") - .option("--max-results ", "Max results", (value: string) => Number(value)) - .option("--min-score ", "Minimum score", (value: string) => Number(value)) - .option("--json", "Print JSON") - .action(async (queryArg: string | undefined, opts: MemorySearchCommandOptions) => { - await runMemorySearch(queryArg, opts); - }); -} diff --git a/src/cli/memory-cli.types.ts b/src/cli/memory-cli.types.ts deleted file mode 100644 index 81315bb020c..00000000000 --- a/src/cli/memory-cli.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type MemoryCommandOptions = { - agent?: string; - json?: boolean; - deep?: boolean; - index?: boolean; - force?: boolean; - verbose?: boolean; -}; - -export type MemorySearchCommandOptions = MemoryCommandOptions & { - query?: string; - maxResults?: number; - minScore?: number; -}; diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 9b8fc6dc454..90b581b49f1 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -46,10 +46,9 @@ describe("cli program (smoke)", () => { ensureConfigReady.mockResolvedValue(undefined); }); - it("registers memory + status commands", () => { + it("registers message + status commands", () => { const names = program.commands.map((command) => command.name()); expect(names).toContain("message"); - expect(names).toContain("memory"); expect(names).toContain("status"); }); diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index 329a28a659f..d91b274fdcf 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -72,7 +72,6 @@ describe("command-registry", () => { it("returns only commands that support subcommands", () => { const names = getCoreCliCommandsWithSubcommands(); expect(names).toContain("config"); - expect(names).toContain("memory"); expect(names).toContain("agents"); expect(names).toContain("backup"); expect(names).toContain("browser"); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 93c4616594e..2f4aeec7fc7 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -147,19 +147,6 @@ const coreEntries: CoreCliEntry[] = [ mod.registerMessageCommands(program, ctx); }, }, - { - commands: [ - { - name: "memory", - description: "Search and reindex memory files", - hasSubcommands: true, - }, - ], - register: async ({ program }) => { - const mod = await import("../memory-cli.js"); - mod.registerMemoryCli(program); - }, - }, { commands: [ { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index ed7a0b10cdb..3d9568270cb 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -56,11 +56,6 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [ description: "Send, read, and manage messages", hasSubcommands: true, }, - { - name: "memory", - description: "Search and reindex memory files", - hasSubcommands: true, - }, { name: "agent", description: "Run one agent turn via the Gateway", diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index f62930f24f9..317d8958d48 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -13,11 +13,17 @@ function buildRootHelpProgram(): Command { agentChannelOptions: "", }); + const existingCommands = new Set(); for (const command of getCoreCliCommandDescriptors()) { program.command(command.name).description(command.description); + existingCommands.add(command.name); } for (const command of getSubCliEntries()) { + if (existingCommands.has(command.name)) { + continue; + } program.command(command.name).description(command.description); + existingCommands.add(command.name); } return program; diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 87849fb4d0b..ab5d710e1f0 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -260,10 +260,6 @@ describe("program routes", () => { ); }); - it("returns false for memory status route when --agent value is missing", async () => { - await expectRunFalse(["memory", "status"], ["node", "openclaw", "memory", "status", "--agent"]); - }); - it("returns false for models list route when --provider value is missing", async () => { await expectRunFalse(["models", "list"], ["node", "openclaw", "models", "list", "--provider"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index cbb6d6dbfdc..5b341c5cabc 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -151,23 +151,6 @@ const routeAgentsList: RouteSpec = { }, }; -const routeMemoryStatus: RouteSpec = { - match: (path) => path[0] === "memory" && path[1] === "status", - run: async (argv) => { - const agent = getFlagValue(argv, "--agent"); - if (agent === null) { - return false; - } - const json = hasFlag(argv, "--json"); - const deep = hasFlag(argv, "--deep"); - const index = hasFlag(argv, "--index"); - const verbose = hasFlag(argv, "--verbose"); - const { runMemoryStatus } = await import("../memory-cli.js"); - await runMemoryStatus({ agent, json, deep, index, verbose }); - return true; - }, -}; - function getFlagValues(argv: string[], name: string): string[] | null { const values: string[] = []; const args = argv.slice(2); @@ -316,7 +299,6 @@ const routes: RouteSpec[] = [ routeGatewayStatus, routeSessions, routeAgentsList, - routeMemoryStatus, routeConfigGet, routeConfigUnset, routeModelsList, diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index ad2d08f77fb..1e83a7e83c7 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -37,7 +37,6 @@ describe("tsdown config", () => { "agents/auth-profiles.runtime", "agents/pi-model-discovery-runtime", "index", - "cli/memory-cli", "commands/status.summary.runtime", "plugins/provider-runtime.runtime", "plugins/runtime/index", diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index cf315802e07..b3566e8f9a8 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -1,12 +1,35 @@ // Narrow plugin-sdk surface for the bundled memory-core plugin. // Keep this list additive and scoped to symbols used under extensions/memory-core. +export type { AnyAgentTool } from "../agents/tools/common.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveCronStyleNow } from "../agents/current-time.js"; export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js"; +export { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js"; +export { resolveMemorySearchConfig } from "../agents/memory-search.js"; +export { parseAgentSessionKey } from "../routing/session-key.js"; +export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js"; export { parseNonNegativeByteSize } from "../config/byte-size.js"; export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +export { loadConfig } from "../config/config.js"; +export { resolveStateDir } from "../config/paths.js"; +export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; +export { getMemorySearchManager } from "../memory/index.js"; +export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; +export { readAgentMemoryFile } from "../memory/read-file.js"; +export { resolveMemoryBackendConfig } from "../memory/backend-config.js"; +export { setVerbose, isVerbose } from "../globals.js"; +export { defaultRuntime } from "../runtime.js"; +export { colorize, isRich, theme } from "../terminal/theme.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { formatHelpExamples } from "../cli/help-format.js"; +export { formatErrorMessage, withManager } from "../cli/cli-utils.js"; +export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; +export { withProgress, withProgressTotals } from "../cli/progress.js"; +export { shortenHomeInString, shortenHomePath } from "../utils.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { MemoryCitationsMode } from "../config/types.memory.js"; +export type { MemorySearchResult } from "../memory/types.js"; export type { MemoryFlushPlan, MemoryFlushPlanResolver } from "../memory/flush-plan.js"; export type { MemoryPromptSectionBuilder } from "../memory/prompt-section.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 4d8af51e3db..414a3657e6a 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -4,15 +4,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins } from "./loader.js"; +import type { OpenClawPluginCliCommandDescriptor } from "./types.js"; import type { PluginLogger } from "./types.js"; const log = createSubsystemLogger("plugins"); -export function registerPluginCliCommands( - program: Command, - cfg?: OpenClawConfig, - env?: NodeJS.ProcessEnv, -) { +function loadPluginCliRegistry(cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) { const config = cfg ?? loadConfig(); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const logger: PluginLogger = { @@ -21,12 +18,48 @@ export function registerPluginCliCommands( error: (msg: string) => log.error(msg), debug: (msg: string) => log.debug(msg), }; - const registry = loadOpenClawPlugins({ + return { config, workspaceDir, - env, logger, - }); + registry: loadOpenClawPlugins({ + config, + workspaceDir, + env, + logger, + }), + }; +} + +export function getPluginCliCommandDescriptors( + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): OpenClawPluginCliCommandDescriptor[] { + try { + const { registry } = loadPluginCliRegistry(cfg, env); + const seen = new Set(); + const descriptors: OpenClawPluginCliCommandDescriptor[] = []; + for (const entry of registry.cliRegistrars) { + for (const descriptor of entry.descriptors) { + if (seen.has(descriptor.name)) { + continue; + } + seen.add(descriptor.name); + descriptors.push(descriptor); + } + } + return descriptors; + } catch { + return []; + } +} + +export function registerPluginCliCommands( + program: Command, + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, +) { + const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env); const existingCommands = new Set(program.commands.map((cmd) => cmd.name())); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d9a984eb74b..f9b69d6aedd 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -119,7 +119,6 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [ "media", "tts", "stt", - "tools", "channel", "events", "logging", diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 32a2817eb70..758fa1a3d21 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -30,6 +30,7 @@ import type { ImageGenerationProviderPlugin, OpenClawPluginApi, OpenClawPluginChannelRegistration, + OpenClawPluginCliCommandDescriptor, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, PluginConversationBindingResolvedEvent, @@ -73,6 +74,7 @@ export type PluginCliRegistration = { pluginName?: string; register: OpenClawPluginCliRegistrar; commands: string[]; + descriptors: OpenClawPluginCliCommandDescriptor[]; source: string; rootDir?: string; }; @@ -703,9 +705,21 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registerCli = ( record: PluginRecord, registrar: OpenClawPluginCliRegistrar, - opts?: { commands?: string[] }, + opts?: { commands?: string[]; descriptors?: OpenClawPluginCliCommandDescriptor[] }, ) => { - const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); + const descriptors = (opts?.descriptors ?? []) + .map((descriptor) => ({ + name: descriptor.name.trim(), + description: descriptor.description.trim(), + hasSubcommands: descriptor.hasSubcommands, + })) + .filter((descriptor) => descriptor.name && descriptor.description); + const commands = [ + ...(opts?.commands ?? []), + ...descriptors.map((descriptor) => descriptor.name), + ] + .map((cmd) => cmd.trim()) + .filter(Boolean); if (commands.length === 0) { pushDiagnostic({ level: "error", @@ -734,6 +748,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginName: record.name, register: registrar, commands, + descriptors, source: record.source, rootDir: record.rootDir, }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index e3a4e6e2c0c..9a551f8feb4 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -19,7 +19,6 @@ import { createRuntimeEvents } from "./runtime-events.js"; import { createRuntimeLogging } from "./runtime-logging.js"; import { createRuntimeMedia } from "./runtime-media.js"; import { createRuntimeSystem } from "./runtime-system.js"; -import { createRuntimeTools } from "./runtime-tools.js"; import type { PluginRuntime } from "./types.js"; const loadTtsRuntime = createLazyRuntimeModule(() => import("./runtime-tts.runtime.js")); @@ -184,7 +183,6 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): listProviders: listWebSearchProviders, search: runWebSearch, }, - tools: createRuntimeTools(), channel: createRuntimeChannel(), events: createRuntimeEvents(), logging: createRuntimeLogging(), diff --git a/src/plugins/runtime/runtime-tools.ts b/src/plugins/runtime/runtime-tools.ts deleted file mode 100644 index 66d98af02b2..00000000000 --- a/src/plugins/runtime/runtime-tools.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; -import { registerMemoryCli } from "../../cli/memory-cli.js"; -import type { PluginRuntime } from "./types.js"; - -export function createRuntimeTools(): PluginRuntime["tools"] { - return { - createMemoryGetTool, - createMemorySearchTool, - registerMemoryCli, - }; -} diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index f004ad7fa90..fa35e7f8466 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -89,11 +89,6 @@ export type PluginRuntimeCore = { stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; }; - tools: { - createMemoryGetTool: typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool; - createMemorySearchTool: typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool; - registerMemoryCli: typeof import("../../cli/memory-cli.js").registerMemoryCli; - }; events: { onAgentEvent: typeof import("../../infra/agent-events.js").onAgentEvent; onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1fca82fbe6d..cb0e3e3c567 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1273,6 +1273,12 @@ export type OpenClawPluginCliContext = { export type OpenClawPluginCliRegistrar = (ctx: OpenClawPluginCliContext) => void | Promise; +export type OpenClawPluginCliCommandDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + /** Context passed to long-lived plugin services. */ export type OpenClawPluginServiceContext = { config: OpenClawConfig; @@ -1364,7 +1370,13 @@ export type OpenClawPluginApi = { /** Register a native messaging channel plugin (channel capability). */ registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; - registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; + registerCli: ( + registrar: OpenClawPluginCliRegistrar, + opts?: { + commands?: string[]; + descriptors?: OpenClawPluginCliCommandDescriptor[]; + }, + ) => void; registerService: (service: OpenClawPluginService) => void; /** Register a text-only CLI backend used by the local CLI runner. */ registerCliBackend: (backend: CliBackendPlugin) => void; diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index 75ec63a53f6..ea2d239764e 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -132,12 +132,6 @@ export function createPluginRuntimeMock(overrides: DeepPartial = stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], }, - tools: { - createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"], - createMemorySearchTool: - vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"], - registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"], - }, channel: { text: { chunkByNewline: vi.fn((text: string) => (text ? [text] : [])), diff --git a/test/helpers/memory-tool-manager-mock.ts b/test/helpers/memory-tool-manager-mock.ts index 75687858823..367f98a5141 100644 --- a/test/helpers/memory-tool-manager-mock.ts +++ b/test/helpers/memory-tool-manager-mock.ts @@ -46,7 +46,7 @@ vi.mock("../../src/memory/read-file.js", () => ({ readAgentMemoryFile: readAgentMemoryFileMock, })); -vi.mock("../../src/agents/tools/memory-tool.runtime.js", () => ({ +vi.mock("../../extensions/memory-core/src/tools.runtime.js", () => ({ resolveMemoryBackendConfig: ({ cfg, }: { diff --git a/tsdown.config.ts b/tsdown.config.ts index 76f388bee89..0885591c2e5 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -115,10 +115,6 @@ function buildCoreDistEntries(): Record { entry: "src/entry.ts", // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", - // Ensure memory-cli is a stable entry so the runtime tools plugin can import - // it by a deterministic path instead of a content-hashed chunk name. - // See https://github.com/openclaw/openclaw/issues/51676 - "cli/memory-cli": "src/cli/memory-cli.ts", // Keep long-lived lazy runtime boundaries on stable filenames so rebuilt // dist/ trees do not strand already-running gateways on stale hashed chunks. "agents/auth-profiles.runtime": "src/agents/auth-profiles.runtime.ts",