import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { DirigentConfig } from "./rules.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"; import { ensurePolicyStateLoaded, persistPolicies, policyState } from "./policy/store.js"; import { buildAgentIdentity, buildUserIdToAccountIdMap, resolveAccountId } from "./core/identity.js"; import { extractMentionedUserIds, getModeratorUserId } from "./core/mentions.js"; import { ensureTurnOrder, recordChannelAccount } from "./core/turn-bootstrap.js"; import { debugCtxSummary, pickDefined, shouldDebugLog } from "./core/utils.js"; import { resolveDiscordUserId, sendModeratorMessage } from "./core/moderator-discord.js"; import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; import { createDiscussionService } from "./core/discussion-service.js"; import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "./core/channel-modes.js"; import { DECISION_TTL_MS, forceNoReplySessions, pruneDecisionMap, sessionAccountId, sessionAllowed, sessionChannelId, sessionDecision, sessionInjected, sessionTurnHandled, } from "./core/session-state.js"; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; type NormalizedDirigentConfig = DirigentConfig & { enableDiscordControlTool: boolean; enableDirigentPolicyTool: boolean; }; function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig { return { enableDiscordControlTool: true, enableDirigentPolicyTool: true, enableDebugLogs: false, debugLogChannelIds: [], noReplyPort: 8787, schedulingIdentifier: "➡️", waitIdentifier: "👤", multiMessageStartMarker: "↗️", multiMessageEndMarker: "↙️", multiMessagePromptMarker: "⤵️", ...(api.pluginConfig || {}), } as NormalizedDirigentConfig; } 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, NO, or an empty response) 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.`; } export default { id: "dirigent", name: "Dirigent", register(api: OpenClawPluginApi) { const baseConfig = normalizePluginConfig(api); ensurePolicyStateLoaded(api, baseConfig); // 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`); const live = normalizePluginConfig(api); // 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 plugin directory try { const entries = fs.readdirSync(pluginDir); api.logger.info(`dirigent: plugin dir (${pluginDir}) entries: ${JSON.stringify(entries)}`); } catch (e) { api.logger.warn(`dirigent: cannot read plugin dir: ${String(e)}`); } startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); 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"); }); const discussionService = createDiscussionService({ api, moderatorBotToken: baseConfig.moderatorBotToken, moderatorUserId: getModeratorUserId(baseConfig), workspaceRoot: process.cwd(), forceNoReplyForSession: (sessionKey: string) => { if (sessionKey) forceNoReplySessions.add(sessionKey); }, }); // Register tools registerDirigentTools({ api, baseConfig, pickDefined, discussionService, }); // 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, shouldDebugLog, debugCtxSummary, ensureTurnOrder, getModeratorUserId, recordChannelAccount, extractMentionedUserIds, buildUserIdToAccountIdMap, enterMultiMessageMode, exitMultiMessageMode, discussionService, }); registerBeforeModelResolveHook({ api, baseConfig, sessionDecision, sessionAllowed, sessionChannelId, sessionAccountId, forceNoReplySessions, policyState, DECISION_TTL_MS, ensurePolicyStateLoaded, resolveAccountId, pruneDecisionMap, shouldDebugLog, ensureTurnOrder, isMultiMessageMode, discussionService, }); registerBeforePromptBuildHook({ api, baseConfig, sessionDecision, sessionInjected, policyState, DECISION_TTL_MS, ensurePolicyStateLoaded, shouldDebugLog, buildEndMarkerInstruction, buildSchedulingIdentifierInstruction, buildAgentIdentity, }); // Register slash commands for Discord registerDirigentCommand({ api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, }); // Handle NO_REPLY detection before message write registerBeforeMessageWriteHook({ api, baseConfig, policyState, sessionAllowed, sessionChannelId, sessionAccountId, sessionTurnHandled, ensurePolicyStateLoaded, shouldDebugLog, ensureTurnOrder, resolveDiscordUserId, isMultiMessageMode, sendModeratorMessage, discussionService, }); // Turn advance: when an agent sends a message, check if it signals end of turn registerMessageSentHook({ api, baseConfig, policyState, sessionChannelId, sessionAccountId, sessionTurnHandled, ensurePolicyStateLoaded, resolveDiscordUserId, sendModeratorMessage, discussionService, }); }, };