Files
Dirigent/plugin/index.ts

241 lines
8.8 KiB
TypeScript

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 { registerAddGuildCommand } from "./commands/add-guild-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,
getDiscussionSessionKeys,
pruneDecisionMap,
recordDiscussionSession,
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);
},
getDiscussionSessionKeys,
});
// 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,
recordDiscussionSession,
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,
});
// Register add-guild command
registerAddGuildCommand(api);
// 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,
});
},
};