fix: relax slash command parsing
This commit is contained in:
@@ -9,7 +9,12 @@ describe("control command parsing", () => {
|
||||
hasCommand: true,
|
||||
mode: "allow",
|
||||
});
|
||||
expect(parseSendPolicyCommand("/send: on")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "allow",
|
||||
});
|
||||
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
|
||||
expect(parseSendPolicyCommand("/send:")).toEqual({ hasCommand: true });
|
||||
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
|
||||
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
|
||||
});
|
||||
@@ -19,6 +24,13 @@ describe("control command parsing", () => {
|
||||
hasCommand: true,
|
||||
mode: "mention",
|
||||
});
|
||||
expect(parseActivationCommand("/activation: mention")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "mention",
|
||||
});
|
||||
expect(parseActivationCommand("/activation:")).toEqual({
|
||||
hasCommand: true,
|
||||
});
|
||||
expect(parseActivationCommand("activation mention")).toEqual({
|
||||
hasCommand: false,
|
||||
});
|
||||
@@ -28,8 +40,10 @@ describe("control command parsing", () => {
|
||||
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("/status")).toBe(true);
|
||||
expect(hasControlCommand("/status:")).toBe(true);
|
||||
expect(hasControlCommand("status")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { listChatCommands, normalizeCommandBody } from "./commands-registry.js";
|
||||
|
||||
export function hasControlCommand(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const normalizedBody = normalizeCommandBody(trimmed);
|
||||
if (!normalizedBody) return false;
|
||||
const lowered = normalizedBody.toLowerCase();
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (lowered === normalized) return true;
|
||||
if (command.acceptsArgs && lowered.startsWith(normalized)) {
|
||||
const nextChar = trimmed.charAt(normalized.length);
|
||||
const nextChar = normalizedBody.charAt(normalized.length);
|
||||
if (nextChar && /\s/.test(nextChar)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ describe("commands registry", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/help")).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("try /status")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -148,6 +148,16 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
if (cachedDetection) return cachedDetection;
|
||||
const exact = new Set<string>();
|
||||
@@ -160,9 +170,9 @@ export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
const escaped = escapeRegExp(normalized);
|
||||
if (!escaped) continue;
|
||||
if (command.acceptsArgs) {
|
||||
patterns.push(`${escaped}(?:\\s+.+)?`);
|
||||
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
|
||||
} else {
|
||||
patterns.push(escaped);
|
||||
patterns.push(`${escaped}(?:\\s*:\\s*)?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ export function parseActivationCommand(raw?: string): {
|
||||
if (!raw) return { hasCommand: false };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
const match = trimmed.match(
|
||||
/^\/activation(?:\s*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i,
|
||||
);
|
||||
if (!match) return { hasCommand: false };
|
||||
const mode = normalizeGroupActivation(match[1]);
|
||||
const token = match[1] ?? match[2];
|
||||
const mode = normalizeGroupActivation(token);
|
||||
return { hasCommand: true, mode };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,15 @@ describe("extractModelDirective", () => {
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("recognizes /gpt: as model directive when alias is configured", () => {
|
||||
const result = extractModelDirective("/gpt:", {
|
||||
aliases: ["gpt", "sonnet", "opus"],
|
||||
});
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt");
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("recognizes /sonnet as model directive", () => {
|
||||
const result = extractModelDirective("/sonnet", {
|
||||
aliases: ["gpt", "sonnet", "opus"],
|
||||
|
||||
@@ -25,7 +25,7 @@ export function extractModelDirective(
|
||||
? null
|
||||
: body.match(
|
||||
new RegExp(
|
||||
`(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)`,
|
||||
`(?:^|\\s)\\/(${aliases.map(escapeRegExp).join("|")})(?=$|\\s|:)(?:\\s*:\\s*)?`,
|
||||
"i",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -143,6 +143,38 @@ describe("directive parsing", () => {
|
||||
expect(res.thinkLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches think with no argument and consumes colon", () => {
|
||||
const res = extractThinkDirective("/think:");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.thinkLevel).toBeUndefined();
|
||||
expect(res.rawLevel).toBeUndefined();
|
||||
expect(res.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("matches verbose with no argument", () => {
|
||||
const res = extractVerboseDirective("/verbose:");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.verboseLevel).toBeUndefined();
|
||||
expect(res.rawLevel).toBeUndefined();
|
||||
expect(res.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("matches reasoning with no argument", () => {
|
||||
const res = extractReasoningDirective("/reasoning:");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.reasoningLevel).toBeUndefined();
|
||||
expect(res.rawLevel).toBeUndefined();
|
||||
expect(res.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("matches elevated with no argument", () => {
|
||||
const res = extractElevatedDirective("/elevated:");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.elevatedLevel).toBeUndefined();
|
||||
expect(res.rawLevel).toBeUndefined();
|
||||
expect(res.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("matches queue directive", () => {
|
||||
const res = extractQueueDirective("please /queue interrupt now");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
@@ -419,6 +451,84 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows current verbose level when /verbose has no argument", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/verbose", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
verboseDefault: "on",
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current verbose level: on");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows current reasoning level when /reasoning has no argument", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/reasoning", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current reasoning level: off");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows current elevated level when /elevated has no argument", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevatedDefault: "on",
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current elevated level: on");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid elevated level", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@@ -472,6 +472,14 @@ export async function getReplyFromConfig(
|
||||
const currentThinkLevel =
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
|
||||
const currentVerboseLevel =
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const currentReasoningLevel =
|
||||
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
const currentElevatedLevel =
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined);
|
||||
const directiveReply = await handleDirectiveOnly({
|
||||
cfg,
|
||||
directives,
|
||||
@@ -492,6 +500,9 @@ export async function getReplyFromConfig(
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
currentThinkLevel,
|
||||
currentVerboseLevel,
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
});
|
||||
typing.cleanup();
|
||||
return directiveReply;
|
||||
|
||||
@@ -30,7 +30,10 @@ import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import {
|
||||
normalizeCommandBody,
|
||||
shouldHandleTextCommands,
|
||||
} from "../commands-registry.js";
|
||||
import {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
@@ -154,9 +157,9 @@ export function buildCommandContext(params: {
|
||||
const abortKey =
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
|
||||
);
|
||||
|
||||
return {
|
||||
surface,
|
||||
|
||||
@@ -310,6 +310,9 @@ export async function handleDirectiveOnly(params: {
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
currentThinkLevel?: ThinkLevel;
|
||||
currentVerboseLevel?: VerboseLevel;
|
||||
currentReasoningLevel?: ReasoningLevel;
|
||||
currentElevatedLevel?: ElevatedLevel;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
directives,
|
||||
@@ -328,6 +331,9 @@ export async function handleDirectiveOnly(params: {
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
currentThinkLevel,
|
||||
currentVerboseLevel,
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
} = params;
|
||||
|
||||
if (directives.hasModelDirective) {
|
||||
@@ -391,18 +397,33 @@ export async function handleDirectiveOnly(params: {
|
||||
};
|
||||
}
|
||||
if (directives.hasVerboseDirective && !directives.verboseLevel) {
|
||||
if (!directives.rawVerboseLevel) {
|
||||
const level = currentVerboseLevel ?? "off";
|
||||
return { text: `Current verbose level: ${level}.` };
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
||||
text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`,
|
||||
};
|
||||
}
|
||||
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
|
||||
if (!directives.rawReasoningLevel) {
|
||||
const level = currentReasoningLevel ?? "off";
|
||||
return { text: `Current reasoning level: ${level}.` };
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off, stream.`,
|
||||
text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`,
|
||||
};
|
||||
}
|
||||
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
|
||||
if (!directives.rawElevatedLevel) {
|
||||
if (!elevatedEnabled || !elevatedAllowed) {
|
||||
return { text: "elevated is not available right now." };
|
||||
}
|
||||
const level = currentElevatedLevel ?? "off";
|
||||
return { text: `Current elevated level: ${level}.` };
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel ?? ""}". Valid levels: off, on.`,
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -18,7 +18,7 @@ export function extractThinkDirective(body?: string): {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
// Match with optional argument - require word boundary via lookahead after keyword
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:thinking|think|t)(?=$|\s|:)(?:\s*:?\s*([a-zA-Z-]+)\b)?/i,
|
||||
/(?:^|\s)\/(?:thinking|think|t)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
|
||||
);
|
||||
const thinkLevel = normalizeThinkLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
@@ -40,7 +40,7 @@ export function extractVerboseDirective(body?: string): {
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
|
||||
);
|
||||
const verboseLevel = normalizeVerboseLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
@@ -62,7 +62,7 @@ export function extractElevatedDirective(body?: string): {
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||
/(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
|
||||
);
|
||||
const elevatedLevel = normalizeElevatedLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
@@ -84,7 +84,7 @@ export function extractReasoningDirective(body?: string): {
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||
/(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i,
|
||||
);
|
||||
const reasoningLevel = normalizeReasoningLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
@@ -103,7 +103,7 @@ export function extractStatusDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
|
||||
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)(?:\s*:\s*)?/i);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
|
||||
@@ -17,9 +17,11 @@ export function parseSendPolicyCommand(raw?: string): {
|
||||
if (!raw) return { hasCommand: false };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
const match = trimmed.match(
|
||||
/^\/send(?:\s*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i,
|
||||
);
|
||||
if (!match) return { hasCommand: false };
|
||||
const token = match[1]?.trim().toLowerCase();
|
||||
const token = (match[1] ?? match[2])?.trim().toLowerCase();
|
||||
if (!token) return { hasCommand: true };
|
||||
if (token === "inherit" || token === "default" || token === "reset") {
|
||||
return { hasCommand: true, mode: "inherit" };
|
||||
|
||||
Reference in New Issue
Block a user