Files
Dirigent/plugin/index.ts

209 lines
8.0 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 { registerDirigentTools } from "./tools/register-tools.js";
import { getLivePluginConfig } from "./core/live-config.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 {
DECISION_TTL_MS,
pruneDecisionMap,
sessionAccountId,
sessionAllowed,
sessionChannelId,
sessionDecision,
sessionInjected,
sessionTurnHandled,
} from "./core/session-state.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
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.`;
}
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,
schedulingIdentifier: "➡️",
waitIdentifier: "👤",
noReplyPort: 8787,
...(api.pluginConfig || {}),
} as DirigentConfig & {
enableDiscordControlTool: boolean;
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`);
const live = getLivePluginConfig(api, baseConfig as DirigentConfig);
// 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");
});
// 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,
});
},
};