From c643ce2a7a85f18ab67f523846f3628b3b757aac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 16:38:52 +0100 Subject: [PATCH] feat: add /debug runtime overrides --- CHANGELOG.md | 1 + docs/cli/index.md | 8 ++ docs/debugging.md | 16 +++ docs/tools/slash-commands.md | 19 ++++ src/auto-reply/commands-registry.test.ts | 1 + src/auto-reply/commands-registry.ts | 7 ++ src/auto-reply/reply/commands.ts | 82 ++++++++++++++ src/auto-reply/reply/debug-commands.test.ts | 21 ++++ src/auto-reply/reply/debug-commands.ts | 98 +++++++++++++++++ src/auto-reply/status.ts | 2 +- src/config/config.ts | 1 + src/config/io.ts | 3 +- src/config/runtime-overrides.test.ts | 43 ++++++++ src/config/runtime-overrides.ts | 112 ++++++++++++++++++++ 14 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 src/auto-reply/reply/debug-commands.test.ts create mode 100644 src/auto-reply/reply/debug-commands.ts create mode 100644 src/config/runtime-overrides.test.ts create mode 100644 src/config/runtime-overrides.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a49e5d5..713ff04dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker). - Onboarding: clarify WhatsApp owner number prompt and label pairing phone number. - Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes. +- Commands: add /debug for runtime config overrides (memory-only). - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. - Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order. diff --git a/docs/cli/index.md b/docs/cli/index.md index 730ad9397..272a3a64f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -147,6 +147,14 @@ clawdbot [--dev] [--profile ] tui ``` +## Chat slash commands + +Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands). + +Highlights: +- `/status` for quick diagnostics. +- `/debug` for runtime-only config overrides (memory, not disk). + ## Setup + onboarding ### `setup` diff --git a/docs/debugging.md b/docs/debugging.md index f4e43e0b1..8cd109ec5 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -11,6 +11,22 @@ read_when: This page covers debugging helpers for streaming output, especially when a provider mixes reasoning into normal text. +## Runtime debug overrides + +Use `/debug` in chat to set **runtime-only** config overrides (memory, not disk). +This is handy when you need to toggle obscure settings without editing `clawdbot.json`. + +Examples: + +``` +/debug show +/debug set messages.responsePrefix="[clawdbot]" +/debug unset messages.responsePrefix +/debug reset +``` + +`/debug reset` clears all overrides and returns to the on-disk config. + ## Gateway watch mode For fast iteration, run the gateway under the file watcher: diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index c17e7dc6b..f35c0db70 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -36,6 +36,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/status` +- `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` @@ -59,6 +60,24 @@ Notes: - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. +## Debug overrides + +`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only. + +Examples: + +``` +/debug show +/debug set messages.responsePrefix="[clawdbot]" +/debug set whatsapp.allowFrom=["+1555","+4477"] +/debug unset messages.responsePrefix +/debug reset +``` + +Notes: +- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`. +- Use `/debug reset` to clear all overrides and return to the on-disk config. + ## Surface notes - **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index b2ac9ad5d..fc270ab08 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -26,6 +26,7 @@ describe("commands registry", () => { expect(detection.regex.test("/status:")).toBe(true); expect(detection.regex.test("/stop")).toBe(true); expect(detection.regex.test("/send:")).toBe(true); + expect(detection.regex.test("/debug set foo=bar")).toBe(true); expect(detection.regex.test("/models")).toBe(true); expect(detection.regex.test("/models list")).toBe(true); expect(detection.regex.test("try /status")).toBe(false); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index ae7c842e2..4f3ec4ef4 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Show current status.", textAliases: ["/status"], }, + { + key: "debug", + nativeName: "debug", + description: "Set runtime debug overrides.", + textAliases: ["/debug"], + acceptsArgs: true, + }, { key: "cost", nativeName: "cost", diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 65a36f203..d996f6569 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -40,6 +40,12 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeE164 } from "../../utils.js"; +import { + getConfigOverrides, + resetConfigOverrides, + setConfigOverride, + unsetConfigOverride, +} from "../../config/runtime-overrides.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import { normalizeCommandBody, @@ -65,6 +71,7 @@ import type { } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; +import { parseDebugCommand } from "./debug-commands.js"; import type { InlineDirectives } from "./directive-handling.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; @@ -609,6 +616,81 @@ export async function handleCommands(params: { return { shouldContinue: false, reply }; } + const debugCommand = allowTextCommands + ? parseDebugCommand(command.commandBodyNormalized) + : null; + if (debugCommand) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /debug from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + if (debugCommand.action === "error") { + return { shouldContinue: false, reply: { text: `⚠️ ${debugCommand.message}` } }; + } + if (debugCommand.action === "show") { + const overrides = getConfigOverrides(); + const hasOverrides = Object.keys(overrides).length > 0; + if (!hasOverrides) { + return { + shouldContinue: false, + reply: { text: "⚙️ Debug overrides: (none)" }, + }; + } + const json = JSON.stringify(overrides, null, 2); + return { + shouldContinue: false, + reply: { text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`` }, + }; + } + if (debugCommand.action === "reset") { + resetConfigOverrides(); + return { + shouldContinue: false, + reply: { text: "⚙️ Debug overrides cleared; using config on disk." }, + }; + } + if (debugCommand.action === "unset") { + const result = unsetConfigOverride(debugCommand.path); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error ?? "Invalid path."}` }, + }; + } + if (!result.removed) { + return { + shouldContinue: false, + reply: { text: `⚙️ No debug override found for ${debugCommand.path}.` }, + }; + } + return { + shouldContinue: false, + reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` }, + }; + } + if (debugCommand.action === "set") { + const result = setConfigOverride(debugCommand.path, debugCommand.value); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error ?? "Invalid override."}` }, + }; + } + const valueLabel = + typeof debugCommand.value === "string" + ? `"${debugCommand.value}"` + : JSON.stringify(debugCommand.value); + return { + shouldContinue: false, + reply: { + text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`, + }, + }; + } + } + const stopRequested = command.commandBodyNormalized === "/stop"; if (allowTextCommands && stopRequested) { if (!command.isAuthorizedSender) { diff --git a/src/auto-reply/reply/debug-commands.test.ts b/src/auto-reply/reply/debug-commands.test.ts new file mode 100644 index 000000000..8c2094520 --- /dev/null +++ b/src/auto-reply/reply/debug-commands.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { parseDebugCommand } from "./debug-commands.js"; + +describe("parseDebugCommand", () => { + it("parses show/reset", () => { + expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); + }); + + it("parses set with JSON", () => { + const cmd = parseDebugCommand('/debug set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); + + it("parses unset", () => { + const cmd = parseDebugCommand("/debug unset foo.bar"); + expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); + }); +}); diff --git a/src/auto-reply/reply/debug-commands.ts b/src/auto-reply/reply/debug-commands.ts new file mode 100644 index 000000000..cda091d47 --- /dev/null +++ b/src/auto-reply/reply/debug-commands.ts @@ -0,0 +1,98 @@ +export type DebugCommand = + | { action: "show" } + | { action: "reset" } + | { action: "set"; path: string; value: unknown } + | { action: "unset"; path: string } + | { action: "error"; message: string }; + +function parseDebugValue(raw: string): { value?: unknown; error?: string } { + const trimmed = raw.trim(); + if (!trimmed) return { error: "Missing value." }; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return { value: JSON.parse(trimmed) }; + } catch (err) { + return { error: `Invalid JSON: ${String(err)}` }; + } + } + + if (trimmed === "true") return { value: true }; + if (trimmed === "false") return { value: false }; + if (trimmed === "null") return { value: null }; + + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const num = Number(trimmed); + if (Number.isFinite(num)) return { value: num }; + } + + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + try { + return { value: JSON.parse(trimmed) }; + } catch { + const unquoted = trimmed.slice(1, -1); + return { value: unquoted }; + } + } + + return { value: trimmed }; +} + +export function parseDebugCommand(raw: string): DebugCommand | null { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith("/debug")) return null; + const rest = trimmed.slice("/debug".length).trim(); + if (!rest) return { action: "show" }; + + const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); + if (!match) return { action: "error", message: "Invalid /debug syntax." }; + const action = match[1].toLowerCase(); + const args = (match[2] ?? "").trim(); + + switch (action) { + case "show": + return { action: "show" }; + case "reset": + return { action: "reset" }; + case "unset": { + if (!args) return { action: "error", message: "Usage: /debug unset path" }; + return { action: "unset", path: args }; + } + case "set": { + if (!args) { + return { + action: "error", + message: "Usage: /debug set path=value", + }; + } + const eqIndex = args.indexOf("="); + if (eqIndex <= 0) { + return { + action: "error", + message: "Usage: /debug set path=value", + }; + } + const path = args.slice(0, eqIndex).trim(); + const rawValue = args.slice(eqIndex + 1); + if (!path) { + return { + action: "error", + message: "Usage: /debug set path=value", + }; + } + const parsed = parseDebugValue(rawValue); + if (parsed.error) { + return { action: "error", message: parsed.error }; + } + return { action: "set", path, value: parsed.value }; + } + default: + return { + action: "error", + message: "Usage: /debug show|set|unset|reset", + }; + } +} diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a9323df6a..9b9f96520 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -357,6 +357,6 @@ export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)", - "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off | /debug show", ].join("\n"); } diff --git a/src/config/config.ts b/src/config/config.ts index e9520f83c..30469b400 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -7,6 +7,7 @@ export { } from "./io.js"; export { migrateLegacyConfig } from "./legacy-migrate.js"; export * from "./paths.js"; +export * from "./runtime-overrides.js"; export * from "./types.js"; export { validateConfigObject } from "./validation.js"; export { ClawdbotSchema } from "./zod-schema.js"; diff --git a/src/config/io.ts b/src/config/io.ts index fd9a920db..f2b7c645d 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -26,6 +26,7 @@ import { resolveConfigPath, resolveStateDir, } from "./paths.js"; +import { applyConfigOverrides } from "./runtime-overrides.js"; import type { ClawdbotConfig, ConfigFileSnapshot, @@ -195,7 +196,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }); } - return cfg; + return applyConfigOverrides(cfg); } catch (err) { if (err instanceof DuplicateAgentDirError) { deps.logger.error(err.message); diff --git a/src/config/runtime-overrides.test.ts b/src/config/runtime-overrides.test.ts new file mode 100644 index 000000000..98a7703c6 --- /dev/null +++ b/src/config/runtime-overrides.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import type { ClawdbotConfig } from "./types.js"; +import { + applyConfigOverrides, + getConfigOverrides, + resetConfigOverrides, + setConfigOverride, + unsetConfigOverride, +} from "./runtime-overrides.js"; + +describe("runtime overrides", () => { + beforeEach(() => { + resetConfigOverrides(); + }); + + it("sets and applies nested overrides", () => { + const cfg = { + messages: { responsePrefix: "[clawdbot]" }, + } as ClawdbotConfig; + setConfigOverride("messages.responsePrefix", "[debug]"); + const next = applyConfigOverrides(cfg); + expect(next.messages?.responsePrefix).toBe("[debug]"); + }); + + it("merges object overrides without clobbering siblings", () => { + const cfg = { + whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] }, + } as ClawdbotConfig; + setConfigOverride("whatsapp.dmPolicy", "open"); + const next = applyConfigOverrides(cfg); + expect(next.whatsapp?.dmPolicy).toBe("open"); + expect(next.whatsapp?.allowFrom).toEqual(["+1"]); + }); + + it("unsets overrides and prunes empty branches", () => { + setConfigOverride("whatsapp.dmPolicy", "open"); + const removed = unsetConfigOverride("whatsapp.dmPolicy"); + expect(removed.ok).toBe(true); + expect(removed.removed).toBe(true); + expect(Object.keys(getConfigOverrides()).length).toBe(0); + }); +}); diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts new file mode 100644 index 000000000..a473f9949 --- /dev/null +++ b/src/config/runtime-overrides.ts @@ -0,0 +1,112 @@ +import type { ClawdbotConfig } from "./types.js"; + +type OverrideTree = Record; + +let overrides: OverrideTree = {}; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +function parsePath(raw: string): string[] | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const parts = trimmed.split(".").map((part) => part.trim()); + if (parts.some((part) => !part)) return null; + return parts; +} + +function setOverrideAtPath( + root: OverrideTree, + path: string[], + value: unknown, +): void { + let cursor: OverrideTree = root; + for (let idx = 0; idx < path.length - 1; idx += 1) { + const key = path[idx]; + const next = cursor[key]; + if (!isPlainObject(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as OverrideTree; + } + cursor[path[path.length - 1]] = value; +} + +function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean { + const stack: Array<{ node: OverrideTree; key: string }> = []; + let cursor: OverrideTree = root; + for (let idx = 0; idx < path.length - 1; idx += 1) { + const key = path[idx]; + const next = cursor[key]; + if (!isPlainObject(next)) return false; + stack.push({ node: cursor, key }); + cursor = next; + } + const leafKey = path[path.length - 1]; + if (!(leafKey in cursor)) return false; + delete cursor[leafKey]; + for (let idx = stack.length - 1; idx >= 0; idx -= 1) { + const { node, key } = stack[idx]; + const child = node[key]; + if (isPlainObject(child) && Object.keys(child).length === 0) { + delete node[key]; + } else { + break; + } + } + return true; +} + +function mergeOverrides(base: unknown, override: unknown): unknown { + if (!isPlainObject(base) || !isPlainObject(override)) return override; + const next: OverrideTree = { ...base }; + for (const [key, value] of Object.entries(override)) { + if (value === undefined) continue; + next[key] = mergeOverrides((base as OverrideTree)[key], value); + } + return next; +} + +export function getConfigOverrides(): OverrideTree { + return overrides; +} + +export function resetConfigOverrides(): void { + overrides = {}; +} + +export function setConfigOverride(pathRaw: string, value: unknown): { + ok: boolean; + error?: string; +} { + const path = parsePath(pathRaw); + if (!path) { + return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." }; + } + setOverrideAtPath(overrides, path, value); + return { ok: true }; +} + +export function unsetConfigOverride(pathRaw: string): { + ok: boolean; + removed: boolean; + error?: string; +} { + const path = parsePath(pathRaw); + if (!path) { + return { ok: false, removed: false, error: "Invalid path." }; + } + const removed = unsetOverrideAtPath(overrides, path); + return { ok: true, removed }; +} + +export function applyConfigOverrides(cfg: ClawdbotConfig): ClawdbotConfig { + if (!overrides || Object.keys(overrides).length === 0) return cfg; + return mergeOverrides(cfg, overrides) as ClawdbotConfig; +}