export type DirigentConfig = { enabled?: boolean; discordOnly?: boolean; listMode?: "human-list" | "agent-list"; humanList?: string[]; agentList?: string[]; channelPoliciesFile?: string; // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; /** Scheduling identifier sent by moderator to activate agents (default: ➡️) */ schedulingIdentifier?: string; noReplyProvider: string; noReplyModel: string; /** Discord bot token for the moderator bot (used for turn handoff messages) */ moderatorBotToken?: string; }; export type ChannelPolicy = { listMode?: "human-list" | "agent-list"; humanList?: string[]; agentList?: string[]; endSymbols?: string[]; }; export type Decision = { shouldUseNoReply: boolean; shouldInjectEndMarkerPrompt: boolean; reason: string; }; /** * Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content. * The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n``` */ function stripTrailingMetadata(input: string): string { // Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks let text = input; // eslint-disable-next-line no-constant-condition while (true) { const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/); if (!m) break; text = text.slice(0, text.length - m[0].length); } return text; } function getLastChar(input: string): string { const t = stripTrailingMetadata(input).trim(); if (!t.length) return ""; // Use Array.from to handle multi-byte characters (emoji, surrogate pairs) const chars = Array.from(t); return chars[chars.length - 1] || ""; } export function resolvePolicy(config: DirigentConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; const globalEnd = config.endSymbols || ["🔚"]; if (!channelId) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; } const cp = channelPolicies || {}; const scoped = cp[channelId]; if (!scoped) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; } return { listMode: scoped.listMode || globalMode, humanList: scoped.humanList || globalHuman, agentList: scoped.agentList || globalAgent, endSymbols: scoped.endSymbols || globalEnd, }; } export function evaluateDecision(params: { config: DirigentConfig; channel?: string; channelId?: string; channelPolicies?: Record; senderId?: string; content?: string; }): Decision { const { config } = params; if (config.enabled === false) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" }; } const channel = (params.channel || "").toLowerCase(); if (config.discordOnly !== false && channel !== "discord") { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" }; } // DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId), // this is a DM session where untrusted metadata is not injected. Always allow through. if (!params.senderId && !params.channelId) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" }; } const policy = resolvePolicy(config, params.channelId, params.channelPolicies); const mode = policy.listMode; const humanList = policy.humanList; const agentList = policy.agentList; const senderId = params.senderId || ""; const inHumanList = !!senderId && humanList.includes(senderId); const inAgentList = !!senderId && agentList.includes(senderId); const lastChar = getLastChar(params.content || ""); const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar); if (mode === "human-list") { if (inHumanList) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" }; } if (hasEnd) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; } return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" }; } // agent-list mode: listed senders require end symbol; others bypass requirement. if (!inAgentList) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" }; } if (hasEnd) { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; } return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" }; }