feat: improve gateway services and auto-reply commands

This commit is contained in:
Peter Steinberger
2026-01-11 02:17:10 +01:00
parent df55d45b6f
commit e0bf86f06c
52 changed files with 888 additions and 213 deletions

View File

@@ -57,6 +57,12 @@ describe("control command parsing", () => {
}
});
it("respects disabled config/debug commands", () => {
const cfg = { commands: { config: false, debug: false } };
expect(hasControlCommand("/config show", cfg)).toBe(false);
expect(hasControlCommand("/debug show", cfg)).toBe(false);
});
it("requires commands to be the full message", () => {
expect(hasControlCommand("hello /status")).toBe(false);
expect(hasControlCommand("/status please")).toBe(false);

View File

@@ -1,13 +1,22 @@
import { listChatCommands, normalizeCommandBody } from "./commands-registry.js";
import type { ClawdbotConfig } from "../config/types.js";
import {
listChatCommands,
listChatCommandsForConfig,
normalizeCommandBody,
} from "./commands-registry.js";
export function hasControlCommand(text?: string): boolean {
export function hasControlCommand(
text?: string,
cfg?: ClawdbotConfig,
): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const normalizedBody = normalizeCommandBody(trimmed);
if (!normalizedBody) return false;
const lowered = normalizedBody.toLowerCase();
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;

View File

@@ -4,7 +4,9 @@ import {
buildCommandText,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "./commands-registry.js";
@@ -21,6 +23,26 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
});
expect(disabled.find((spec) => spec.key === "config")).toBeFalsy();
expect(disabled.find((spec) => spec.key === "debug")).toBeFalsy();
const enabled = listChatCommandsForConfig({
commands: { config: true, debug: true },
});
expect(enabled.find((spec) => spec.key === "config")).toBeTruthy();
expect(enabled.find((spec) => spec.key === "debug")).toBeTruthy();
const nativeDisabled = listNativeCommandSpecsForConfig({
commands: { config: false, debug: false, native: true },
});
expect(nativeDisabled.find((spec) => spec.name === "config")).toBeFalsy();
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -290,6 +290,21 @@ export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function isCommandEnabled(
cfg: ClawdbotConfig,
commandKey: string,
): boolean {
if (commandKey === "config") return cfg.commands?.config === true;
if (commandKey === "debug") return cfg.commands?.debug === true;
return true;
}
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName,
@@ -300,6 +315,18 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] {
}));
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {

View File

@@ -877,7 +877,7 @@ export async function getReplyFromConfig(
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(commandSource)
hasControlCommand(commandSource, cfg)
) {
typing.cleanup();
return undefined;

View File

@@ -602,7 +602,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
return { shouldContinue: false, reply: { text: buildHelpMessage(cfg) } };
}
const commandsRequested = command.commandBodyNormalized === "/commands";
@@ -613,7 +613,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
return { shouldContinue: false, reply: { text: buildCommandsMessage(cfg) } };
}
const statusRequested =
@@ -650,6 +650,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /config is disabled. Set commands.config=true to enable.",
},
};
}
if (configCommand.action === "error") {
return {
shouldContinue: false,
@@ -774,6 +782,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.debug !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /debug is disabled. Set commands.debug=true to enable.",
},
};
}
if (debugCommand.action === "error") {
return {
shouldContinue: false,

View File

@@ -4,7 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
import {
buildCommandsMessage,
buildHelpMessage,
buildStatusMessage,
} from "./status.js";
afterEach(() => {
vi.restoreAllMocks();
@@ -317,7 +321,9 @@ describe("buildStatusMessage", () => {
describe("buildCommandsMessage", () => {
it("lists commands with aliases and text-only hints", () => {
const text = buildCommandsMessage();
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain(
"/think (aliases: /thinking, /t) - Set thinking level.",
@@ -325,5 +331,17 @@ describe("buildCommandsMessage", () => {
expect(text).toContain(
"/compact (text-only) - Compact the session context.",
);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});
describe("buildHelpMessage", () => {
it("hides config/debug when disabled", () => {
const text = buildHelpMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});

View File

@@ -28,7 +28,10 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import {
listChatCommands,
listChatCommandsForConfig,
} from "./commands-registry.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -356,18 +359,29 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
export function buildHelpMessage(): string {
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|off",
"/reasoning on|off",
"/elevated on|off",
"/model <id>",
"/cost on|off",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
`Options: ${options.join(" | ")}`,
"More: /commands for all slash commands",
].join("\n");
}
export function buildCommandsMessage(): string {
export function buildCommandsMessage(cfg?: ClawdbotConfig): string {
const lines = [" Slash commands"];
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;