import path from "node:path"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { IdentityRegistry } from "./core/identity-registry.js"; import { ChannelStore } from "./core/channel-store.js"; import { scanPaddedCell } from "./core/padded-cell.js"; import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; import { registerAgentEndHook } from "./hooks/agent-end.js"; import { registerMessageReceivedHook } from "./hooks/message-received.js"; import { registerDirigentTools } from "./tools/register-tools.js"; import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js"; import { registerAddGuildCommand } from "./commands/add-guild-command.js"; import { registerControlPage } from "./web/control-page.js"; import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js"; import { setSpeakerList } from "./turn-manager.js"; import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js"; type PluginConfig = { moderatorBotToken?: string; noReplyProvider?: string; noReplyModel?: string; noReplyPort?: number; scheduleIdentifier?: string; identityFilePath?: string; channelStoreFilePath?: string; }; function normalizeConfig(api: OpenClawPluginApi): Required { const cfg = (api.pluginConfig ?? {}) as PluginConfig; return { moderatorBotToken: cfg.moderatorBotToken ?? "", noReplyProvider: cfg.noReplyProvider ?? "dirigent", noReplyModel: cfg.noReplyModel ?? "no-reply", noReplyPort: Number(cfg.noReplyPort ?? 8787), scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️", identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"), channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"), }; } /** * Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once * when the gateway process starts/stops, not per agent session. We guard these on * globalThis so only the first register() call adds the lifecycle handlers. * * Agent-session events (before_model_resolve, agent_end, message_received) are * delivered via the api instance that belongs to each individual agent session. * OpenClaw creates a new VM context (and calls register() again) for each hot-reload * within a session. We register those handlers unconditionally — event-level dedup * (WeakSet / runId Set, also stored on globalThis) prevents double-processing. * * All VM contexts share the real globalThis because they run in the same Node.js * process as openclaw-gateway. */ const _G = globalThis as Record; const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered"; function isGatewayLifecycleRegistered(): boolean { return !!_G[_GATEWAY_LIFECYCLE_KEY]; } function markGatewayLifecycleRegistered(): void { _G[_GATEWAY_LIFECYCLE_KEY] = true; } export default { id: "dirigent", name: "Dirigent", register(api: OpenClawPluginApi) { const config = normalizeConfig(api); const pluginDir = path.dirname(new URL(import.meta.url).pathname); const openclawDir = path.join(os.homedir(), ".openclaw"); const identityRegistry = new IdentityRegistry(config.identityFilePath); const channelStore = new ChannelStore(config.channelStoreFilePath); let paddedCellDetected = false; function hasPaddedCell(): boolean { return paddedCellDetected; } function tryAutoScanPaddedCell(): void { const count = scanPaddedCell(identityRegistry, openclawDir, api.logger); paddedCellDetected = count >= 0; if (paddedCellDetected) { api.logger.info(`dirigent: padded-cell detected — ${count} identity entries auto-registered`); } } // ── Gateway lifecycle (once per gateway process) ─────────────────────── if (!isGatewayLifecycleRegistered()) { markGatewayLifecycleRegistered(); api.on("gateway_start", () => { const live = normalizeConfig(api); startNoReplyApi(api.logger, pluginDir, live.noReplyPort); if (live.moderatorBotToken) { startModeratorPresence(live.moderatorBotToken, api.logger); api.logger.info("dirigent: moderator bot presence started"); } else { api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled"); } tryAutoScanPaddedCell(); }); api.on("gateway_stop", () => { stopNoReplyApi(api.logger); stopModeratorPresence(); }); } // ── Hooks (registered on every api instance — event-level dedup handles duplicates) ── registerBeforeModelResolveHook({ api, channelStore, identityRegistry, moderatorBotToken: config.moderatorBotToken || undefined, noReplyModel: config.noReplyModel, noReplyProvider: config.noReplyProvider, scheduleIdentifier: config.scheduleIdentifier, }); const interruptTailMatch = registerAgentEndHook({ api, channelStore, identityRegistry, moderatorBotToken: config.moderatorBotToken || undefined, scheduleIdentifier: config.scheduleIdentifier, onDiscussionDormant: async (channelId: string) => { const live = normalizeConfig(api); if (!live.moderatorBotToken) return; const rec = channelStore.getRecord(channelId); if (!rec.discussion || rec.discussion.concluded) return; const initiatorEntry = identityRegistry.findByAgentId(rec.discussion.initiatorAgentId); const mention = initiatorEntry ? `<@${initiatorEntry.discordUserId}>` : rec.discussion.initiatorAgentId; await sendModeratorMessage( live.moderatorBotToken, channelId, `${mention} Discussion is idle. Please summarize the results and call \`discussion-complete\`.`, api.logger, ).catch(() => undefined); }, }); registerMessageReceivedHook({ api, channelStore, identityRegistry, moderatorBotToken: config.moderatorBotToken || undefined, scheduleIdentifier: config.scheduleIdentifier, interruptTailMatch, }); // ── Tools ────────────────────────────────────────────────────────────── registerDirigentTools({ api, channelStore, identityRegistry, moderatorBotToken: config.moderatorBotToken || undefined, scheduleIdentifier: config.scheduleIdentifier, onDiscussionCreate: async ({ channelId, guildId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => { const live = normalizeConfig(api); if (!live.moderatorBotToken) return; // Post discussion-guide to wake participants await sendModeratorMessage(live.moderatorBotToken, channelId, discussionGuide, api.logger) .catch(() => undefined); // Initialize speaker list const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry); const speakers = agentIds .map((aid) => { const entry = identityRegistry.findByAgentId(aid); return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null; }) .filter((s): s is NonNullable => s !== null); if (speakers.length > 0) { setSpeakerList(channelId, speakers); const first = speakers[0]; await sendAndDelete( live.moderatorBotToken, channelId, `<@${first.discordUserId}>${live.scheduleIdentifier}`, api.logger, ).catch(() => undefined); } }, }); // ── Commands ─────────────────────────────────────────────────────────── registerSetChannelModeCommand({ api, channelStore }); registerAddGuildCommand(api); // ── Control page ─────────────────────────────────────────────────────── registerControlPage({ api, channelStore, identityRegistry, moderatorBotToken: config.moderatorBotToken || undefined, openclawDir, hasPaddedCell, }); api.logger.info("dirigent: plugin registered (v2)"); }, };