Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-09 11:46:08 -05:00
committed by GitHub
26 changed files with 913 additions and 364 deletions

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasControlCommand } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
@@ -37,17 +38,20 @@ describe("control command parsing", () => {
});
it("treats bare commands as non-control", () => {
expect(hasControlCommand("/send")).toBe(true);
expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("/help")).toBe(true);
expect(hasControlCommand("/help:")).toBe(true);
expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(hasControlCommand(alias)).toBe(true);
expect(hasControlCommand(`${alias}:`)).toBe(true);
}
}
});
it("requires commands to be the full message", () => {

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildCommandText,
getCommandDetection,
listChatCommands,
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "./commands-registry.js";
@@ -21,15 +22,21 @@ describe("commands registry", () => {
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
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);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
expect(detection.regex.test(alias)).toBe(true);
expect(detection.regex.test(`${alias}:`)).toBe(true);
if (command.acceptsArgs) {
expect(detection.regex.test(`${alias} list`)).toBe(true);
expect(detection.regex.test(`${alias}: list`)).toBe(true);
} else {
expect(detection.regex.test(`${alias} list`)).toBe(false);
expect(detection.regex.test(`${alias}: list`)).toBe(false);
}
}
}
expect(detection.regex.test("try /status")).toBe(false);
});

View File

@@ -14,123 +14,186 @@ export type NativeCommandSpec = {
acceptsArgs: boolean;
};
const CHAT_COMMANDS: ChatCommandDefinition[] = [
{
key: "help",
nativeName: "help",
description: "Show available commands.",
textAliases: ["/help"],
},
{
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAliases: ["/commands"],
},
{
key: "status",
nativeName: "status",
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "debug",
nativeName: "debug",
description: "Set runtime debug overrides.",
textAliases: ["/debug"],
acceptsArgs: true,
},
{
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAliases: ["/cost"],
acceptsArgs: true,
},
{
key: "stop",
nativeName: "stop",
description: "Stop the current run.",
textAliases: ["/stop"],
},
{
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAliases: ["/restart"],
},
{
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAliases: ["/activation"],
acceptsArgs: true,
},
{
key: "send",
nativeName: "send",
description: "Set send policy.",
textAliases: ["/send"],
acceptsArgs: true,
},
{
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAliases: ["/reset"],
},
{
key: "new",
nativeName: "new",
description: "Start a new session.",
textAliases: ["/new"],
},
{
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAliases: ["/thinking", "/think", "/t"],
acceptsArgs: true,
},
{
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAliases: ["/verbose", "/v"],
acceptsArgs: true,
},
{
key: "reasoning",
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAliases: ["/reasoning", "/reason"],
acceptsArgs: true,
},
{
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAliases: ["/elevated", "/elev"],
acceptsArgs: true,
},
{
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model", "/models"],
acceptsArgs: true,
},
{
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAliases: ["/queue"],
acceptsArgs: true,
},
];
function defineChatCommand(
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
): ChatCommandDefinition {
return {
key: command.key,
nativeName: command.nativeName,
description: command.description,
acceptsArgs: command.acceptsArgs,
textAliases: [command.textAlias],
};
}
function registerAlias(
commands: ChatCommandDefinition[],
key: string,
...aliases: string[]
): void {
const command = commands.find((entry) => entry.key === key);
if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`);
}
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
for (const alias of aliases) {
const trimmed = alias.trim();
if (!trimmed) continue;
if (existing.has(trimmed)) continue;
existing.add(trimmed);
command.textAliases.push(trimmed);
}
}
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const commands: ChatCommandDefinition[] = [
defineChatCommand({
key: "help",
nativeName: "help",
description: "Show available commands.",
textAlias: "/help",
}),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
}),
defineChatCommand({
key: "status",
nativeName: "status",
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "debug",
nativeName: "debug",
description: "Set runtime debug overrides.",
textAlias: "/debug",
acceptsArgs: true,
}),
defineChatCommand({
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAlias: "/cost",
acceptsArgs: true,
}),
defineChatCommand({
key: "stop",
nativeName: "stop",
description: "Stop the current run.",
textAlias: "/stop",
}),
defineChatCommand({
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAlias: "/restart",
}),
defineChatCommand({
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAlias: "/activation",
acceptsArgs: true,
}),
defineChatCommand({
key: "send",
nativeName: "send",
description: "Set send policy.",
textAlias: "/send",
acceptsArgs: true,
}),
defineChatCommand({
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAlias: "/reset",
}),
defineChatCommand({
key: "new",
nativeName: "new",
description: "Start a new session.",
textAlias: "/new",
}),
defineChatCommand({
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAlias: "/think",
acceptsArgs: true,
}),
defineChatCommand({
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAlias: "/verbose",
acceptsArgs: true,
}),
defineChatCommand({
key: "reasoning",
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAlias: "/reasoning",
acceptsArgs: true,
}),
defineChatCommand({
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAlias: "/elevated",
acceptsArgs: true,
}),
defineChatCommand({
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAlias: "/model",
acceptsArgs: true,
}),
defineChatCommand({
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAlias: "/queue",
acceptsArgs: true,
}),
];
registerAlias(commands, "status", "/usage");
registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v");
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
return commands;
})();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection:
| {
exact: Set<string>;
@@ -171,11 +234,31 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {

View File

@@ -144,6 +144,12 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping usage directives before paths", () => {
const res = extractStatusDirective("thats not /usage:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",

View File

@@ -249,6 +249,42 @@ describe("directive behavior", () => {
});
});
it("strips reply tags with whitespace and maps reply_to_current to MessageSid", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello [[ reply_to_current ]]" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.text).toBe("hello");
expect(payload?.replyToId).toBe("msg-123");
});
});
it("prefers explicit reply_to id over reply_to_current", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -242,6 +242,23 @@ describe("trigger handling", () => {
});
});
it("reports status via /usage without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/usage",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("ClawdBot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports active auth profile and key snippet in status", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
@@ -1240,6 +1257,7 @@ describe("trigger handling", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high");
expect(prompt).not.toContain("/think high");
});
});

View File

@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
return extractSimpleDirective(body, ["status"]);
return extractSimpleDirective(body, ["status", "usage"]);
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };

View File

@@ -1,3 +1,13 @@
const REPLY_TAG_RE =
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
function normalizeReplyText(text: string) {
return text
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
export function extractReplyToTag(
text?: string,
currentMessageId?: string,
@@ -7,29 +17,28 @@ export function extractReplyToTag(
hasTag: boolean;
} {
if (!text) return { cleaned: "", hasTag: false };
let cleaned = text;
let replyToId: string | undefined;
let sawCurrent = false;
let lastExplicitId: string | undefined;
let hasTag = false;
const currentMatch = cleaned.match(/\[\[\s*reply_to_current\s*\]\]/i);
if (currentMatch) {
cleaned = cleaned.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " ");
hasTag = true;
if (currentMessageId?.trim()) {
replyToId = currentMessageId.trim();
}
}
const cleaned = normalizeReplyText(
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
hasTag = true;
if (idRaw === undefined) {
sawCurrent = true;
return " ";
}
const idMatch = cleaned.match(/\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]/i);
if (idMatch?.[1]) {
cleaned = cleaned.replace(/\[\[\s*reply_to\s*:\s*[^\]\n]+\s*\]\]/gi, " ");
replyToId = idMatch[1].trim();
hasTag = true;
}
const id = idRaw.trim();
if (id) lastExplicitId = id;
return " ";
}),
);
const replyToId =
lastExplicitId ??
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
cleaned = cleaned
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
return { cleaned, replyToId, hasTag };
}