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:
221
plugin/hooks/agent-end.ts
Normal file
221
plugin/hooks/agent-end.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { parseDiscordChannelId } from "./before-model-resolve.js";
|
||||
import {
|
||||
isCurrentSpeaker,
|
||||
getAnchor,
|
||||
advanceSpeaker,
|
||||
wakeFromDormant,
|
||||
hasSpeakers,
|
||||
getDebugInfo,
|
||||
isTurnPending,
|
||||
clearTurnPending,
|
||||
consumeBlockedPending,
|
||||
type SpeakerEntry,
|
||||
} from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import { pollForTailMatch, sendAndDelete } from "../core/moderator-discord.js";
|
||||
|
||||
const TAIL_LENGTH = 40;
|
||||
const TAIL_MATCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Process-level deduplication for agent_end events.
|
||||
* OpenClaw hot-reloads plugin modules (re-imports), stacking duplicate handlers.
|
||||
* Using globalThis ensures the dedup Set survives module reloads and is shared
|
||||
* by all handler instances in the same process.
|
||||
*/
|
||||
const _AGENT_END_DEDUP_KEY = "_dirigentProcessedAgentEndRunIds";
|
||||
if (!(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] = new Set<string>();
|
||||
}
|
||||
const processedAgentEndRunIds: Set<string> = (globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] as Set<string>;
|
||||
|
||||
/** Extract plain text from agent_end event.messages last assistant entry. */
|
||||
function extractFinalText(messages: unknown[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i] as Record<string, unknown> | null;
|
||||
if (!msg || msg.role !== "assistant") continue;
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
let text = "";
|
||||
for (const part of content) {
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p?.type === "text" && typeof p.text === "string") text += p.text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isEmptyTurn(text: string): boolean {
|
||||
const t = text.trim();
|
||||
return t === "" || /^NO$/i.test(t) || /^NO_REPLY$/i.test(t);
|
||||
}
|
||||
|
||||
export type AgentEndDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
/** Called when discussion channel enters dormant — to send idle reminder. */
|
||||
onDiscussionDormant?: (channelId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Exposed so message-received can interrupt an in-progress tail-match wait. */
|
||||
export type InterruptFn = (channelId: string) => void;
|
||||
|
||||
export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionDormant } = deps;
|
||||
|
||||
const interruptedChannels = new Set<string>();
|
||||
|
||||
function interrupt(channelId: string): void {
|
||||
interruptedChannels.add(channelId);
|
||||
setTimeout(() => interruptedChannels.delete(channelId), 5000);
|
||||
}
|
||||
|
||||
async function buildSpeakerList(channelId: string): Promise<SpeakerEntry[]> {
|
||||
if (!moderatorBotToken) return [];
|
||||
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
|
||||
const result: SpeakerEntry[] = [];
|
||||
for (const agentId of agentIds) {
|
||||
const identity = identityRegistry.findByAgentId(agentId);
|
||||
if (identity) result.push({ agentId, discordUserId: identity.discordUserId });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
|
||||
if (!moderatorBotToken) return;
|
||||
const msg = `<@${next.discordUserId}>${scheduleIdentifier}`;
|
||||
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
||||
api.logger.info(`dirigent: triggered next speaker agentId=${next.agentId} channel=${channelId}`);
|
||||
}
|
||||
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
try {
|
||||
// Deduplicate: skip if this runId was already processed by another handler
|
||||
// instance (can happen when OpenClaw hot-reloads the plugin in the same process).
|
||||
const runId = (event as Record<string, unknown>).runId as string | undefined;
|
||||
if (runId) {
|
||||
if (processedAgentEndRunIds.has(runId)) return;
|
||||
processedAgentEndRunIds.add(runId);
|
||||
// Evict old entries to prevent unbounded growth (keep last 500)
|
||||
if (processedAgentEndRunIds.size > 500) {
|
||||
const oldest = processedAgentEndRunIds.values().next().value;
|
||||
if (oldest) processedAgentEndRunIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = ctx.sessionKey;
|
||||
if (!sessionKey) return;
|
||||
|
||||
const channelId = parseDiscordChannelId(sessionKey);
|
||||
if (!channelId) return;
|
||||
|
||||
const mode = channelStore.getMode(channelId);
|
||||
if (mode === "none" || mode === "work" || mode === "report") return;
|
||||
|
||||
if (!hasSpeakers(channelId)) return;
|
||||
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return;
|
||||
if (!isCurrentSpeaker(channelId, agentId)) return;
|
||||
|
||||
// Only process agent_ends for turns that were explicitly started by before_model_resolve.
|
||||
// This prevents stale NO_REPLY completions (from initial suppression) from being counted.
|
||||
if (!isTurnPending(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: agent_end skipping stale turn agentId=${agentId} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume a blocked-pending slot if any exists. These are NO_REPLY completions
|
||||
// from before_model_resolve blocking events (non-speaker or init-suppressed) that
|
||||
// fire late — after the agent became the current speaker — due to history-building
|
||||
// overhead (~10s). We skip them until the counter is exhausted, at which point
|
||||
// the next agent_end is the real LLM response.
|
||||
if (consumeBlockedPending(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTurnPending(channelId, agentId);
|
||||
|
||||
const messages = Array.isArray((event as Record<string, unknown>).messages)
|
||||
? ((event as Record<string, unknown>).messages as unknown[])
|
||||
: [];
|
||||
const finalText = extractFinalText(messages);
|
||||
const empty = isEmptyTurn(finalText);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
||||
);
|
||||
|
||||
if (!empty) {
|
||||
// Real turn: wait for Discord delivery via tail-match polling
|
||||
const identity = identityRegistry.findByAgentId(agentId);
|
||||
if (identity && moderatorBotToken) {
|
||||
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
||||
const tail = [...finalText].slice(-TAIL_LENGTH).join("");
|
||||
|
||||
const { matched, interrupted } = await pollForTailMatch({
|
||||
token: moderatorBotToken,
|
||||
channelId,
|
||||
anchorId,
|
||||
agentDiscordUserId: identity.discordUserId,
|
||||
tailFingerprint: tail,
|
||||
timeoutMs: TAIL_MATCH_TIMEOUT_MS,
|
||||
isInterrupted: () => interruptedChannels.has(channelId),
|
||||
});
|
||||
|
||||
if (interrupted) {
|
||||
api.logger.info(`dirigent: tail-match interrupted channel=${channelId} — wake-from-dormant`);
|
||||
const first = wakeFromDormant(channelId);
|
||||
if (first) await triggerNextSpeaker(channelId, first);
|
||||
return;
|
||||
}
|
||||
if (!matched) {
|
||||
api.logger.warn(`dirigent: tail-match timeout channel=${channelId} agentId=${agentId} — advancing anyway`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine shuffle mode from current list size
|
||||
const debugBefore = getDebugInfo(channelId);
|
||||
const currentListSize = debugBefore.exists ? (debugBefore.speakerList?.length ?? 0) : 0;
|
||||
const isShuffle = currentListSize > 2;
|
||||
// In shuffle mode, pass agentId as previousLastAgentId so new list avoids it as first
|
||||
const previousLastAgentId = isShuffle ? agentId : undefined;
|
||||
|
||||
const { next, enteredDormant } = await advanceSpeaker(
|
||||
channelId,
|
||||
agentId,
|
||||
empty,
|
||||
() => buildSpeakerList(channelId),
|
||||
previousLastAgentId,
|
||||
);
|
||||
|
||||
if (enteredDormant) {
|
||||
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
||||
if (mode === "discussion") {
|
||||
await onDiscussionDormant?.(channelId).catch((err) => {
|
||||
api.logger.warn(`dirigent: onDiscussionDormant failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (next) await triggerNextSpeaker(channelId, next);
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: agent_end hook error: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return interrupt;
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type BeforeMessageWriteDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
isMultiMessageMode: (channelId: string) => boolean;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<unknown>;
|
||||
discussionService?: {
|
||||
maybeSendIdleReminder: (channelId: string) => Promise<void>;
|
||||
getDiscussion: (channelId: string) => { status: string } | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
resolveDiscordUserId,
|
||||
isMultiMessageMode,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
} = deps;
|
||||
|
||||
api.on("before_message_write", (event, ctx) => {
|
||||
try {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
);
|
||||
|
||||
const key = ctx.sessionKey;
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
let content = "";
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
||||
const msgContent = msg?.content;
|
||||
if (msg) {
|
||||
const role = msg.role as string | undefined;
|
||||
if (role && role !== "assistant") return;
|
||||
|
||||
// Detect tool calls — intermediate model step, not a final response.
|
||||
// Skip turn processing entirely to avoid false NO_REPLY detection.
|
||||
if (Array.isArray(msgContent)) {
|
||||
const hasToolCalls = (msgContent as Record<string, unknown>[]).some(
|
||||
(part) => part?.type === "toolCall" || part?.type === "tool_call" || part?.type === "tool_use",
|
||||
);
|
||||
if (hasToolCalls) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping tool-call message session=${key ?? "undefined"} channel=${channelId ?? "undefined"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") content += part;
|
||||
else if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
|
||||
content += (part as Record<string, unknown>).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
content = ((event as Record<string, unknown>).content as string) || "";
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
if (!key || !channelId || !accountId) return;
|
||||
|
||||
const currentTurn = getTurnDebugInfo(channelId);
|
||||
if (currentTurn.currentSpeaker !== accountId) {
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
// Treat explicit NO/NO_REPLY keywords as no-reply.
|
||||
const wasNoReply = isNoReply;
|
||||
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
const noReplyKeyword = /^NO$/i.test(trimmed) ? "NO" : "NO_REPLY";
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed} keyword=${noReplyKeyword}`,
|
||||
);
|
||||
|
||||
if (wasAllowed === undefined) return;
|
||||
|
||||
if (wasAllowed === false) {
|
||||
sessionAllowed.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
|
||||
if (!nextSpeaker) {
|
||||
if (discussionService?.getDiscussion(channelId)?.status === "active") {
|
||||
void discussionService.maybeSendIdleReminder(channelId).catch((err) => {
|
||||
api.logger.warn(`dirigent: idle reminder failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
if (isMultiMessageMode(channelId)) {
|
||||
// In multi-message mode, send the prompt marker instead of scheduling identifier
|
||||
const promptMarker = live.multiMessagePromptMarker || "⤵️";
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, promptMarker, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write multi-message prompt marker failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
|
||||
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasEndSymbol) {
|
||||
void ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
} else {
|
||||
api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,176 +1,141 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { checkTurn } from "../turn-manager.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { isCurrentSpeaker, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||
import { getLatestMessageId, sendAndDelete } from "../core/moderator-discord.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
|
||||
export function parseDiscordChannelId(sessionKey: string): string | undefined {
|
||||
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforeModelResolveDeps = {
|
||||
type Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionAllowed: Map<string, boolean>;
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
recordDiscussionSession?: (channelId: string, sessionKey: string) => void;
|
||||
forceNoReplySessions: Set<string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
|
||||
pruneDecisionMap: () => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
isMultiMessageMode: (channelId: string) => boolean;
|
||||
discussionService?: {
|
||||
isClosedDiscussion: (channelId: string) => boolean;
|
||||
};
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
noReplyModel: string;
|
||||
noReplyProvider: string;
|
||||
scheduleIdentifier: string;
|
||||
};
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionAllowed,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
recordDiscussionSession,
|
||||
forceNoReplySessions,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveAccountId,
|
||||
pruneDecisionMap,
|
||||
shouldDebugLog,
|
||||
ensureTurnOrder,
|
||||
isMultiMessageMode,
|
||||
discussionService,
|
||||
} = deps;
|
||||
/**
|
||||
* Process-level deduplication for before_model_resolve events.
|
||||
* Uses a WeakSet keyed on the event object — works when OpenClaw passes
|
||||
* the same event reference to all stacked handlers (hot-reload scenario).
|
||||
* Stored on globalThis so it persists across module reloads.
|
||||
*/
|
||||
const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents";
|
||||
if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] = new WeakSet<object>();
|
||||
}
|
||||
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
|
||||
|
||||
export function registerBeforeModelResolveHook(deps: Deps): void {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps;
|
||||
|
||||
const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const;
|
||||
|
||||
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
|
||||
const initializingChannels = getInitializingChannels();
|
||||
|
||||
api.on("before_model_resolve", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
// Deduplicate: if another handler instance already processed this event
|
||||
// object, skip. Prevents double-counting from hot-reload stacked handlers.
|
||||
const eventObj = event as object;
|
||||
if (processedBeforeModelResolveEvents.has(eventObj)) return;
|
||||
processedBeforeModelResolveEvents.add(eventObj);
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const sessionKey = ctx.sessionKey;
|
||||
if (!sessionKey) return;
|
||||
|
||||
if (forceNoReplySessions.has(key)) {
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
}
|
||||
// Only handle Discord group channel sessions
|
||||
const channelId = parseDiscordChannelId(sessionKey);
|
||||
if (!channelId) return;
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
if (live.enableDebugLogs) {
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
|
||||
`promptPreview=${prompt.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
// dead mode: suppress all responses
|
||||
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
|
||||
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
// disabled modes: let agents respond freely
|
||||
if (mode === "none" || mode === "work") return;
|
||||
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||
// discussion / chat: check turn
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return;
|
||||
|
||||
if (derived.channelId) {
|
||||
sessionChannelId.set(key, derived.channelId);
|
||||
recordDiscussionSession?.(derived.channelId, key);
|
||||
if (discussionService?.isClosedDiscussion(derived.channelId)) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(`dirigent: before_model_resolve forcing no-reply for closed discussion channel=${derived.channelId} session=${key}`);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
// If speaker list not yet loaded, initialize it now
|
||||
if (!hasSpeakers(channelId)) {
|
||||
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
|
||||
if (initializingChannels.has(channelId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
|
||||
return NO_REPLY;
|
||||
}
|
||||
|
||||
if (isMultiMessageMode(derived.channelId)) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (resolvedAccountId) {
|
||||
sessionAccountId.set(key, resolvedAccountId);
|
||||
}
|
||||
initializingChannels.add(channelId);
|
||||
try {
|
||||
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
|
||||
const speakers: SpeakerEntry[] = agentIds
|
||||
.map((aid) => {
|
||||
const entry = identityRegistry.findByAgentId(aid);
|
||||
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
|
||||
})
|
||||
.filter((s): s is SpeakerEntry => s !== null);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
sessionDecision.set(key, rec);
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_model_resolve recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (speakers.length > 0) {
|
||||
setSpeakerList(channelId, speakers);
|
||||
const first = speakers[0];
|
||||
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
|
||||
|
||||
if (derived.channelId) {
|
||||
await ensureTurnOrder(api, derived.channelId);
|
||||
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (accountId) {
|
||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||
if (!turnCheck.allowed) {
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(
|
||||
`dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`,
|
||||
);
|
||||
return {
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
noReply: true,
|
||||
};
|
||||
// If this agent is NOT the first speaker, trigger first speaker and suppress this one
|
||||
if (first.agentId !== agentId && moderatorBotToken) {
|
||||
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
||||
return NO_REPLY;
|
||||
}
|
||||
// If this agent IS the first speaker, fall through to normal turn logic
|
||||
} else {
|
||||
// No registered agents visible — let everyone respond freely
|
||||
return;
|
||||
}
|
||||
sessionAllowed.set(key, true);
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`);
|
||||
return;
|
||||
} finally {
|
||||
initializingChannels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) return;
|
||||
// If channel is dormant: suppress all agents
|
||||
if (isDormant(channelId)) return NO_REPLY;
|
||||
|
||||
const out: Record<string, unknown> = { noReply: true };
|
||||
if (rec.decision.provider) out.provider = rec.decision.provider;
|
||||
if (rec.decision.model) out.model = rec.decision.model;
|
||||
return out;
|
||||
if (!isCurrentSpeaker(channelId, agentId)) {
|
||||
api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
|
||||
incrementBlockedPending(channelId, agentId);
|
||||
return NO_REPLY;
|
||||
}
|
||||
|
||||
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
|
||||
markTurnStarted(channelId, agentId);
|
||||
|
||||
// Current speaker: record anchor message ID for tail-match polling
|
||||
if (moderatorBotToken) {
|
||||
try {
|
||||
const anchorId = await getLatestMessageId(moderatorBotToken, channelId);
|
||||
if (anchorId) {
|
||||
setAnchor(channelId, agentId, anchorId);
|
||||
api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify agent has a known Discord user ID (needed for tail-match later)
|
||||
const identity = identityRegistry.findByAgentId(agentId);
|
||||
if (!identity) {
|
||||
api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js";
|
||||
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type DecisionRecord = {
|
||||
decision: Decision;
|
||||
createdAt: number;
|
||||
needsRestore?: boolean;
|
||||
};
|
||||
|
||||
type BeforePromptBuildDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
sessionDecision: Map<string, DecisionRecord>;
|
||||
sessionInjected: Set<string>;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
DECISION_TTL_MS: number;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string;
|
||||
buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string;
|
||||
buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string;
|
||||
};
|
||||
|
||||
export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
sessionDecision,
|
||||
sessionInjected,
|
||||
policyState,
|
||||
DECISION_TTL_MS,
|
||||
ensurePolicyStateLoaded,
|
||||
shouldDebugLog,
|
||||
buildEndMarkerInstruction,
|
||||
buildSchedulingIdentifierInstruction,
|
||||
buildAgentIdentity,
|
||||
} = deps;
|
||||
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
channel: derived.channel,
|
||||
channelId: derived.channelId,
|
||||
channelPolicies: policyState.channelPolicies as Record<string, any>,
|
||||
senderId: derived.senderId,
|
||||
content: derived.content,
|
||||
});
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: debug before_prompt_build recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
|
||||
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sessionDecision.delete(key);
|
||||
|
||||
if (sessionInjected.has(key)) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record<string, any>);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId);
|
||||
|
||||
let identity = "";
|
||||
if (isGroupChat && ctx.agentId) {
|
||||
const idStr = buildAgentIdentity(api, ctx.agentId);
|
||||
if (idStr) {
|
||||
identity = `\n\nYour agent identity: ${idStr}.`;
|
||||
}
|
||||
}
|
||||
|
||||
const schedulingInstruction = isGroupChat ? buildSchedulingIdentifierInstruction(schedulingId) : "";
|
||||
(event as Record<string, unknown>).prompt = `${prompt}\n\n${instruction}${identity}${schedulingInstruction}`;
|
||||
sessionInjected.add(key);
|
||||
});
|
||||
}
|
||||
@@ -1,141 +1,128 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId } from "../channel-resolver.js";
|
||||
import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.js";
|
||||
import type { DirigentConfig } from "../rules.js";
|
||||
import type { ChannelStore } from "../core/channel-store.js";
|
||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||
import { parseDiscordChannelId } from "./before-model-resolve.js";
|
||||
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||
import { sendAndDelete, sendModeratorMessage } from "../core/moderator-discord.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import type { InterruptFn } from "./agent-end.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageReceivedDeps = {
|
||||
type Deps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
||||
debugCtxSummary: (ctx: Record<string, unknown>, event: Record<string, unknown>) => Record<string, unknown>;
|
||||
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
||||
getModeratorUserId: (cfg: DirigentConfig) => string | undefined;
|
||||
recordChannelAccount: (api: OpenClawPluginApi, channelId: string, accountId: string) => boolean;
|
||||
extractMentionedUserIds: (content: string) => string[];
|
||||
buildUserIdToAccountIdMap: (api: OpenClawPluginApi) => Map<string, string>;
|
||||
enterMultiMessageMode: (channelId: string) => void;
|
||||
exitMultiMessageMode: (channelId: string) => void;
|
||||
discussionService?: {
|
||||
maybeReplyClosedChannel: (channelId: string, senderId?: string) => Promise<boolean>;
|
||||
};
|
||||
channelStore: ChannelStore;
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
interruptTailMatch: InterruptFn;
|
||||
};
|
||||
|
||||
export function registerMessageReceivedHook(deps: MessageReceivedDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
shouldDebugLog,
|
||||
debugCtxSummary,
|
||||
ensureTurnOrder,
|
||||
getModeratorUserId,
|
||||
recordChannelAccount,
|
||||
extractMentionedUserIds,
|
||||
buildUserIdToAccountIdMap,
|
||||
enterMultiMessageMode,
|
||||
exitMultiMessageMode,
|
||||
discussionService,
|
||||
} = deps;
|
||||
export function registerMessageReceivedHook(deps: Deps): void {
|
||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
try {
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
const preChannelId = extractDiscordChannelId(c, e);
|
||||
const livePre = baseConfig as DirigentConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
const e = event as Record<string, unknown>;
|
||||
const c = ctx as Record<string, unknown>;
|
||||
|
||||
// Extract Discord channel ID from session key or event metadata
|
||||
let channelId: string | undefined;
|
||||
if (typeof c.sessionKey === "string") {
|
||||
channelId = parseDiscordChannelId(c.sessionKey);
|
||||
}
|
||||
if (!channelId) {
|
||||
// Try from event metadata (conversation_info channel_id field)
|
||||
const metadata = e.metadata as Record<string, unknown> | undefined;
|
||||
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
|
||||
const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? "");
|
||||
if (/^\d+$/.test(raw)) channelId = raw;
|
||||
}
|
||||
if (!channelId) return;
|
||||
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
// dead: suppress routing entirely (OpenClaw handles no-route automatically,
|
||||
// but we handle archived auto-reply here)
|
||||
if (mode === "report") return;
|
||||
|
||||
// archived: auto-reply via moderator
|
||||
if (mode === "discussion") {
|
||||
const rec = channelStore.getRecord(channelId);
|
||||
if (rec.discussion?.concluded && moderatorBotToken) {
|
||||
await sendModeratorMessage(
|
||||
moderatorBotToken, channelId,
|
||||
"This discussion is closed and no longer active.",
|
||||
api.logger,
|
||||
).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (preChannelId) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||
const from =
|
||||
(typeof metadata?.senderId === "string" && metadata.senderId) ||
|
||||
(typeof (e as Record<string, unknown>).from === "string" ? ((e as Record<string, unknown>).from as string) : "");
|
||||
if (mode === "none" || mode === "work") return;
|
||||
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (discussionService) {
|
||||
const closedHandled = await discussionService.maybeReplyClosedChannel(preChannelId, from);
|
||||
if (closedHandled) return;
|
||||
// chat / discussion (active): initialize speaker list on first message if needed
|
||||
const initializingChannels = getInitializingChannels();
|
||||
if (!hasSpeakers(channelId) && moderatorBotToken) {
|
||||
// Guard against concurrent initialization from multiple VM contexts
|
||||
if (initializingChannels.has(channelId)) {
|
||||
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
initializingChannels.add(channelId);
|
||||
try {
|
||||
const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry);
|
||||
const speakers: SpeakerEntry[] = agentIds
|
||||
.map((aid) => {
|
||||
const entry = identityRegistry.findByAgentId(aid);
|
||||
return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null;
|
||||
})
|
||||
.filter((s): s is SpeakerEntry => s !== null);
|
||||
|
||||
const messageContent = ((e as Record<string, unknown>).content as string) || ((e as Record<string, unknown>).text as string) || "";
|
||||
const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent));
|
||||
|
||||
if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
|
||||
if (speakers.length > 0) {
|
||||
setSpeakerList(channelId, speakers);
|
||||
const first = speakers[0];
|
||||
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
|
||||
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
const isHuman = humanList.includes(from);
|
||||
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
|
||||
} finally {
|
||||
initializingChannels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (senderAccountId && senderAccountId !== "default") {
|
||||
const isNew = recordChannelAccount(api, preChannelId, senderAccountId);
|
||||
if (isNew) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
}
|
||||
}
|
||||
// chat / discussion (active): check if this is an external message
|
||||
// that should interrupt an in-progress tail-match or wake dormant
|
||||
|
||||
if (isHuman) {
|
||||
const startMarker = livePre.multiMessageStartMarker || "↗️";
|
||||
const endMarker = livePre.multiMessageEndMarker || "↙️";
|
||||
const senderId = String(
|
||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||
(e.metadata as Record<string, unknown>)?.sender_id ??
|
||||
e.from ?? "",
|
||||
);
|
||||
|
||||
if (messageContent.includes(startMarker)) {
|
||||
enterMultiMessageMode(preChannelId);
|
||||
api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`);
|
||||
} else if (messageContent.includes(endMarker)) {
|
||||
exitMultiMessageMode(preChannelId);
|
||||
api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`);
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
} else {
|
||||
const mentionedUserIds = extractMentionedUserIds(messageContent);
|
||||
// Identify the sender: is it the current speaker's Discord account?
|
||||
const currentSpeakerIsThisSender = (() => {
|
||||
if (!senderId) return false;
|
||||
const entry = identityRegistry.findByDiscordUserId(senderId);
|
||||
if (!entry) return false;
|
||||
return isCurrentSpeaker(channelId!, entry.agentId);
|
||||
})();
|
||||
|
||||
if (mentionedUserIds.length > 0) {
|
||||
const userIdMap = buildUserIdToAccountIdMap(api);
|
||||
const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid);
|
||||
if (!currentSpeakerIsThisSender) {
|
||||
// Non-current-speaker posted — interrupt any tail-match in progress
|
||||
interruptTailMatch(channelId);
|
||||
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
|
||||
|
||||
if (mentionedAccountIds.length > 0) {
|
||||
await ensureTurnOrder(api, preChannelId);
|
||||
const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds);
|
||||
if (overrideSet) {
|
||||
api.logger.info(
|
||||
`dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`,
|
||||
);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onNewMessage(preChannelId, senderAccountId, false);
|
||||
}
|
||||
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`,
|
||||
);
|
||||
// Wake from dormant if needed
|
||||
if (isDormant(channelId) && moderatorBotToken) {
|
||||
const first = wakeFromDormant(channelId);
|
||||
if (first) {
|
||||
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
|
||||
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
||||
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: message_received hook error: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
||||
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
||||
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
|
||||
|
||||
type DebugConfig = {
|
||||
enableDebugLogs?: boolean;
|
||||
debugLogChannelIds?: string[];
|
||||
};
|
||||
|
||||
type MessageSentDeps = {
|
||||
api: OpenClawPluginApi;
|
||||
baseConfig: DirigentConfig;
|
||||
policyState: { channelPolicies: Record<string, unknown> };
|
||||
sessionChannelId: Map<string, string>;
|
||||
sessionAccountId: Map<string, string>;
|
||||
sessionTurnHandled: Set<string>;
|
||||
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
||||
resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined;
|
||||
sendModeratorMessage: (
|
||||
botToken: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void },
|
||||
) => Promise<unknown>;
|
||||
discussionService?: {
|
||||
isClosedDiscussion: (channelId: string) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
||||
const {
|
||||
api,
|
||||
baseConfig,
|
||||
policyState,
|
||||
sessionChannelId,
|
||||
sessionAccountId,
|
||||
sessionTurnHandled,
|
||||
ensurePolicyStateLoaded,
|
||||
resolveDiscordUserId,
|
||||
sendModeratorMessage,
|
||||
discussionService,
|
||||
} = deps;
|
||||
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const key = ctx.sessionKey;
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
const e = (event || {}) as Record<string, unknown>;
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
|
||||
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
|
||||
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
|
||||
`session=${key ?? "undefined"}`,
|
||||
);
|
||||
|
||||
let channelId = extractDiscordChannelId(c, e);
|
||||
if (!channelId && key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!channelId && key) {
|
||||
channelId = extractDiscordChannelIdFromSessionKey(key);
|
||||
}
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
api.logger.info(
|
||||
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = baseConfig as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const waitId = live.waitIdentifier || "👤";
|
||||
const hasWaitIdentifier = !!lastChar && lastChar === waitId;
|
||||
// Treat explicit NO/NO_REPLY keywords as no-reply.
|
||||
const wasNoReply = isNoReply;
|
||||
|
||||
if (key && sessionTurnHandled.has(key)) {
|
||||
sessionTurnHandled.delete(key);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWaitIdentifier) {
|
||||
setWaitingForHuman(channelId);
|
||||
api.logger.info(
|
||||
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasNoReply || hasEndSymbol) {
|
||||
// Check if this is a closed discussion channel
|
||||
if (discussionService?.isClosedDiscussion(channelId)) {
|
||||
api.logger.info(
|
||||
`dirigent: message_sent skipping turn advance for closed discussion channel=${channelId} from=${accountId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol";
|
||||
const noReplyKeyword = wasNoReply ? (/^NO$/i.test(trimmed) ? "NO" : "NO_REPLY") : "";
|
||||
const keywordNote = wasNoReply ? ` keyword=${noReplyKeyword}` : "";
|
||||
api.logger.info(
|
||||
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}${keywordNote}`,
|
||||
);
|
||||
|
||||
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||
} else {
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user