From 6d9d4d04ed5b9242cca8c56556dd977bdcad0ef9 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 7 Feb 2026 19:48:03 -0800 Subject: [PATCH] Memory/QMD: add configurable search mode --- CHANGELOG.md | 1 + docs/concepts/memory.md | 8 +- src/config/schema.ts | 788 +++++++++++++++++++++++++++++- src/config/types.memory.ts | 2 + src/config/zod-schema.ts | 1 + src/memory/backend-config.test.ts | 15 + src/memory/backend-config.ts | 11 + src/memory/qmd-manager.test.ts | 41 ++ src/memory/qmd-manager.ts | 48 +- 9 files changed, 907 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ee28f800..97a1970cbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07. - Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07. - Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824) +- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 22762bbef0a..273a4cdf924 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -139,8 +139,10 @@ out to QMD for retrieval. Key points: - Boot refresh now runs in the background by default so chat startup is not blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous blocking behavior. -- Searches run via `qmd query --json`, scoped to OpenClaw-managed collections. - If QMD fails or the binary is missing, +- Searches run via `memory.qmd.searchMode` (default `qmd query --json`; also + supports `search` and `vsearch`). If the selected mode rejects flags on your + QMD build, OpenClaw retries with `qmd query`. If QMD fails or the binary is + missing, OpenClaw automatically falls back to the builtin SQLite manager so memory tools keep working. - OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is @@ -177,6 +179,8 @@ out to QMD for retrieval. Key points: **Config surface (`memory.qmd.*`)** - `command` (default `qmd`): override the executable path. +- `searchMode` (default `query`): pick which QMD command backs + `memory_search` (`query`, `search`, `vsearch`). - `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`. - `paths[]`: add extra directories/files (`path`, optional `pattern`, optional stable `name`). diff --git a/src/config/schema.ts b/src/config/schema.ts index 4160403b8d7..d0eb3ce72ab 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,10 +1,19 @@ -import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; -import { applySensitiveHints, buildBaseHints } from "./schema.hints.js"; import { OpenClawSchema } from "./zod-schema.js"; -export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; +export type ConfigUiHint = { + label?: string; + help?: string; + group?: string; + order?: number; + advanced?: boolean; + sensitive?: boolean; + placeholder?: string; + itemTemplate?: unknown; +}; + +export type ConfigUiHints = Record; export type ConfigSchema = ReturnType; @@ -36,6 +45,745 @@ export type ChannelUiMetadata = { configUiHints?: Record; }; +const GROUP_LABELS: Record = { + wizard: "Wizard", + update: "Update", + diagnostics: "Diagnostics", + logging: "Logging", + gateway: "Gateway", + nodeHost: "Node Host", + agents: "Agents", + tools: "Tools", + bindings: "Bindings", + audio: "Audio", + models: "Models", + messages: "Messages", + commands: "Commands", + session: "Session", + cron: "Cron", + hooks: "Hooks", + ui: "UI", + browser: "Browser", + talk: "Talk", + channels: "Messaging Channels", + skills: "Skills", + plugins: "Plugins", + discovery: "Discovery", + presence: "Presence", + voicewake: "Voice Wake", +}; + +const GROUP_ORDER: Record = { + wizard: 20, + update: 25, + diagnostics: 27, + gateway: 30, + nodeHost: 35, + agents: 40, + tools: 50, + bindings: 55, + audio: 60, + models: 70, + messages: 80, + commands: 85, + session: 90, + cron: 100, + hooks: 110, + ui: 120, + browser: 130, + talk: 140, + channels: 150, + skills: 200, + plugins: 205, + discovery: 210, + presence: 220, + voicewake: 230, + logging: 900, +}; + +const FIELD_LABELS: Record = { + "meta.lastTouchedVersion": "Config Last Touched Version", + "meta.lastTouchedAt": "Config Last Touched At", + "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", + "diagnostics.enabled": "Diagnostics Enabled", + "diagnostics.flags": "Diagnostics Flags", + "diagnostics.otel.enabled": "OpenTelemetry Enabled", + "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", + "diagnostics.otel.protocol": "OpenTelemetry Protocol", + "diagnostics.otel.headers": "OpenTelemetry Headers", + "diagnostics.otel.serviceName": "OpenTelemetry Service Name", + "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", + "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", + "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", + "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", + "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", + "gateway.remote.url": "Remote Gateway URL", + "gateway.remote.sshTarget": "Remote Gateway SSH Target", + "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", + "gateway.remote.token": "Remote Gateway Token", + "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", + "gateway.auth.token": "Gateway Token", + "gateway.auth.password": "Gateway Password", + "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.maxBytes": "Image Understanding Max Bytes", + "tools.media.image.maxChars": "Image Understanding Max Chars", + "tools.media.image.prompt": "Image Understanding Prompt", + "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", + "tools.media.image.attachments": "Image Understanding Attachment Policy", + "tools.media.image.models": "Image Understanding Models", + "tools.media.image.scope": "Image Understanding Scope", + "tools.media.models": "Media Understanding Shared Models", + "tools.media.concurrency": "Media Understanding Concurrency", + "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", + "tools.media.audio.maxChars": "Audio Understanding Max Chars", + "tools.media.audio.prompt": "Audio Understanding Prompt", + "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", + "tools.media.audio.language": "Audio Understanding Language", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", + "tools.media.audio.models": "Audio Understanding Models", + "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.maxBytes": "Video Understanding Max Bytes", + "tools.media.video.maxChars": "Video Understanding Max Chars", + "tools.media.video.prompt": "Video Understanding Prompt", + "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", + "tools.media.video.attachments": "Video Understanding Attachment Policy", + "tools.media.video.models": "Video Understanding Models", + "tools.media.video.scope": "Video Understanding Scope", + "tools.links.enabled": "Enable Link Understanding", + "tools.links.maxLinks": "Link Understanding Max Links", + "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", + "tools.links.models": "Link Understanding Models", + "tools.links.scope": "Link Understanding Scope", + "tools.profile": "Tool Profile", + "tools.alsoAllow": "Tool Allowlist Additions", + "agents.list[].tools.profile": "Agent Tool Profile", + "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", + "tools.exec.applyPatch.enabled": "Enable apply_patch", + "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", + "tools.exec.safeBins": "Exec Safe Bins", + "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", + "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", + "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", + "tools.message.crossContext.marker.enabled": "Cross-Context Marker", + "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", + "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", + "tools.message.broadcast.enabled": "Enable Message Broadcast", + "tools.web.search.enabled": "Enable Web Search Tool", + "tools.web.search.provider": "Web Search Provider", + "tools.web.search.apiKey": "Brave Search API Key", + "tools.web.search.maxResults": "Web Search Max Results", + "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", + "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", + "tools.web.fetch.enabled": "Enable Web Fetch Tool", + "tools.web.fetch.maxChars": "Web Fetch Max Chars", + "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", + "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", + "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", + "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", + "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.memorySearch": "Memory Search", + "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Memory Search Session Index (Experimental)", + "agents.defaults.memorySearch.provider": "Memory Search Provider", + "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", + "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", + "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", + "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", + "agents.defaults.memorySearch.model": "Memory Search Model", + "agents.defaults.memorySearch.fallback": "Memory Search Fallback", + "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", + "agents.defaults.memorySearch.store.path": "Memory Search Index Path", + "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", + "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", + "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", + "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", + "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", + "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", + "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", + "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", + "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", + "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", + "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", + "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Memory Search Hybrid Candidate Multiplier", + "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", + memory: "Memory", + "memory.backend": "Memory Backend", + "memory.citations": "Memory Citations Mode", + "memory.qmd.command": "QMD Binary", + "memory.qmd.searchMode": "QMD Search Mode", + "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", + "memory.qmd.paths": "QMD Extra Paths", + "memory.qmd.paths.path": "QMD Path", + "memory.qmd.paths.pattern": "QMD Path Pattern", + "memory.qmd.paths.name": "QMD Path Name", + "memory.qmd.sessions.enabled": "QMD Session Indexing", + "memory.qmd.sessions.exportDir": "QMD Session Export Directory", + "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", + "memory.qmd.update.interval": "QMD Update Interval", + "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", + "memory.qmd.update.onBoot": "QMD Update on Startup", + "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.embedInterval": "QMD Embed Interval", + "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", + "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", + "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", + "memory.qmd.limits.maxResults": "QMD Max Results", + "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", + "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", + "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", + "memory.qmd.scope": "QMD Surface Scope", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", + "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", + "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", + "auth.cooldowns.failureWindowHours": "Failover Window (hours)", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.humanDelay.mode": "Human Delay Mode", + "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", + "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", + "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", + "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", + "commands.config": "Allow /config", + "commands.debug": "Allow /debug", + "commands.restart": "Allow Restart", + "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", + "ui.seamColor": "Accent Color", + "ui.assistant.name": "Assistant Name", + "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", + "browser.snapshotDefaults": "Browser Snapshot Defaults", + "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", + "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", + "session.dmScope": "DM Session Scope", + "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", + "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", + "talk.apiKey": "Talk API Key", + "channels.whatsapp": "WhatsApp", + "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", + "channels.discord": "Discord", + "channels.slack": "Slack", + "channels.mattermost": "Mattermost", + "channels.signal": "Signal", + "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", + "channels.msteams": "MS Teams", + "channels.telegram.botToken": "Telegram Bot Token", + "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.streamMode": "Telegram Draft Stream Mode", + "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", + "channels.telegram.retry.attempts": "Telegram Retry Attempts", + "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", + "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", + "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", + "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", + "channels.signal.dmPolicy": "Signal DM Policy", + "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dm.policy": "Discord DM Policy", + "channels.discord.retry.attempts": "Discord Retry Attempts", + "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", + "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", + "channels.discord.retry.jitter": "Discord Retry Jitter", + "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", + "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.token": "Discord Bot Token", + "channels.slack.botToken": "Slack Bot Token", + "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.thread.historyScope": "Slack Thread History Scope", + "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.mattermost.botToken": "Mattermost Bot Token", + "channels.mattermost.baseUrl": "Mattermost Base URL", + "channels.mattermost.chatmode": "Mattermost Chat Mode", + "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", + "channels.mattermost.requireMention": "Mattermost Require Mention", + "channels.signal.account": "Signal Account", + "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", + "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", + "plugins.enabled": "Enable Plugins", + "plugins.allow": "Plugin Allowlist", + "plugins.deny": "Plugin Denylist", + "plugins.load.paths": "Plugin Load Paths", + "plugins.slots": "Plugin Slots", + "plugins.slots.memory": "Memory Plugin", + "plugins.entries": "Plugin Entries", + "plugins.entries.*.enabled": "Plugin Enabled", + "plugins.entries.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", +}; + +const FIELD_HELP: Record = { + "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", + "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", + "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', + "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", + "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", + "gateway.remote.tlsFingerprint": + "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", + "gateway.remote.sshTarget": + "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", + "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list.*.skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].identity.avatar": + "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "discovery.mdns.mode": + 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', + "gateway.auth.token": + "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", + "gateway.auth.password": "Required for Tailscale funnel.", + "gateway.controlUi.basePath": + "Optional URL prefix where the Control UI is served (e.g. /openclaw).", + "gateway.controlUi.root": + "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", + "gateway.controlUi.allowedOrigins": + "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", + "gateway.controlUi.allowInsecureAuth": + "Allow Control UI auth over insecure HTTP (token-only; not recommended).", + "gateway.controlUi.dangerouslyDisableDeviceAuth": + "DANGEROUS. Disable Control UI device identity checks (token/password only).", + "gateway.http.endpoints.chatCompletions.enabled": + "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", + "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', + "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.browser.mode": + 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', + "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", + "gateway.nodes.allowCommands": + "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", + "gateway.nodes.denyCommands": + "Commands to block even if present in node claims or default allowlist.", + "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", + "nodeHost.browserProxy.allowProfiles": + "Optional allowlist of browser profile names exposed via the node proxy.", + "diagnostics.flags": + 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', + "diagnostics.cacheTrace.enabled": + "Log cache trace snapshots for embedded agent runs (default: false).", + "diagnostics.cacheTrace.filePath": + "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", + "diagnostics.cacheTrace.includeMessages": + "Include full message payloads in trace output (default: true).", + "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", + "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", + "tools.exec.applyPatch.enabled": + "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", + "tools.exec.applyPatch.allowModels": + 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', + "tools.exec.notifyOnExit": + "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", + "tools.exec.safeBins": + "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.message.allowCrossContextSend": + "Legacy override: allow cross-context sends across all providers.", + "tools.message.crossContext.allowWithinProvider": + "Allow sends to other channels within the same provider (default: true).", + "tools.message.crossContext.allowAcrossProviders": + "Allow sends across different providers (default: false).", + "tools.message.crossContext.marker.enabled": + "Add a visible origin marker when sending cross-context (default: true).", + "tools.message.crossContext.marker.prefix": + 'Text prefix for cross-context markers (supports "{channel}").', + "tools.message.crossContext.marker.suffix": + 'Text suffix for cross-context markers (supports "{channel}").', + "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", + "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", + "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", + "tools.web.search.maxResults": "Default number of results to return (1-10).", + "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", + "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.perplexity.apiKey": + "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", + "tools.web.search.perplexity.baseUrl": + "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", + "tools.web.search.perplexity.model": + 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", + "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", + "tools.web.fetch.maxCharsCap": + "Hard cap for web_fetch maxChars (applies to config and tool calls).", + "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", + "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", + "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", + "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", + "tools.web.fetch.readability": + "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", + "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", + "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.fetch.firecrawl.baseUrl": + "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", + "tools.web.fetch.firecrawl.onlyMainContent": + "When true, Firecrawl returns only the main content (default: true).", + "tools.web.fetch.firecrawl.maxAgeMs": + "Firecrawl maxAge (ms) for cached results when supported by the API.", + "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "channels.slack.allowBots": + "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.slack.thread.historyScope": + 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', + "channels.slack.thread.inheritParent": + "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.mattermost.botToken": + "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", + "channels.mattermost.baseUrl": + "Base URL for your Mattermost server (e.g., https://chat.example.com).", + "channels.mattermost.chatmode": + 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', + "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', + "channels.mattermost.requireMention": + "Require @mention in channels before responding (default: true).", + "auth.profiles": "Named auth profiles (provider + mode + optional email).", + "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", + "auth.cooldowns.billingBackoffHours": + "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", + "auth.cooldowns.billingBackoffHoursByProvider": + "Optional per-provider overrides for billing backoff (hours).", + "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", + "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", + "agents.defaults.bootstrapMaxChars": + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.repoRoot": + "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', + "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", + "agents.defaults.memorySearch": + "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "agents.defaults.memorySearch.sources": + 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Enable experimental session transcript indexing for memory search (default: false).", + "agents.defaults.memorySearch.provider": + 'Embedding provider ("openai", "gemini", "voyage", or "local").', + "agents.defaults.memorySearch.remote.baseUrl": + "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", + "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", + "agents.defaults.memorySearch.remote.headers": + "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", + "agents.defaults.memorySearch.remote.batch.enabled": + "Enable batch API for memory embeddings (OpenAI/Gemini; default: true).", + "agents.defaults.memorySearch.remote.batch.wait": + "Wait for batch completion when indexing (default: true).", + "agents.defaults.memorySearch.remote.batch.concurrency": + "Max concurrent embedding batch jobs for memory indexing (default: 2).", + "agents.defaults.memorySearch.remote.batch.pollIntervalMs": + "Polling interval in ms for batch status (default: 2000).", + "agents.defaults.memorySearch.remote.batch.timeoutMinutes": + "Timeout in minutes for batch indexing (default: 60).", + "agents.defaults.memorySearch.local.modelPath": + "Local GGUF model path or hf: URI (node-llama-cpp).", + "agents.defaults.memorySearch.fallback": + 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', + "agents.defaults.memorySearch.store.path": + "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", + "agents.defaults.memorySearch.store.vector.enabled": + "Enable sqlite-vec extension for vector search (default: true).", + "agents.defaults.memorySearch.store.vector.extensionPath": + "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", + "agents.defaults.memorySearch.query.hybrid.enabled": + "Enable hybrid BM25 + vector search for memory (default: true).", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": + "Weight for vector similarity when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.textWeight": + "Weight for BM25 text relevance when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Multiplier for candidate pool size (default: 4).", + "agents.defaults.memorySearch.cache.enabled": + "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", + memory: "Memory backend configuration (global).", + "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', + "memory.citations": 'Default citation behavior ("auto", "on", or "off").', + "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", + "memory.qmd.searchMode": + 'QMD search command used for memory recall ("query", "search", or "vsearch"; default: "query").', + "memory.qmd.includeDefaultMemory": + "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", + "memory.qmd.paths": + "Additional directories/files to index with QMD (path + optional glob pattern).", + "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", + "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", + "memory.qmd.paths.name": + "Optional stable name for the QMD collection (default derived from path).", + "memory.qmd.sessions.enabled": + "Enable QMD session transcript indexing (experimental, default: false).", + "memory.qmd.sessions.exportDir": + "Override directory for sanitized session exports before indexing.", + "memory.qmd.sessions.retentionDays": + "Retention window for exported sessions before pruning (default: unlimited).", + "memory.qmd.update.interval": + "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", + "memory.qmd.update.debounceMs": + "Minimum delay between successive QMD refresh runs (default: 15000).", + "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", + "memory.qmd.update.waitForBootSync": + "Block startup until the boot QMD refresh finishes (default: false).", + "memory.qmd.update.embedInterval": + "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", + "memory.qmd.update.commandTimeoutMs": + "Timeout for QMD maintenance commands like collection list/add (default: 30000).", + "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", + "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", + "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", + "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", + "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", + "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", + "memory.qmd.scope": + "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", + "agents.defaults.memorySearch.cache.maxEntries": + "Optional cap on cached embeddings (best-effort).", + "agents.defaults.memorySearch.sync.onSearch": + "Lazy sync: schedule a reindex on search after changes.", + "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": + "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": + "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", + "plugins.enabled": "Enable plugin/extension loading (default: true).", + "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", + "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", + "plugins.load.paths": "Additional plugin files or directories to load.", + "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", + "plugins.slots.memory": + 'Select the active memory plugin by id, or "none" to disable memory plugins.', + "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", + "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", + "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", + "plugins.installs": + "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', + "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", + "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", + "plugins.installs.*.installPath": + "Resolved install directory (usually ~/.openclaw/extensions/).", + "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "agents.list.*.identity.avatar": + "Agent avatar (workspace-relative path, http(s) URL, or data URI).", + "agents.defaults.model.primary": "Primary model (provider/model).", + "agents.defaults.model.fallbacks": + "Ordered fallback models (provider/model). Used when the primary model fails.", + "agents.defaults.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", + "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', + "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", + "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "commands.native": + "Register native commands with channels that support it (Discord/Slack/Telegram).", + "commands.nativeSkills": + "Register native skill commands (user-invocable skills) with channels that support it.", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", + "commands.config": "Allow /config chat command to read/write config on disk (default: false).", + "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", + "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", + "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.ownerAllowFrom": + "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", + "session.dmScope": + 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', + "session.identityLinks": + "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", + "channels.telegram.configWrites": + "Allow Telegram to write config in response to channel events/commands (default: true).", + "channels.slack.configWrites": + "Allow Slack to write config in response to channel events/commands (default: true).", + "channels.mattermost.configWrites": + "Allow Mattermost to write config in response to channel events/commands (default: true).", + "channels.discord.configWrites": + "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.whatsapp.configWrites": + "Allow WhatsApp to write config in response to channel events/commands (default: true).", + "channels.signal.configWrites": + "Allow Signal to write config in response to channel events/commands (default: true).", + "channels.imessage.configWrites": + "Allow iMessage to write config in response to channel events/commands (default: true).", + "channels.msteams.configWrites": + "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', + "channels.discord.commands.nativeSkills": + 'Override native skill commands for Discord (bool or "auto").', + "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', + "channels.telegram.commands.nativeSkills": + 'Override native skill commands for Telegram (bool or "auto").', + "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', + "channels.slack.commands.nativeSkills": + 'Override native skill commands for Slack (bool or "auto").', + "session.agentToAgent.maxPingPongTurns": + "Max reply-back turns between requester and target (0–5).", + "channels.telegram.customCommands": + "Additional Telegram bot menu commands (merged with native; conflicts ignored).", + "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "messages.inbound.debounceMs": + "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", + "channels.telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', + "channels.telegram.streamMode": + "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "channels.telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', + "channels.telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', + "channels.telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", + "channels.telegram.retry.attempts": + "Max retry attempts for outbound Telegram API calls (default: 3).", + "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", + "channels.telegram.retry.maxDelayMs": + "Maximum retry delay cap in ms for Telegram outbound calls.", + "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", + "channels.telegram.timeoutSeconds": + "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', + "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", + "channels.whatsapp.debounceMs": + "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", + "channels.signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', + "channels.imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.bluebubbles.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', + "channels.discord.retry.attempts": + "Max retry attempts for outbound Discord API calls (default: 3).", + "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", + "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", + "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", + "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", + "channels.discord.pluralkit.enabled": + "Resolve PluralKit proxied messages and treat system members as distinct senders.", + "channels.discord.pluralkit.token": + "Optional PluralKit token for resolving private systems or members.", + "channels.slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', +}; + +const FIELD_PLACEHOLDERS: Record = { + "gateway.remote.url": "ws://host:18789", + "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", + "gateway.remote.sshTarget": "user@host", + "gateway.controlUi.basePath": "/openclaw", + "gateway.controlUi.root": "dist/control-ui", + "gateway.controlUi.allowedOrigins": "https://control.example.com", + "channels.mattermost.baseUrl": "https://chat.example.com", + "agents.list[].identity.avatar": "avatars/openclaw.png", +}; + +const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; + +function isSensitivePath(path: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} + type JsonSchemaObject = JsonSchemaNode & { type?: string | string[]; properties?: Record; @@ -88,6 +836,40 @@ function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): return merged; } +function buildBaseHints(): ConfigUiHints { + const hints: ConfigUiHints = {}; + for (const [group, label] of Object.entries(GROUP_LABELS)) { + hints[group] = { + label, + group: label, + order: GROUP_ORDER[group], + }; + } + for (const [path, label] of Object.entries(FIELD_LABELS)) { + const current = hints[path]; + hints[path] = current ? { ...current, label } : { label }; + } + for (const [path, help] of Object.entries(FIELD_HELP)) { + const current = hints[path]; + hints[path] = current ? { ...current, help } : { help }; + } + for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { + const current = hints[path]; + hints[path] = current ? { ...current, placeholder } : { placeholder }; + } + return hints; +} + +function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints { + const next = { ...hints }; + for (const key of Object.keys(next)) { + if (isSensitivePath(key)) { + next[key] = { ...next[key], sensitive: true }; + } + } + return next; +} + function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints { const next: ConfigUiHints = { ...hints }; for (const plugin of plugins) { diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index ca53e2d8482..74479baaaa4 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -2,6 +2,7 @@ import type { SessionSendPolicyConfig } from "./types.base.js"; export type MemoryBackend = "builtin" | "qmd"; export type MemoryCitationsMode = "auto" | "on" | "off"; +export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; export type MemoryConfig = { backend?: MemoryBackend; @@ -11,6 +12,7 @@ export type MemoryConfig = { export type MemoryQmdConfig = { command?: string; + searchMode?: MemoryQmdSearchMode; includeDefaultMemory?: boolean; paths?: MemoryQmdIndexPath[]; sessions?: MemoryQmdSessionConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 604a6ea3157..a1e3004a6d6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -73,6 +73,7 @@ const MemoryQmdLimitsSchema = z const MemoryQmdSchema = z .object({ command: z.string().optional(), + searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).optional(), includeDefaultMemory: z.boolean().optional(), paths: z.array(MemoryQmdPathSchema).optional(), sessions: MemoryQmdSessionSchema.optional(), diff --git a/src/memory/backend-config.test.ts b/src/memory/backend-config.test.ts index 55b4a3bed32..c31c165d30a 100644 --- a/src/memory/backend-config.test.ts +++ b/src/memory/backend-config.test.ts @@ -25,6 +25,7 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.backend).toBe("qmd"); expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3); expect(resolved.qmd?.command).toBe("qmd"); + expect(resolved.qmd?.searchMode).toBe("query"); expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); expect(resolved.qmd?.update.waitForBootSync).toBe(false); expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); @@ -93,4 +94,18 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000); expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000); }); + + it("resolves qmd search mode override", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: { + searchMode: "vsearch", + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + expect(resolved.qmd?.searchMode).toBe("vsearch"); + }); }); diff --git a/src/memory/backend-config.ts b/src/memory/backend-config.ts index 0e48f6bff87..e08b157a069 100644 --- a/src/memory/backend-config.ts +++ b/src/memory/backend-config.ts @@ -6,6 +6,7 @@ import type { MemoryCitationsMode, MemoryQmdConfig, MemoryQmdIndexPath, + MemoryQmdSearchMode, } from "../config/types.memory.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { parseDurationMs } from "../cli/parse-duration.js"; @@ -51,6 +52,7 @@ export type ResolvedQmdSessionConfig = { export type ResolvedQmdConfig = { command: string; + searchMode: MemoryQmdSearchMode; collections: ResolvedQmdCollection[]; sessions: ResolvedQmdSessionConfig; update: ResolvedQmdUpdateConfig; @@ -64,6 +66,7 @@ const DEFAULT_CITATIONS: MemoryCitationsMode = "auto"; const DEFAULT_QMD_INTERVAL = "5m"; const DEFAULT_QMD_DEBOUNCE_MS = 15_000; const DEFAULT_QMD_TIMEOUT_MS = 4_000; +const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "query"; const DEFAULT_QMD_EMBED_INTERVAL = "60m"; const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000; const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000; @@ -171,6 +174,13 @@ function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig return parsed; } +function resolveSearchMode(raw?: MemoryQmdConfig["searchMode"]): MemoryQmdSearchMode { + if (raw === "search" || raw === "vsearch" || raw === "query") { + return raw; + } + return DEFAULT_QMD_SEARCH_MODE; +} + function resolveSessionConfig( cfg: MemoryQmdConfig["sessions"], workspaceDir: string, @@ -265,6 +275,7 @@ export function resolveMemoryBackendConfig(params: { const command = parsedCommand?.[0] || rawCommand.split(/\s+/)[0] || "qmd"; const resolved: ResolvedQmdConfig = { command, + searchMode: resolveSearchMode(qmdCfg?.searchMode), collections, includeDefaultMemory, sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir), diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index bcf0e142de9..55f16ea2fad 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -285,6 +285,47 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("uses configured qmd search mode command", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + searchMode: "search", + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stdout.emit("data", "[]"); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + + expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "search")).toBe(true); + expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false); + await manager.close(); + }); + it("queues a forced sync behind an in-flight update", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index c3b985ecf2f..14fb6d4f1d2 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -260,7 +260,8 @@ export class QmdMemoryManager implements MemorySearchManager { log.warn("qmd query skipped: no managed collections configured"); return []; } - const args = ["query", trimmed, "--json", "-n", String(limit), ...collectionFilterArgs]; + const qmdSearchCommand = this.qmd.searchMode; + const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit, collectionFilterArgs); let stdout: string; let stderr: string; try { @@ -268,8 +269,25 @@ export class QmdMemoryManager implements MemorySearchManager { stdout = result.stdout; stderr = result.stderr; } catch (err) { - log.warn(`qmd query failed: ${String(err)}`); - throw err instanceof Error ? err : new Error(String(err)); + if (qmdSearchCommand !== "query" && this.isUnsupportedQmdOptionError(err)) { + log.warn( + `qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`, + ); + try { + const fallback = await this.runQmd( + this.buildSearchArgs("query", trimmed, limit, collectionFilterArgs), + { timeoutMs: this.qmd.limits.timeoutMs }, + ); + stdout = fallback.stdout; + stderr = fallback.stderr; + } catch (fallbackErr) { + log.warn(`qmd query fallback failed: ${String(fallbackErr)}`); + throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr)); + } + } else { + log.warn(`qmd ${qmdSearchCommand} failed: ${String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } } const parsed = parseQmdQueryJson(stdout, stderr); const results: MemorySearchResult[] = []; @@ -953,6 +971,18 @@ export class QmdMemoryManager implements MemorySearchManager { return normalized.includes("sqlite_busy") || normalized.includes("database is locked"); } + private isUnsupportedQmdOptionError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const normalized = message.toLowerCase(); + return ( + normalized.includes("unknown flag") || + normalized.includes("unknown option") || + normalized.includes("unrecognized option") || + normalized.includes("flag provided but not defined") || + normalized.includes("unexpected argument") + ); + } + private createQmdBusyError(err: unknown): Error { const message = err instanceof Error ? err.message : String(err); return new Error(`qmd index busy while reading results: ${message}`); @@ -976,4 +1006,16 @@ export class QmdMemoryManager implements MemorySearchManager { } return names.flatMap((name) => ["-c", name]); } + + private buildSearchArgs( + command: "query" | "search" | "vsearch", + query: string, + limit: number, + collectionFilterArgs: string[], + ): string[] { + if (command === "query") { + return ["query", query, "--json", "-n", String(limit), ...collectionFilterArgs]; + } + return [command, query, "--json"]; + } }