241 lines
8.8 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|