import fs from "node:fs"; import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; import { initTurnOrder } from "./turn-manager.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; import { registerMessageReceivedHook } from "./hooks/message-received.js"; import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js"; import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js"; import { registerMessageSentHook } from "./hooks/message-sent.js"; import { registerDirigentCommand } from "./commands/dirigent-command.js"; import { registerDirigentTools } from "./tools/register-tools.js"; // ── No-Reply API child process lifecycle ────────────────────────────── let noReplyProcess: ChildProcess | null = null; function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void { logger.info(`dirigent: startNoReplyApi called, pluginDir=${pluginDir}`); if (noReplyProcess) { logger.info("dirigent: no-reply API already running, skipping"); return; } const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); logger.info(`dirigent: resolved serverPath=${serverPath}`); if (!fs.existsSync(serverPath)) { logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`); return; } logger.info(`dirigent: no-reply API server found, spawning process...`); noReplyProcess = spawn(process.execPath, [serverPath], { env: { ...process.env, PORT: String(port) }, stdio: ["ignore", "pipe", "pipe"], detached: false, }); noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`)); noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`)); noReplyProcess.on("exit", (code, signal) => { logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`); noReplyProcess = null; }); logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`); } function stopNoReplyApi(logger: { info: (m: string) => void }): void { if (!noReplyProcess) return; logger.info("dirigent: stopping no-reply API"); noReplyProcess.kill("SIGTERM"); noReplyProcess = null; } type DecisionRecord = { decision: Decision; createdAt: number; needsRestore?: boolean; }; type PolicyState = { filePath: string; channelPolicies: Record; }; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; const sessionDecision = new Map(); const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) const sessionInjected = new Set(); // Track which sessions have already injected the end marker const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`; if (isGroupChat) { instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`; instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`; } return instruction; } function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): string { return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`; } const policyState: PolicyState = { filePath: "", channelPolicies: {}, }; function pruneDecisionMap(now = Date.now()) { for (const [k, v] of sessionDecision.entries()) { if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); } if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; const keys = sessionDecision.keys(); while (sessionDecision.size > MAX_SESSION_DECISIONS) { const k = keys.next(); if (k.done) break; sessionDecision.delete(k.value); } } function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig { const root = (api.config as Record) || {}; const plugins = (root.plugins as Record) || {}; const entries = (plugins.entries as Record) || {}; // Support both "dirigent" and legacy "whispergate" config keys const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; const cfg = (entry.config as Record) || {}; if (Object.keys(cfg).length > 0) { // Merge with defaults to ensure optional fields have values return { enableDiscordControlTool: true, enableDirigentPolicyTool: true, discordControlApiBaseUrl: "http://127.0.0.1:8790", enableDebugLogs: false, debugLogChannelIds: [], schedulingIdentifier: "➡️", waitIdentifier: "👤", ...cfg, } as DirigentConfig; } return fallback; } function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string { return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json"); } function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig) { if (policyState.filePath) return; const filePath = resolvePoliciesPath(api, config); policyState.filePath = filePath; try { if (!fs.existsSync(filePath)) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "{}\n", "utf8"); policyState.channelPolicies = {}; return; } const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { api.logger.warn(`dirigent: failed init policy file ${filePath}: ${String(err)}`); policyState.channelPolicies = {}; } } /** Resolve agentId → Discord accountId from config bindings */ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; if (!Array.isArray(bindings)) return undefined; for (const b of bindings) { if (b.agentId === agentId) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { return match.accountId; } } } return undefined; } /** * Get all Discord bot accountIds from config bindings. */ function getAllBotAccountIds(api: OpenClawPluginApi): string[] { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; if (!Array.isArray(bindings)) return []; const ids: string[] = []; for (const b of bindings) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { ids.push(match.accountId); } } return ids; } /** * Track which bot accountIds have been seen in each channel via message_received. * Key: channelId, Value: Set of accountIds seen. */ const channelSeenAccounts = new Map>(); /** * Record a bot accountId seen in a channel. * Returns true if this is a new account for this channel (turn order should be updated). */ function recordChannelAccount(channelId: string, accountId: string): boolean { let seen = channelSeenAccounts.get(channelId); if (!seen) { seen = new Set(); channelSeenAccounts.set(channelId, seen); } if (seen.has(accountId)) return false; seen.add(accountId); return true; } /** * Get the list of bot accountIds seen in a channel. * Only returns accounts that are also in the global bindings (actual bots). */ function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] { const allBots = new Set(getAllBotAccountIds(api)); const seen = channelSeenAccounts.get(channelId); if (!seen) return []; return [...seen].filter(id => allBots.has(id)); } /** * Ensure turn order is initialized for a channel. * Uses only bot accounts that have been seen in this channel. */ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { const botAccounts = getChannelBotAccountIds(api, channelId); if (botAccounts.length > 0) { initTurnOrder(channelId, botAccounts); } } /** * Build agent identity string for injection into group chat prompts. * Includes agent name, Discord accountId, and Discord userId. */ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; const agents = ((root.agents as Record)?.list as Array>) || []; if (!Array.isArray(bindings)) return undefined; // Find accountId for this agent let accountId: string | undefined; for (const b of bindings) { if (b.agentId === agentId) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { accountId = match.accountId; break; } } } if (!accountId) return undefined; // Find agent name const agent = agents.find((a: Record) => a.id === agentId); const name = (agent?.name as string) || agentId; // Resolve Discord userId from bot token const discordUserId = resolveDiscordUserId(api, accountId); let identity = `You are ${name} (Discord account: ${accountId}`; if (discordUserId) { identity += `, Discord userId: ${discordUserId}`; } identity += `).`; return identity; } // --- Moderator bot helpers --- /** Extract Discord user ID from a bot token (base64-encoded in first segment) */ function userIdFromToken(token: string): string | undefined { try { const segment = token.split(".")[0]; // Add padding const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); return Buffer.from(padded, "base64").toString("utf8"); } catch { return undefined; } } /** Resolve accountId → Discord user ID by reading the account's bot token from config */ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined { const root = (api.config as Record) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const acct = accounts[accountId]; if (!acct?.token || typeof acct.token !== "string") return undefined; return userIdFromToken(acct.token); } /** * Build a reverse map: Discord userId → accountId for all configured Discord accounts. */ function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map { const root = (api.config as Record) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const map = new Map(); for (const [accountId, acct] of Object.entries(accounts)) { if (typeof acct.token === "string") { const userId = userIdFromToken(acct.token); if (userId) map.set(userId, accountId); } } return map; } /** * Extract Discord @mention user IDs from message content. * Matches <@USER_ID> and <@!USER_ID> patterns. * Returns user IDs in the order they appear. */ function extractMentionedUserIds(content: string): string[] { const regex = /<@!?(\d+)>/g; const ids: string[] = []; const seen = new Set(); let match; while ((match = regex.exec(content)) !== null) { const id = match[1]; if (!seen.has(id)) { seen.add(id); ids.push(id); } } return ids; } /** Get the moderator bot's Discord user ID from its token */ function getModeratorUserId(config: DirigentConfig): string | undefined { if (!config.moderatorBotToken) return undefined; return userIdFromToken(config.moderatorBotToken); } /** Send a message as the moderator bot via Discord REST API */ async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise { try { const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { "Authorization": `Bot ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content }), }); if (!r.ok) { const text = await r.text(); logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); return false; } logger.info(`dirigent: moderator message sent to channel=${channelId}`); return true; } catch (err) { logger.warn(`dirigent: moderator send error: ${String(err)}`); return false; } } function persistPolicies(api: OpenClawPluginApi): void { const filePath = policyState.filePath; if (!filePath) throw new Error("policy file path not initialized"); const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; const tmp = `${filePath}.tmp`; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); api.logger.info(`dirigent: policy file persisted: ${filePath}`); } function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { if (v !== undefined) out[k] = v; } return out; } function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { if (!cfg.enableDebugLogs) return false; const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; if (allow.length === 0) return true; if (!channelId) return true; return allow.includes(channelId); } function debugCtxSummary(ctx: Record, event: Record) { const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; return { sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined, commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined, messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined, channel: typeof ctx.channel === "string" ? ctx.channel : undefined, channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined, from: typeof ctx.from === "string" ? ctx.from : undefined, metaSenderId: typeof meta.senderId === "string" ? meta.senderId : typeof meta.sender_id === "string" ? meta.sender_id : undefined, metaUserId: typeof meta.userId === "string" ? meta.userId : typeof meta.user_id === "string" ? meta.user_id : undefined, }; } export default { id: "dirigent", name: "Dirigent", register(api: OpenClawPluginApi) { // Merge pluginConfig with defaults (in case config is missing from openclaw.json) const baseConfig = { enableDiscordControlTool: true, enableDirigentPolicyTool: true, discordControlApiBaseUrl: "http://127.0.0.1:8790", schedulingIdentifier: "➡️", waitIdentifier: "👤", ...(api.pluginConfig || {}), } as DirigentConfig & { enableDiscordControlTool: boolean; discordControlApiBaseUrl: string; discordControlApiToken?: string; discordControlCallerId?: string; enableDirigentPolicyTool: boolean; }; const liveAtRegister = getLivePluginConfig(api, baseConfig as DirigentConfig); ensurePolicyStateLoaded(api, liveAtRegister); // Resolve plugin directory for locating sibling modules (no-reply-api/) // Note: api.resolvePath(".") returns cwd, not script directory. Use import.meta.url instead. const pluginDir = path.dirname(new URL(import.meta.url).pathname); api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`); // Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway api.on("gateway_start", () => { api.logger.info(`dirigent: gateway_start event received`); // Check no-reply-api server file exists const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); api.logger.info(`dirigent: checking no-reply-api server at ${serverPath}, exists=${fs.existsSync(serverPath)}`); // Additional debug: list what's in the parent directory const parentDir = path.resolve(pluginDir, ".."); try { const entries = fs.readdirSync(parentDir); api.logger.info(`dirigent: parent dir (${parentDir}) entries: ${JSON.stringify(entries)}`); } catch (e) { api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`); } startNoReplyApi(api.logger, pluginDir); const live = getLivePluginConfig(api, baseConfig as DirigentConfig); api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); if (live.moderatorBotToken) { api.logger.info("dirigent: starting moderator bot presence..."); startModeratorPresence(live.moderatorBotToken, api.logger); api.logger.info("dirigent: moderator bot presence started"); } else { api.logger.info("dirigent: moderator bot not starting - no moderatorBotToken in config"); } }); api.on("gateway_stop", () => { stopNoReplyApi(api.logger); stopModeratorPresence(); api.logger.info("dirigent: gateway stopping, services shut down"); }); // Register tools registerDirigentTools({ api, baseConfig: baseConfig as DirigentConfig, policyState, pickDefined, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig, }); // Turn management is handled internally by the plugin (not exposed as tools). // Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control. registerMessageReceivedHook({ api, baseConfig: baseConfig as DirigentConfig, getLivePluginConfig, shouldDebugLog, debugCtxSummary, ensureTurnOrder, getModeratorUserId, recordChannelAccount, extractMentionedUserIds, buildUserIdToAccountIdMap, }); registerBeforeModelResolveHook({ api, baseConfig: baseConfig as DirigentConfig, sessionDecision, sessionAllowed, sessionChannelId, sessionAccountId, policyState, DECISION_TTL_MS, ensurePolicyStateLoaded, getLivePluginConfig, resolveAccountId, pruneDecisionMap, shouldDebugLog, ensureTurnOrder, }); registerBeforePromptBuildHook({ api, baseConfig: baseConfig as DirigentConfig, sessionDecision, sessionInjected, policyState, DECISION_TTL_MS, ensurePolicyStateLoaded, getLivePluginConfig, shouldDebugLog, buildEndMarkerInstruction, buildSchedulingIdentifierInstruction, buildAgentIdentity, }); // Register slash commands for Discord registerDirigentCommand({ api, policyState, }); // Handle NO_REPLY detection before message write registerBeforeMessageWriteHook({ api, baseConfig: baseConfig as DirigentConfig, policyState, sessionAllowed, sessionChannelId, sessionAccountId, sessionTurnHandled, ensurePolicyStateLoaded, getLivePluginConfig, shouldDebugLog, ensureTurnOrder, resolveDiscordUserId, sendModeratorMessage, }); // Turn advance: when an agent sends a message, check if it signals end of turn registerMessageSentHook({ api, baseConfig: baseConfig as DirigentConfig, policyState, sessionChannelId, sessionAccountId, sessionTurnHandled, ensurePolicyStateLoaded, getLivePluginConfig, resolveDiscordUserId, sendModeratorMessage, }); }, };