feat: rewrite plugin as v2 with globalThis-based turn management
Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
363
plugin/index.ts
363
plugin/index.ts
@@ -1,240 +1,213 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
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 { 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 { 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";
|
||||
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 DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
type PluginConfig = {
|
||||
moderatorBotToken?: string;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
noReplyPort?: number;
|
||||
scheduleIdentifier?: string;
|
||||
identityFilePath?: string;
|
||||
channelStoreFilePath?: string;
|
||||
};
|
||||
|
||||
type NormalizedDirigentConfig = DirigentConfig & {
|
||||
enableDiscordControlTool: boolean;
|
||||
enableDirigentPolicyTool: boolean;
|
||||
};
|
||||
|
||||
function normalizePluginConfig(api: OpenClawPluginApi): NormalizedDirigentConfig {
|
||||
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||
return {
|
||||
enableDiscordControlTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
noReplyPort: 8787,
|
||||
schedulingIdentifier: "➡️",
|
||||
waitIdentifier: "👤",
|
||||
multiMessageStartMarker: "↗️",
|
||||
multiMessageEndMarker: "↙️",
|
||||
multiMessagePromptMarker: "⤵️",
|
||||
...(api.pluginConfig || {}),
|
||||
} as NormalizedDirigentConfig;
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
const _GATEWAY_LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered";
|
||||
|
||||
function isGatewayLifecycleRegistered(): boolean {
|
||||
return !!_G[_GATEWAY_LIFECYCLE_KEY];
|
||||
}
|
||||
|
||||
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.`;
|
||||
function markGatewayLifecycleRegistered(): void {
|
||||
_G[_GATEWAY_LIFECYCLE_KEY] = true;
|
||||
}
|
||||
|
||||
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 config = normalizeConfig(api);
|
||||
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
api.logger.info(`dirigent: pluginDir resolved from import.meta.url: ${pluginDir}`);
|
||||
const openclawDir = path.join(os.homedir(), ".openclaw");
|
||||
|
||||
// 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 identityRegistry = new IdentityRegistry(config.identityFilePath);
|
||||
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
||||
|
||||
const live = normalizePluginConfig(api);
|
||||
let paddedCellDetected = false;
|
||||
|
||||
// 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)}`);
|
||||
function hasPaddedCell(): boolean {
|
||||
return paddedCellDetected;
|
||||
}
|
||||
|
||||
// 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)}`);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787));
|
||||
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);
|
||||
// ── Gateway lifecycle (once per gateway process) ───────────────────────
|
||||
if (!isGatewayLifecycleRegistered()) {
|
||||
markGatewayLifecycleRegistered();
|
||||
|
||||
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_start", () => {
|
||||
const live = normalizeConfig(api);
|
||||
|
||||
api.on("gateway_stop", () => {
|
||||
stopNoReplyApi(api.logger);
|
||||
stopModeratorPresence();
|
||||
api.logger.info("dirigent: gateway stopping, services shut down");
|
||||
});
|
||||
startNoReplyApi(api.logger, pluginDir, live.noReplyPort);
|
||||
|
||||
const discussionService = createDiscussionService({
|
||||
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,
|
||||
moderatorBotToken: baseConfig.moderatorBotToken,
|
||||
moderatorUserId: getModeratorUserId(baseConfig),
|
||||
workspaceRoot: process.cwd(),
|
||||
forceNoReplyForSession: (sessionKey: string) => {
|
||||
if (sessionKey) forceNoReplySessions.add(sessionKey);
|
||||
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);
|
||||
},
|
||||
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,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
interruptTailMatch,
|
||||
});
|
||||
|
||||
registerBeforeModelResolveHook({
|
||||
// ── Tools ──────────────────────────────────────────────────────────────
|
||||
registerDirigentTools({
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
recordDiscussionSession,
|
||||
forceNoReplySessions,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
isMultiMessageMode,
|
||||
discussionService,
|
||||
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<typeof s> => 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
// ── Commands ───────────────────────────────────────────────────────────
|
||||
registerSetChannelModeCommand({ api, channelStore });
|
||||
registerAddGuildCommand(api);
|
||||
|
||||
// Handle NO_REPLY detection before message write
|
||||
registerBeforeMessageWriteHook({
|
||||
// ── Control page ───────────────────────────────────────────────────────
|
||||
registerControlPage({
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
isMultiMessageMode,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
openclawDir,
|
||||
hasPaddedCell,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
api.logger.info("dirigent: plugin registered (v2)");
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user