diff --git a/docs/cli/wiki.md b/docs/cli/wiki.md index 662889323b74..d284567a397e 100644 --- a/docs/cli/wiki.md +++ b/docs/cli/wiki.md @@ -55,21 +55,22 @@ openclaw wiki obsidian daily ## Agent selection When `plugins.entries.memory-wiki.config.vault.scope` is `agent`, select the -vault with the top-level `--agent ` option: +vault with the command's `--agent ` option: ```bash -openclaw wiki --agent support status -openclaw wiki --agent support search "refund policy" -openclaw wiki --agent marketing ingest ./campaign-notes.md +openclaw wiki status --agent support +openclaw wiki search "refund policy" --agent support +openclaw wiki ingest ./campaign-notes.md --agent marketing ``` -In a setup with multiple configured agents, `--agent` is required for CLI -operations so a command cannot read or write an arbitrary default vault. If -only one agent is configured, that agent remains the default. Unknown agent ids -fail before the vault operation starts. The option does not change the selected -path when `vault.scope` is `global`. +When `--agent` is omitted, CLI operations use the configured default agent, +matching other agent-scoped CLI families. Pass the flag to select a different +agent. Unknown agent ids fail before the vault operation starts. If no default +can be resolved, the error tells you to pass `--agent ` or configure an +agent. The option does not change the selected path when `vault.scope` is +`global`. -Gateway clients follow the same rule: pass `agentId` on vault-backed `wiki.*` +Gateway clients remain explicit: pass `agentId` on vault-backed `wiki.*` requests in an agent-scoped multi-agent setup. A missing or unknown id is an error. Agent turns, wiki tools, memory corpus supplements, and compiled prompt digests already carry the active runtime agent context. diff --git a/docs/plugins/memory-wiki.md b/docs/plugins/memory-wiki.md index b69352b25e60..d28204e05ef4 100644 --- a/docs/plugins/memory-wiki.md +++ b/docs/plugins/memory-wiki.md @@ -438,10 +438,9 @@ the existing `~/.openclaw/wiki/main` path. Agent tools, compiled prompt digests, and the wiki supplement exposed through `memory_search` / `memory_get` resolve the vault from the active agent context. -For CLI and Gateway calls in a setup with multiple configured agents, provide -the agent explicitly with `openclaw wiki --agent ...` or the Gateway -request's `agentId`. A single configured agent remains the default when no id is -provided. +CLI calls use the configured default agent unless the command passes +`--agent `. Gateway calls in a multi-agent setup still require the +request's `agentId`. In bridge mode, agent-scoped imports accept a public memory artifact only when its `agentIds` includes the selected agent. Artifacts owned by another agent, diff --git a/extensions/memory-wiki/README.md b/extensions/memory-wiki/README.md index 906ffe7485e3..0fc124dfc323 100644 --- a/extensions/memory-wiki/README.md +++ b/extensions/memory-wiki/README.md @@ -107,9 +107,9 @@ therefore keeps the existing `~/.openclaw/wiki/main` path. In global scope, Wiki tools and compiled prompt/corpus supplements resolve the active runtime agent on each call. In bridge mode, an agent vault imports only public memory artifacts whose `agentIds` includes that agent; unowned and other-agent -artifacts are skipped. CLI and Gateway operations require an explicit agent in -multi-agent setups; use `openclaw wiki --agent ...` or pass `agentId` -to the `wiki.*` RPC request. A single configured agent may remain implicit. +artifacts are skipped. CLI operations use the configured default agent unless +the command passes `--agent `; Gateway operations in multi-agent +setups require `agentId` on the `wiki.*` RPC request. Configuration validation rejects agent scope with either `vaultMode: "unsafe-local"` or `obsidian.useOfficialCli: true`. Obsidian-friendly @@ -182,8 +182,8 @@ openclaw wiki obsidian command workspace:quick-switcher openclaw wiki obsidian daily # Agent-scoped vault -openclaw wiki --agent support status -openclaw wiki --agent support search "refund policy" +openclaw wiki status --agent support +openclaw wiki search "refund policy" --agent support ``` ## Agent tools diff --git a/extensions/memory-wiki/src/cli.agent-scope.test.ts b/extensions/memory-wiki/src/cli.agent-scope.test.ts new file mode 100644 index 000000000000..9bfc5c3e49d4 --- /dev/null +++ b/extensions/memory-wiki/src/cli.agent-scope.test.ts @@ -0,0 +1,226 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerWikiCli } from "./cli.js"; +import { + resolveMemoryWikiAgentConfig, + type MemoryWikiPluginConfig, + type ResolvedMemoryWikiConfig, +} from "./config.js"; +import { createMemoryWikiTestHarness } from "./test-helpers.js"; + +const AGENT_SCOPED_WIKI_COMMANDS = [ + { label: "status", path: ["status"], args: ["status"] }, + { label: "doctor", path: ["doctor"], args: ["doctor"] }, + { label: "init", path: ["init"], args: ["init"] }, + { label: "compile", path: ["compile"], args: ["compile"] }, + { label: "lint", path: ["lint"], args: ["lint"] }, + { label: "ingest", path: ["ingest"], args: ["ingest", "note.md"] }, + { label: "okf import", path: ["okf", "import"], args: ["okf", "import", "bundle"] }, + { label: "search", path: ["search"], args: ["search", "query"] }, + { label: "get", path: ["get"], args: ["get", "entity.alpha"] }, + { + label: "apply synthesis", + path: ["apply", "synthesis"], + args: ["apply", "synthesis", "Summary", "--body", "Body", "--source-id", "source.alpha"], + }, + { + label: "apply metadata", + path: ["apply", "metadata"], + args: ["apply", "metadata", "entity.alpha"], + }, + { label: "bridge import", path: ["bridge", "import"], args: ["bridge", "import"] }, + { + label: "chatgpt import", + path: ["chatgpt", "import"], + args: ["chatgpt", "import", "--export", "export"], + }, + { + label: "chatgpt rollback", + path: ["chatgpt", "rollback"], + args: ["chatgpt", "rollback", "run-id"], + }, +] as const; + +const { createVault } = createMemoryWikiTestHarness(); +let suiteRoot = ""; +let caseIndex = 0; +let stdoutWriteMock: ReturnType; + +describe("memory-wiki agent-scoped cli", () => { + beforeAll(async () => { + suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-agent-suite-")); + }); + + afterAll(async () => { + if (suiteRoot) { + await fs.rm(suiteRoot, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + stdoutWriteMock = vi.fn(() => true); + vi.spyOn(process.stdout, "write").mockImplementation( + stdoutWriteMock as unknown as typeof process.stdout.write, + ); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + async function createCliVault(options?: { config?: MemoryWikiPluginConfig }) { + return createVault({ + prefix: "memory-wiki-cli-agent-", + rootDir: path.join(suiteRoot, `case-${caseIndex++}`), + config: options?.config, + }); + } + + function stubWikiCommandAction(program: Command, commandPath: readonly string[]) { + let command = program.commands.find((candidate) => candidate.name() === "wiki"); + for (const name of commandPath) { + command = command?.commands.find((candidate) => candidate.name() === name); + } + expect(command, `wiki ${commandPath.join(" ")} command`).toBeDefined(); + command!.action(() => {}); + return command!; + } + + function createAgentSelectionProgram(params: { + config: ResolvedMemoryWikiConfig; + appConfig: { agents: { entries: Record } }; + commandPath: readonly string[]; + }) { + const resolveConfig = vi.fn((agentId?: string) => + resolveMemoryWikiAgentConfig({ + config: params.config, + appConfig: params.appConfig, + ...(agentId ? { agentId } : {}), + }), + ); + const program = new Command(); + program.name("test").enablePositionalOptions().exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + registerWikiCli(program, { + config: params.config, + getAppConfig: () => params.appConfig, + resolveConfig, + }); + const command = stubWikiCommandAction(program, params.commandPath); + return { command, program, resolveConfig }; + } + + it.each(AGENT_SCOPED_WIKI_COMMANDS)( + "accepts command-local --agent for wiki $label", + async ({ path: commandPath, args }) => { + const { rootDir, config } = await createCliVault({ + config: { vault: { scope: "agent" } }, + }); + const appConfig = { + agents: { entries: { support: { default: true }, marketing: {} } }, + }; + const { command, program, resolveConfig } = createAgentSelectionProgram({ + config, + appConfig, + commandPath, + }); + + await program.parseAsync(["wiki", ...args, "--agent", "marketing"], { from: "user" }); + + expect(command.helpInformation()).toContain("--agent "); + expect(resolveConfig).toHaveBeenCalledWith("marketing", appConfig); + expect(resolveConfig.mock.results[0]?.value.vault.path).toBe(path.join(rootDir, "marketing")); + }, + ); + + it.each(AGENT_SCOPED_WIKI_COMMANDS)( + "uses the configured default agent for wiki $label", + async ({ path: commandPath, args }) => { + const { rootDir, config } = await createCliVault({ + config: { vault: { scope: "agent" } }, + }); + const appConfig = { + agents: { entries: { support: { default: true }, marketing: {} } }, + }; + const { program, resolveConfig } = createAgentSelectionProgram({ + config, + appConfig, + commandPath, + }); + + await program.parseAsync(["wiki", ...args], { from: "user" }); + + expect(resolveConfig).toHaveBeenCalledWith("support", appConfig); + expect(resolveConfig.mock.results[0]?.value.vault.path).toBe(path.join(rootDir, "support")); + }, + ); + + it.each(AGENT_SCOPED_WIKI_COMMANDS)( + "gives actionable missing-agent guidance for wiki $label", + async ({ path: commandPath, args }) => { + const { config } = await createCliVault({ + config: { vault: { scope: "agent" } }, + }); + const appConfig = { agents: { entries: {} } }; + const { program, resolveConfig } = createAgentSelectionProgram({ + config, + appConfig, + commandPath, + }); + + await expect(program.parseAsync(["wiki", ...args], { from: "user" })).rejects.toThrow( + "No default memory-wiki agent is configured. Pass --agent , or add an agent with `openclaw agents add`.", + ); + expect(resolveConfig).not.toHaveBeenCalled(); + }, + ); + + it("runs wiki doctor against explicit and default agent-scoped vaults", async () => { + const { rootDir, config } = await createCliVault({ + config: { vault: { scope: "agent" } }, + }); + const appConfig = { + agents: { entries: { support: { default: true }, marketing: {} } }, + }; + const run = async (args: string[]) => { + stdoutWriteMock.mockClear(); + const program = new Command(); + program.name("test").enablePositionalOptions(); + registerWikiCli(program, { config, getAppConfig: () => appConfig }); + await program.parseAsync(["wiki", ...args], { from: "user" }); + return stdoutWriteMock.mock.calls.map(([chunk]) => String(chunk)).join(""); + }; + + await run(["init", "--agent", "marketing"]); + const explicitOutput = await run(["doctor", "--agent", "marketing"]); + await run(["init"]); + const defaultOutput = await run(["doctor"]); + + expect(explicitOutput).toContain("Wiki doctor: healthy"); + expect(explicitOutput).toContain("Vault scope: agent (marketing)"); + expect(explicitOutput).toContain(path.join(rootDir, "marketing")); + expect(defaultOutput).toContain("Wiki doctor: healthy"); + expect(defaultOutput).toContain("Vault scope: agent (support)"); + expect(defaultOutput).toContain(path.join(rootDir, "support")); + }); + + it("does not require an agent to probe Obsidian CLI availability", async () => { + const { config } = await createCliVault({ config: { vault: { scope: "agent" } } }); + const appConfig = { agents: { entries: {} } }; + const { program, resolveConfig } = createAgentSelectionProgram({ + config, + appConfig, + commandPath: ["obsidian", "status"], + }); + + await expect( + program.parseAsync(["wiki", "obsidian", "status"], { from: "user" }), + ).resolves.toBeDefined(); + expect(resolveConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/memory-wiki/src/cli.test.ts b/extensions/memory-wiki/src/cli.test.ts index def9c71b06ed..c35e7175272e 100644 --- a/extensions/memory-wiki/src/cli.test.ts +++ b/extensions/memory-wiki/src/cli.test.ts @@ -220,7 +220,7 @@ describe("memory-wiki cli", () => { ); }); - it("resolves --agent for local commands and requires it with multiple agent vaults", async () => { + it("keeps the parent --agent spelling compatible", async () => { const { rootDir, config } = await createCliVault({ config: { vault: { scope: "agent" } }, }); @@ -237,14 +237,6 @@ describe("memory-wiki cli", () => { }); await expect(fs.stat(path.join(rootDir, "marketing", "index.md"))).resolves.toBeDefined(); - - const missingAgentProgram = new Command(); - missingAgentProgram.name("test"); - missingAgentProgram.exitOverride(); - registerWikiCli(missingAgentProgram, { config, getAppConfig: () => appConfig }); - await expect( - missingAgentProgram.parseAsync(["wiki", "status", "--json"], { from: "user" }), - ).rejects.toThrow("agentId is required for memory-wiki when vault.scope=agent."); }); it("forwards --agent through every bridge Gateway call", async () => { diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 6d96a8d5987a..228c08c701b8 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import type { Command } from "commander"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/memory-host-core"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { isRecord, @@ -985,11 +986,25 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("wiki") .description("Inspect and initialize the memory wiki vault") .option("--agent ", "Agent id for agent-scoped wiki vaults"); - wiki.hook("preAction", () => { - const requestedAgentId = wiki.opts().agent?.trim() || undefined; + wiki.hook("preAction", (_thisCommand, actionCommand) => { + const needsAgent = actionCommand.options.some((option) => option.long === "--agent"); + const requestedAgentId = + actionCommand.opts().agent?.trim() || + wiki.opts().agent?.trim() || + undefined; const currentAppConfig = registration.getAppConfig?.(); - const config = resolveConfig(requestedAgentId, currentAppConfig); - const agentId = config.agentId ?? requestedAgentId; + let agentId = requestedAgentId; + if (needsAgent && registration.config.vault.scope === "agent" && !agentId) { + try { + agentId = resolveDefaultAgentId(currentAppConfig ?? {}); + } catch { + throw new Error( + "No default memory-wiki agent is configured. Pass --agent , or add an agent with `openclaw agents add`.", + ); + } + } + const config = needsAgent ? resolveConfig(agentId, currentAppConfig) : registration.config; + agentId = config.agentId ?? agentId; commandContext = { config, ...(currentAppConfig ? { appConfig: currentAppConfig } : {}), @@ -1000,6 +1015,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg wiki .command("status") .description("Show wiki vault status") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiStatusCommandOptions) => { const { agentId, appConfig, config } = requireCommandContext(); @@ -1009,6 +1025,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg wiki .command("doctor") .description("Audit wiki vault setup and report actionable fixes") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiDoctorCommandOptions) => { const { agentId, appConfig, config } = requireCommandContext(); @@ -1018,6 +1035,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg wiki .command("init") .description("Initialize the wiki vault layout") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiInitCommandOptions) => { const { config } = requireCommandContext(); @@ -1027,6 +1045,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg wiki .command("compile") .description("Refresh generated wiki indexes") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiCompileCommandOptions) => { const { appConfig, config } = requireCommandContext(); @@ -1036,6 +1055,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg wiki .command("lint") .description("Lint the wiki vault and write a report") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiLintCommandOptions) => { const { appConfig, config } = requireCommandContext(); @@ -1046,6 +1066,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("ingest") .description("Ingest a local file into the wiki sources folder") .argument("", "Local file path to ingest") + .option("--agent ", "Agent id (default: configured default agent)") .option("--title ", "Override the source title") .option("--json", "Print JSON") .action(async (inputPath: string, opts: WikiIngestCommandOptions) => { @@ -1058,6 +1079,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("import") .description("Import an unpacked OKF bundle into wiki concept pages") .argument("<path>", "OKF bundle directory") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => { const { config } = requireCommandContext(); @@ -1069,6 +1091,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("search") .description("Search wiki pages and, when configured, the active memory corpus") .argument("<query>", "Search query") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--max-results <n>", "Maximum results", (value: string) => parseWikiPositiveIntegerOption(value, "--max-results"), ) @@ -1095,6 +1118,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("get") .description("Read a wiki page by id or relative path, with optional active-memory fallback") .argument("<lookup>", "Relative path or page id") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--from <n>", "Start line", (value: string) => parseWikiPositiveIntegerOption(value, "--from"), ) @@ -1124,6 +1148,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("synthesis") .description("Create or refresh a synthesis page with managed summary content") .argument("<title>", "Synthesis title") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--body <text>", "Summary body text") .option("--body-file <path>", "Read summary body text from a file"), ) @@ -1148,7 +1173,8 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg apply .command("metadata") .description("Update metadata on an existing page") - .argument("<lookup>", "Relative path or page id"), + .argument("<lookup>", "Relative path or page id") + .option("--agent <id>", "Agent id (default: configured default agent)"), ) .option("--clear-confidence", "Remove any stored confidence value") .option("--json", "Print JSON") @@ -1174,6 +1200,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg bridge .command("import") .description("Sync bridge-backed memory artifacts into wiki source pages") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (opts: WikiBridgeImportCommandOptions) => { const { agentId, appConfig, config } = requireCommandContext(); @@ -1199,6 +1226,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("import") .description("Import a ChatGPT export into draft wiki source pages") .requiredOption("--export <path>", "ChatGPT export directory or conversations.json path") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--dry-run", "Preview changes without writing", false) .option("--json", "Print JSON") .action(async (opts: WikiChatGptImportCommandOptions) => { @@ -1214,6 +1242,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg .command("rollback") .description("Roll back a previously applied ChatGPT import run") .argument("<run-id>", "Import run id") + .option("--agent <id>", "Agent id (default: configured default agent)") .option("--json", "Print JSON") .action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => { const { config } = requireCommandContext();