From 8aa3efb9e8cfe44a0fcf9a6e754f9c51a78de3f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 03:40:39 +0100 Subject: [PATCH] refactor: tidy directive parsing + queue status --- docs/tools/slash-commands.md | 2 +- src/auto-reply/group-activation.ts | 10 +- src/auto-reply/reply.directive.parse.test.ts | 154 +++++++++++++++ src/auto-reply/reply.directive.test.ts | 192 ++++--------------- src/auto-reply/reply/directive-handling.ts | 29 +++ src/auto-reply/reply/directives.ts | 157 ++++++++++----- src/auto-reply/send-policy.ts | 9 +- 7 files changed, 342 insertions(+), 211 deletions(-) create mode 100644 src/auto-reply/reply.directive.parse.test.ts diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 9f5715d41..fa9e4e636 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -43,7 +43,7 @@ Text + native (when enabled): - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) - `/model ` (or `/` from `agent.models.*.alias`) -- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`) +- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) Text-only: - `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction)) diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts index f05075099..fd64194da 100644 --- a/src/auto-reply/group-activation.ts +++ b/src/auto-reply/group-activation.ts @@ -1,3 +1,5 @@ +import { normalizeCommandBody } from "./commands-registry.js"; + export type GroupActivationMode = "mention" | "always"; export function normalizeGroupActivation( @@ -16,11 +18,9 @@ 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*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i, - ); + const normalized = normalizeCommandBody(trimmed); + const match = normalized.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; - const token = match[1] ?? match[2]; - const mode = normalizeGroupActivation(token); + const mode = normalizeGroupActivation(match[1]); return { hasCommand: true, mode }; } diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts new file mode 100644 index 000000000..b4bf49009 --- /dev/null +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { + extractElevatedDirective, + extractQueueDirective, + extractReasoningDirective, + extractReplyToTag, + extractThinkDirective, + extractVerboseDirective, +} from "./reply.js"; + +describe("directive parsing", () => { + it("ignores verbose directive inside URL", () => { + const body = "https://x.com/verioussmith/status/1997066835133669687"; + const res = extractVerboseDirective(body); + expect(res.hasDirective).toBe(false); + expect(res.cleaned).toBe(body); + }); + + it("ignores typoed /verioussmith", () => { + const body = "/verioussmith"; + const res = extractVerboseDirective(body); + expect(res.hasDirective).toBe(false); + expect(res.cleaned).toBe(body.trim()); + }); + + it("ignores think directive inside URL", () => { + const body = "see https://example.com/path/thinkstuff"; + const res = extractThinkDirective(body); + expect(res.hasDirective).toBe(false); + }); + + it("matches verbose with leading space", () => { + const res = extractVerboseDirective(" please /verbose on now"); + expect(res.hasDirective).toBe(true); + expect(res.verboseLevel).toBe("on"); + }); + + it("matches reasoning directive", () => { + const res = extractReasoningDirective("/reasoning on please"); + expect(res.hasDirective).toBe(true); + expect(res.reasoningLevel).toBe("on"); + }); + + it("matches reasoning stream directive", () => { + const res = extractReasoningDirective("/reasoning stream please"); + expect(res.hasDirective).toBe(true); + expect(res.reasoningLevel).toBe("stream"); + }); + + it("matches elevated with leading space", () => { + const res = extractElevatedDirective(" please /elevated on now"); + expect(res.hasDirective).toBe(true); + expect(res.elevatedLevel).toBe("on"); + }); + + it("matches think at start of line", () => { + const res = extractThinkDirective("/think:high run slow"); + expect(res.hasDirective).toBe(true); + expect(res.thinkLevel).toBe("high"); + }); + + it("does not match /think followed by extra letters", () => { + // e.g. someone typing "/think" + extra letter "hink" + const res = extractThinkDirective("/thinkstuff"); + expect(res.hasDirective).toBe(false); + }); + + it("matches /think with no argument", () => { + const res = extractThinkDirective("/think"); + expect(res.hasDirective).toBe(true); + expect(res.thinkLevel).toBeUndefined(); + expect(res.rawLevel).toBeUndefined(); + }); + + it("matches /t with no argument", () => { + const res = extractThinkDirective("/t"); + expect(res.hasDirective).toBe(true); + 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); + expect(res.queueMode).toBe("interrupt"); + expect(res.queueReset).toBe(false); + expect(res.cleaned).toBe("please now"); + }); + + it("parses queue options and modes", () => { + const res = extractQueueDirective( + "please /queue steer+backlog debounce:2s cap:5 drop:summarize now", + ); + expect(res.hasDirective).toBe(true); + expect(res.queueMode).toBe("steer-backlog"); + expect(res.debounceMs).toBe(2000); + expect(res.cap).toBe(5); + expect(res.dropPolicy).toBe("summarize"); + expect(res.cleaned).toBe("please now"); + }); + + it("extracts reply_to_current tag", () => { + const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1"); + expect(res.replyToId).toBe("msg-1"); + expect(res.cleaned).toBe("ok"); + }); + + it("extracts reply_to id tag", () => { + const res = extractReplyToTag("see [[reply_to:12345]] now", "msg-1"); + expect(res.replyToId).toBe("12345"); + expect(res.cleaned).toBe("see now"); + }); + + it("preserves newlines when stripping reply tags", () => { + const res = extractReplyToTag( + "line 1\nline 2 [[reply_to_current]]\n\nline 3", + "msg-2", + ); + expect(res.replyToId).toBe("msg-2"); + expect(res.cleaned).toBe("line 1\nline 2\n\nline 3"); + }); +}); diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 4b6179f99..55ed6edec 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -12,15 +12,7 @@ import { saveSessionStore, } from "../config/sessions.js"; import { drainSystemEvents } from "../infra/system-events.js"; -import { - extractElevatedDirective, - extractQueueDirective, - extractReasoningDirective, - extractReplyToTag, - extractThinkDirective, - extractVerboseDirective, - getReplyFromConfig, -} from "./reply.js"; +import { getReplyFromConfig } from "./reply.js"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -60,7 +52,7 @@ async function withTempHome(fn: (home: string) => Promise): Promise { } } -describe("directive parsing", () => { +describe("directive behavior", () => { beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([ @@ -74,127 +66,6 @@ describe("directive parsing", () => { vi.restoreAllMocks(); }); - it("ignores verbose directive inside URL", () => { - const body = "https://x.com/verioussmith/status/1997066835133669687"; - const res = extractVerboseDirective(body); - expect(res.hasDirective).toBe(false); - expect(res.cleaned).toBe(body); - }); - - it("ignores typoed /verioussmith", () => { - const body = "/verioussmith"; - const res = extractVerboseDirective(body); - expect(res.hasDirective).toBe(false); - expect(res.cleaned).toBe(body.trim()); - }); - - it("ignores think directive inside URL", () => { - const body = "see https://example.com/path/thinkstuff"; - const res = extractThinkDirective(body); - expect(res.hasDirective).toBe(false); - }); - - it("matches verbose with leading space", () => { - const res = extractVerboseDirective(" please /verbose on now"); - expect(res.hasDirective).toBe(true); - expect(res.verboseLevel).toBe("on"); - }); - - it("matches reasoning directive", () => { - const res = extractReasoningDirective("/reasoning on please"); - expect(res.hasDirective).toBe(true); - expect(res.reasoningLevel).toBe("on"); - }); - - it("matches reasoning stream directive", () => { - const res = extractReasoningDirective("/reasoning stream please"); - expect(res.hasDirective).toBe(true); - expect(res.reasoningLevel).toBe("stream"); - }); - - it("matches elevated with leading space", () => { - const res = extractElevatedDirective(" please /elevated on now"); - expect(res.hasDirective).toBe(true); - expect(res.elevatedLevel).toBe("on"); - }); - - it("matches think at start of line", () => { - const res = extractThinkDirective("/think:high run slow"); - expect(res.hasDirective).toBe(true); - expect(res.thinkLevel).toBe("high"); - }); - - it("does not match /think followed by extra letters", () => { - // e.g. someone typing "/think" + extra letter "hink" - const res = extractThinkDirective("/thinkstuff"); - expect(res.hasDirective).toBe(false); - }); - - it("matches /think with no argument", () => { - const res = extractThinkDirective("/think"); - expect(res.hasDirective).toBe(true); - expect(res.thinkLevel).toBeUndefined(); - expect(res.rawLevel).toBeUndefined(); - }); - - it("matches /t with no argument", () => { - const res = extractThinkDirective("/t"); - expect(res.hasDirective).toBe(true); - 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); - expect(res.queueMode).toBe("interrupt"); - expect(res.queueReset).toBe(false); - expect(res.cleaned).toBe("please now"); - }); - - it("parses queue options and modes", () => { - const res = extractQueueDirective( - "please /queue steer+backlog debounce:2s cap:5 drop:summarize now", - ); - expect(res.hasDirective).toBe(true); - expect(res.queueMode).toBe("steer-backlog"); - expect(res.debounceMs).toBe(2000); - expect(res.cap).toBe(5); - expect(res.dropPolicy).toBe("summarize"); - expect(res.cleaned).toBe("please now"); - }); - it("keeps reserved command aliases from matching after trimming", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -254,6 +125,44 @@ describe("directive parsing", () => { }); }); + it("shows current queue settings when /queue has no arguments", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/queue", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + routing: { + queue: { + mode: "collect", + debounceMs: 1500, + cap: 9, + drop: "summarize", + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain( + "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", + ); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("shows current think level when /think has no argument", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -299,27 +208,6 @@ describe("directive parsing", () => { }); }); - it("extracts reply_to_current tag", () => { - const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1"); - expect(res.replyToId).toBe("msg-1"); - expect(res.cleaned).toBe("ok"); - }); - - it("extracts reply_to id tag", () => { - const res = extractReplyToTag("see [[reply_to:12345]] now", "msg-1"); - expect(res.replyToId).toBe("12345"); - expect(res.cleaned).toBe("see now"); - }); - - it("preserves newlines when stripping reply tags", () => { - const res = extractReplyToTag( - "line 1\nline 2 [[reply_to_current]]\n\nline 3", - "msg-2", - ); - expect(res.replyToId).toBe("msg-2"); - expect(res.cleaned).toBe("line 1\nline 2\n\nline 3"); - }); - it("strips reply tags and maps reply_to_current to MessageSid", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 1248d7bcb..08d113208 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -50,6 +50,7 @@ import { extractQueueDirective, type QueueDropPolicy, type QueueMode, + resolveQueueSettings, } from "./queue.js"; const SYSTEM_MARK = "⚙️"; @@ -328,6 +329,7 @@ export async function handleDirectiveOnly(params: { allowedModelKeys, allowedModelCatalog, resetModelOverride, + provider, initialModelLabel, formatModelSwitchEvent, currentThinkLevel, @@ -433,6 +435,33 @@ export async function handleDirectiveOnly(params: { return { text: "elevated is not available right now." }; } + if ( + directives.hasQueueDirective && + !directives.queueMode && + !directives.queueReset && + !directives.hasQueueOptions && + directives.rawQueueMode === undefined && + directives.rawDebounce === undefined && + directives.rawCap === undefined && + directives.rawDrop === undefined + ) { + const settings = resolveQueueSettings({ + cfg: params.cfg, + provider, + sessionEntry, + }); + const debounceLabel = + typeof settings.debounceMs === "number" + ? `${settings.debounceMs}ms` + : "default"; + const capLabel = + typeof settings.cap === "number" ? String(settings.cap) : "default"; + const dropLabel = settings.dropPolicy ?? "default"; + return { + text: `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, + }; + } + const queueModeInvalid = directives.hasQueueDirective && !directives.queueMode && diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index ef07dbf88..b0ddb1a43 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -9,6 +9,81 @@ import { type VerboseLevel, } from "../thinking.js"; +type ExtractedLevel = { + cleaned: string; + level?: T; + rawLevel?: string; + hasDirective: boolean; +}; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const matchLevelDirective = ( + body: string, + names: string[], +): { start: number; end: number; rawLevel?: string } | null => { + const namePattern = names.map(escapeRegExp).join("|"); + const match = body.match( + new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"), + ); + if (!match || match.index === undefined) return null; + const start = match.index; + let end = match.index + match[0].length; + let i = end; + while (i < body.length && /\s/.test(body[i])) i += 1; + if (body[i] === ":") { + i += 1; + while (i < body.length && /\s/.test(body[i])) i += 1; + } + const argStart = i; + while (i < body.length && /[A-Za-z-]/.test(body[i])) i += 1; + const rawLevel = i > argStart ? body.slice(argStart, i) : undefined; + end = i; + return { start, end, rawLevel }; +}; + +const extractLevelDirective = ( + body: string, + names: string[], + normalize: (raw?: string) => T | undefined, +): ExtractedLevel => { + const match = matchLevelDirective(body, names); + if (!match) { + return { cleaned: body.trim(), hasDirective: false }; + } + const rawLevel = match.rawLevel; + const level = normalize(rawLevel); + const cleaned = body + .slice(0, match.start) + .concat(body.slice(match.end)) + .replace(/\s+/g, " ") + .trim(); + return { + cleaned, + level, + rawLevel, + hasDirective: true, + }; +}; + +const extractSimpleDirective = ( + body: string, + names: string[], +): { cleaned: string; hasDirective: boolean } => { + const namePattern = names.map(escapeRegExp).join("|"); + const match = body.match( + new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"), + ); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { + cleaned, + hasDirective: Boolean(match), + }; +}; + export function extractThinkDirective(body?: string): { cleaned: string; thinkLevel?: ThinkLevel; @@ -16,19 +91,16 @@ export function extractThinkDirective(body?: string): { hasDirective: boolean; } { 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, + const extracted = extractLevelDirective( + body, + ["thinking", "think", "t"], + normalizeThinkLevel, ); - const thinkLevel = normalizeThinkLevel(match?.[1]); - const cleaned = match - ? body.replace(match[0], "").replace(/\s+/g, " ").trim() - : body.trim(); return { - cleaned, - thinkLevel, - rawLevel: match?.[1], - hasDirective: !!match, + cleaned: extracted.cleaned, + thinkLevel: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, }; } @@ -39,18 +111,16 @@ export function extractVerboseDirective(body?: string): { hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; - const match = body.match( - /(?:^|\s)\/(?:verbose|v)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i, + const extracted = extractLevelDirective( + body, + ["verbose", "v"], + normalizeVerboseLevel, ); - const verboseLevel = normalizeVerboseLevel(match?.[1]); - const cleaned = match - ? body.replace(match[0], "").replace(/\s+/g, " ").trim() - : body.trim(); return { - cleaned, - verboseLevel, - rawLevel: match?.[1], - hasDirective: !!match, + cleaned: extracted.cleaned, + verboseLevel: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, }; } @@ -61,18 +131,16 @@ export function extractElevatedDirective(body?: string): { hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; - const match = body.match( - /(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i, + const extracted = extractLevelDirective( + body, + ["elevated", "elev"], + normalizeElevatedLevel, ); - const elevatedLevel = normalizeElevatedLevel(match?.[1]); - const cleaned = match - ? body.replace(match[0], "").replace(/\s+/g, " ").trim() - : body.trim(); return { - cleaned, - elevatedLevel, - rawLevel: match?.[1], - hasDirective: !!match, + cleaned: extracted.cleaned, + elevatedLevel: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, }; } @@ -83,18 +151,16 @@ export function extractReasoningDirective(body?: string): { hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; - const match = body.match( - /(?:^|\s)\/(?:reasoning|reason)(?=$|\s|:)(?:\s*:?\s*(?:([a-zA-Z-]+)\b)?)?/i, + const extracted = extractLevelDirective( + body, + ["reasoning", "reason"], + normalizeReasoningLevel, ); - const reasoningLevel = normalizeReasoningLevel(match?.[1]); - const cleaned = match - ? body.replace(match[0], "").replace(/\s+/g, " ").trim() - : body.trim(); return { - cleaned, - reasoningLevel, - rawLevel: match?.[1], - hasDirective: !!match, + cleaned: extracted.cleaned, + reasoningLevel: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, }; } @@ -103,14 +169,7 @@ export function extractStatusDirective(body?: string): { hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; - const match = body.match(/(?:^|\s)\/status(?=$|\s|:)(?:\s*:\s*)?/i); - const cleaned = match - ? body.replace(match[0], "").replace(/\s+/g, " ").trim() - : body.trim(); - return { - cleaned, - hasDirective: !!match, - }; + return extractSimpleDirective(body, ["status"]); } export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; diff --git a/src/auto-reply/send-policy.ts b/src/auto-reply/send-policy.ts index 1c7e7ff31..dcbc53f98 100644 --- a/src/auto-reply/send-policy.ts +++ b/src/auto-reply/send-policy.ts @@ -1,3 +1,5 @@ +import { normalizeCommandBody } from "./commands-registry.js"; + export type SendPolicyOverride = "allow" | "deny"; export function normalizeSendPolicyOverride( @@ -17,11 +19,10 @@ 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*:\s*([a-zA-Z]+)?\s*|\s+([a-zA-Z]+)\s*)?$/i, - ); + const normalized = normalizeCommandBody(trimmed); + const match = normalized.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; - const token = (match[1] ?? match[2])?.trim().toLowerCase(); + const token = match[1]?.trim().toLowerCase(); if (!token) return { hasCommand: true }; if (token === "inherit" || token === "default" || token === "reset") { return { hasCommand: true, mode: "inherit" };