@@ -1,7 +1,7 @@
import fs from "node:fs" ;
import path from "node:path" ;
import type { OpenClawPluginApi } from "openclaw/plugin-sdk" ;
import { evaluateDecision , resolvePolicy , type ChannelPolicy , type Decision , type WhisperGate Config } from "./rules.js" ;
import { evaluateDecision , resolvePolicy , type ChannelPolicy , type Decision , type Dirigent Config } from "./rules.js" ;
import { checkTurn , advanceTurn , resetTurn , onNewMessage , onSpeakerDone , initTurnOrder , getTurnDebugInfo } from "./turn-manager.js" ;
import { startModeratorPresence , stopModeratorPresence } from "./moderator-presence.js" ;
@@ -31,15 +31,20 @@ const sessionAccountId = new Map<string, string>(); // Track sessionKey -> accou
const sessionTurnHandled = new Set < string > ( ) ; // Track sessions where turn was already advanced in before_message_write
const MAX_SESSION_DECISIONS = 2000 ;
const DECISION_TTL_MS = 5 * 60 * 1000 ;
function buildEndMarkerInstruction ( endSymbols : string [ ] , isGroupChat : boolean ) : string {
function buildEndMarkerInstruction ( endSymbols : string [ ] , isGroupChat : boolean , schedulingIdentifier : string ) : string {
const symbols = endSymbols . length > 0 ? endSymbols . join ( "" ) : "🔚" ;
let instruction = ` 你的这次发言必须以 ${ symbols } 作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、 HEARTBEAT_OK),这些关键词不要加 ${ symbols } 。 ` ;
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 \ n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。 ` ;
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. ` ;
}
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. ` ;
}
const policyState : PolicyState = {
filePath : "" ,
channelPolicies : { } ,
@@ -170,31 +175,33 @@ function pruneDecisionMap(now = Date.now()) {
}
function getLivePluginConfig ( api : OpenClawPluginApi , fallback : WhisperGate Config) : WhisperGate Config {
function getLivePluginConfig ( api : OpenClawPluginApi , fallback : Dirigent Config) : Dirigent Config {
const root = ( api . config as Record < string , unknown > ) || { } ;
const plugins = ( root . plugins as Record < string , unknown > ) || { } ;
const entries = ( plugins . entries as Record < string , unknown > ) || { } ;
const entry = ( entries . whispergate as Record < string , unknown > ) || { } ;
// Support both "dirigent" and legacy "whispergate" config keys
const entry = ( entries . dirigent as Record < string , unknown > ) || ( entries . whispergate as Record < string , unknown > ) || { } ;
const cfg = ( entry . config as Record < string , unknown > ) || { } ;
if ( Object . keys ( cfg ) . length > 0 ) {
// Merge with defaults to ensure optional fields have values
return {
enableDiscordControlTool : true ,
enableWhispergate PolicyTool : true ,
enableDirigent PolicyTool : true ,
discordControlApiBaseUrl : "http://127.0.0.1:8790" ,
enableDebugLogs : false ,
debugLogChannelIds : [ ] ,
schedulingIdentifier : "➡️" ,
. . . cfg ,
} as WhisperGate Config;
} as Dirigent Config;
}
return fallback ;
}
function resolvePoliciesPath ( api : OpenClawPluginApi , config : WhisperGate Config) : string {
return api . resolvePath ( config . channelPoliciesFile || "~/.openclaw/whispergate -channel-policies.json" ) ;
function resolvePoliciesPath ( api : OpenClawPluginApi , config : Dirigent Config) : string {
return api . resolvePath ( config . channelPoliciesFile || "~/.openclaw/dirigent -channel-policies.json" ) ;
}
function ensurePolicyStateLoaded ( api : OpenClawPluginApi , config : WhisperGate Config) {
function ensurePolicyStateLoaded ( api : OpenClawPluginApi , config : Dirigent Config) {
if ( policyState . filePath ) return ;
const filePath = resolvePoliciesPath ( api , config ) ;
policyState . filePath = filePath ;
@@ -211,7 +218,7 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
const parsed = JSON . parse ( raw ) as Record < string , ChannelPolicy > ;
policyState . channelPolicies = parsed && typeof parsed === "object" ? parsed : { } ;
} catch ( err ) {
api . logger . warn ( ` whispergate : failed init policy file ${ filePath } : ${ String ( err ) } ` ) ;
api . logger . warn ( ` dirigent : failed init policy file ${ filePath } : ${ String ( err ) } ` ) ;
policyState . channelPolicies = { } ;
}
}
@@ -294,6 +301,7 @@ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
/**
* Build agent identity string for injection into group chat prompts.
* Includes agent name, Discord accountId, and Discord userId.
*/
function buildAgentIdentity ( api : OpenClawPluginApi , agentId : string ) : string | undefined {
const root = ( api . config as Record < string , unknown > ) || { } ;
@@ -318,9 +326,16 @@ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | u
const agent = agents . find ( ( a : Record < string , unknown > ) = > a . id === agentId ) ;
const name = ( agent ? . name as string ) || agentId ;
// Find Discord bot user ID from accoun t token (not available directly)
// We'll use accountId as the identifier
return ` 你是 ${ name } ( Discord 账号: ${ accountId } )。 ` ;
// Resolve Discord userId from bo t token
const discordUserId = resolveDiscordUserId ( api , accountId ) ;
let identity = ` You are ${ name } (Discord account: ${ accountId } ` ;
if ( discordUserId ) {
identity += ` , Discord userId: ${ discordUserId } ` ;
}
identity += ` ). ` ;
return identity ;
}
// --- Moderator bot helpers ---
@@ -349,7 +364,7 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string
}
/** Get the moderator bot's Discord user ID from its token */
function getModeratorUserId ( config : WhisperGate Config) : string | undefined {
function getModeratorUserId ( config : Dirigent Config) : string | undefined {
if ( ! config . moderatorBotToken ) return undefined ;
return userIdFromToken ( config . moderatorBotToken ) ;
}
@@ -367,13 +382,13 @@ async function sendModeratorMessage(token: string, channelId: string, content: s
} ) ;
if ( ! r . ok ) {
const text = await r . text ( ) ;
logger . warn ( ` whispergate : moderator send failed (${ r . status } ): ${ text } ` ) ;
logger . warn ( ` dirigent : moderator send failed (${ r . status } ): ${ text } ` ) ;
return false ;
}
logger . info ( ` whispergate : moderator message sent to channel=${ channelId } ` ) ;
logger . info ( ` dirigent : moderator message sent to channel=${ channelId } ` ) ;
return true ;
} catch ( err ) {
logger . warn ( ` whispergate : moderator send error: ${ String ( err ) } ` ) ;
logger . warn ( ` dirigent : moderator send error: ${ String ( err ) } ` ) ;
return false ;
}
}
@@ -386,7 +401,7 @@ function persistPolicies(api: OpenClawPluginApi): void {
fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } ) ;
fs . writeFileSync ( tmp , before , "utf8" ) ;
fs . renameSync ( tmp , filePath ) ;
api . logger . info ( ` whispergate : policy file persisted: ${ filePath } ` ) ;
api . logger . info ( ` dirigent : policy file persisted: ${ filePath } ` ) ;
}
function pickDefined ( input : Record < string , unknown > ) {
@@ -401,7 +416,7 @@ function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
if ( ! cfg . enableDebugLogs ) return false ;
const allow = Array . isArray ( cfg . debugLogChannelIds ) ? cfg . debugLogChannelIds : [ ] ;
if ( allow . length === 0 ) return true ;
if ( ! channelId ) return true ; // 允许打印,方便排查 channelId 为空的场景
if ( ! channelId ) return true ;
return allow . includes ( channelId ) ;
}
@@ -431,36 +446,37 @@ function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unk
}
export default {
id : "whispergate " ,
name : "WhisperGate " ,
id : "dirigent " ,
name : "Dirigent " ,
register ( api : OpenClawPluginApi ) {
// Merge pluginConfig with defaults (in case config is missing from openclaw.json)
const baseConfig = {
enableDiscordControlTool : true ,
enableWhispergate PolicyTool : true ,
enableDirigent PolicyTool : true ,
discordControlApiBaseUrl : "http://127.0.0.1:8790" ,
schedulingIdentifier : "➡️" ,
. . . ( api . pluginConfig || { } ) ,
} as WhisperGate Config & {
} as Dirigent Config & {
enableDiscordControlTool : boolean ;
discordControlApiBaseUrl : string ;
discordControlApiToken? : string ;
discordControlCallerId? : string ;
enableWhispergate PolicyTool : boolean ;
enableDirigent PolicyTool : boolean ;
} ;
const liveAtRegister = getLivePluginConfig ( api , baseConfig as WhisperGate Config) ;
const liveAtRegister = getLivePluginConfig ( api , baseConfig as Dirigent Config) ;
ensurePolicyStateLoaded ( api , liveAtRegister ) ;
// Start moderator bot presence (keep it "online" on Discord)
if ( liveAtRegister . moderatorBotToken ) {
startModeratorPresence ( liveAtRegister . moderatorBotToken , api . logger ) ;
api . logger . info ( "whispergate : moderator bot presence starting" ) ;
api . logger . info ( "dirigent : moderator bot presence starting" ) ;
}
api . registerTool (
{
name : "whispergate _tools" ,
description : "WhisperGate unified tool: Discord admin actions + in-memory policy management." ,
name : "dirigent _tools" ,
description : "Dirigent unified tool: Discord admin actions + in-memory policy management." ,
parameters : {
type : "object" ,
additionalProperties : false ,
@@ -498,12 +514,12 @@ export default {
required : [ "action" ] ,
} ,
async execute ( _id : string , params : Record < string , unknown > ) {
const live = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & {
const live = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & {
discordControlApiBaseUrl? : string ;
discordControlApiToken? : string ;
discordControlCallerId? : string ;
enableDiscordControlTool? : boolean ;
enableWhispergate PolicyTool? : boolean ;
enableDirigent PolicyTool? : boolean ;
} ;
ensurePolicyStateLoaded ( api , live ) ;
@@ -528,14 +544,14 @@ export default {
const text = await r . text ( ) ;
if ( ! r . ok ) {
return {
content : [ { type : "text" , text : ` whispergate _tools discord failed (${ r . status } ): ${ text } ` } ] ,
content : [ { type : "text" , text : ` dirigent _tools discord failed (${ r . status } ): ${ text } ` } ] ,
isError : true ,
} ;
}
return { content : [ { type : "text" , text } ] } ;
}
if ( live . enableWhispergate PolicyTool === false ) {
if ( live . enableDirigent PolicyTool === false ) {
return { content : [ { type : "text" , text : "policy actions disabled by config" } ] , isError : true } ;
}
@@ -613,9 +629,9 @@ export default {
// ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake.
// Extract the real Discord channel ID from conversationId or event.to.
const preChannelId = extractDiscordChannelId ( c , e ) ;
const livePre = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & DebugConfig ;
const livePre = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & DebugConfig ;
if ( shouldDebugLog ( livePre , preChannelId ) ) {
api . logger . info ( ` whispergate : debug message_received preflight ctx=${ JSON . stringify ( debugCtxSummary ( c , e ) ) } ` ) ;
api . logger . info ( ` dirigent : debug message_received preflight ctx=${ JSON . stringify ( debugCtxSummary ( c , e ) ) } ` ) ;
}
// Turn management on message received
@@ -631,7 +647,7 @@ export default {
const moderatorUserId = getModeratorUserId ( livePre ) ;
if ( moderatorUserId && from === moderatorUserId ) {
if ( shouldDebugLog ( livePre , preChannelId ) ) {
api . logger . info ( ` whispergate : ignoring moderator message in channel=${ preChannelId } ` ) ;
api . logger . info ( ` dirigent : ignoring moderator message in channel=${ preChannelId } ` ) ;
}
// Don't call onNewMessage — moderator messages are transparent to turn logic
} else {
@@ -645,18 +661,18 @@ export default {
if ( isNew ) {
// Re-initialize turn order with updated channel membership
ensureTurnOrder ( api , preChannelId ) ;
api . logger . info ( ` whispergate : new account ${ senderAccountId } seen in channel= ${ preChannelId } , turn order updated ` ) ;
api . logger . info ( ` dirigent : new account ${ senderAccountId } seen in channel= ${ preChannelId } , turn order updated ` ) ;
}
}
onNewMessage ( preChannelId , senderAccountId , isHuman ) ;
if ( shouldDebugLog ( livePre , preChannelId ) ) {
api . logger . info ( ` whispergate : turn onNewMessage channel=${ preChannelId } from= ${ from } isHuman= ${ isHuman } accountId= ${ senderAccountId ? ? "unknown" } ` ) ;
api . logger . info ( ` dirigent : turn onNewMessage channel=${ preChannelId } from= ${ from } isHuman= ${ isHuman } accountId= ${ senderAccountId ? ? "unknown" } ` ) ;
}
}
}
} catch ( err ) {
api . logger . warn ( ` whispergate : message hook failed: ${ String ( err ) } ` ) ;
api . logger . warn ( ` dirigent : message hook failed: ${ String ( err ) } ` ) ;
}
} ) ;
@@ -664,14 +680,14 @@ export default {
const key = ctx . sessionKey ;
if ( ! key ) return ;
const live = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & DebugConfig ;
const live = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & DebugConfig ;
ensurePolicyStateLoaded ( api , live ) ;
const prompt = ( ( event as Record < string , unknown > ) . prompt as string ) || "" ;
if ( live . enableDebugLogs ) {
api . logger . info (
` whispergate : DEBUG_BEFORE_MODEL_RESOLVE ctx=${ JSON . stringify ( { sessionKey : ctx.sessionKey , messageProvider : ctx.messageProvider , agentId : ctx.agentId } )} ` +
` dirigent : DEBUG_BEFORE_MODEL_RESOLVE ctx=${ JSON . stringify ( { sessionKey : ctx.sessionKey , messageProvider : ctx.messageProvider , agentId : ctx.agentId } )} ` +
` promptPreview= ${ prompt . slice ( 0 , 300 ) } ` ,
) ;
}
@@ -711,7 +727,7 @@ export default {
pruneDecisionMap ( ) ;
if ( shouldDebugLog ( live , derived . channelId ) ) {
api . logger . info (
` whispergate : debug before_model_resolve recompute session=${ key } ` +
` 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 ? ? "" ) } ` +
@@ -732,7 +748,7 @@ export default {
// Forced no-reply - record this session as not allowed to speak
sessionAllowed . set ( key , false ) ;
api . logger . info (
` whispergate : turn gate blocked session=${ key } accountId= ${ accountId } currentSpeaker= ${ turnCheck . currentSpeaker } reason= ${ turnCheck . reason } ` ,
` dirigent : turn gate blocked session=${ key } accountId= ${ accountId } currentSpeaker= ${ turnCheck . currentSpeaker } reason= ${ turnCheck . reason } ` ,
) ;
return {
providerOverride : live.noReplyProvider ,
@@ -745,7 +761,6 @@ export default {
}
if ( ! rec . decision . shouldUseNoReply ) {
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
if ( rec . needsRestore ) {
sessionDecision . delete ( key ) ;
return {
@@ -756,16 +771,14 @@ export default {
return ;
}
// 标记这次执行了 no-reply, 下次需要恢复模型
rec . needsRestore = true ;
sessionDecision . set ( key , rec ) ;
// 无论是否有缓存,只要 debug flag 开启就打印决策详情
if ( live . enableDebugLogs ) {
const prompt = ( ( event as Record < string , unknown > ) . prompt as string ) || "" ;
const hasConvMarker = prompt . includes ( "Conversation info (untrusted metadata):" ) ;
api . logger . info (
` whispergate : DEBUG_NO_REPLY_TRIGGER session=${ key } ` +
` dirigent : DEBUG_NO_REPLY_TRIGGER 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 ? ? "" ) } ` +
@@ -776,7 +789,7 @@ export default {
}
api . logger . info (
` whispergate : override model for session=${ key } , provider= ${ live . noReplyProvider } , model= ${ live . noReplyModel } , reason= ${ rec . decision . reason } ` ,
` dirigent : override model for session=${ key } , provider= ${ live . noReplyProvider } , model= ${ live . noReplyModel } , reason= ${ rec . decision . reason } ` ,
) ;
return {
@@ -789,7 +802,7 @@ export default {
const key = ctx . sessionKey ;
if ( ! key ) return ;
const live = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & DebugConfig ;
const live = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & DebugConfig ;
ensurePolicyStateLoaded ( api , live ) ;
let rec = sessionDecision . get ( key ) ;
@@ -810,7 +823,7 @@ export default {
rec = { decision , createdAt : Date.now ( ) } ;
if ( shouldDebugLog ( live , derived . channelId ) ) {
api . logger . info (
` whispergate : debug before_prompt_build recompute session=${ key } ` +
` 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 ? ? "" ) } ` +
@@ -826,7 +839,7 @@ export default {
if ( sessionInjected . has ( key ) ) {
if ( shouldDebugLog ( live , undefined ) ) {
api . logger . info (
` whispergate : debug before_prompt_build session=${ key } inject skipped (already injected) ` ,
` dirigent : debug before_prompt_build session=${ key } inject skipped (already injected) ` ,
) ;
}
return ;
@@ -835,7 +848,7 @@ export default {
if ( ! rec . decision . shouldInjectEndMarkerPrompt ) {
if ( shouldDebugLog ( live , undefined ) ) {
api . logger . info (
` whispergate : debug before_prompt_build session=${ key } inject=false reason= ${ rec . decision . reason } ` ,
` dirigent : debug before_prompt_build session=${ key } inject=false reason= ${ rec . decision . reason } ` ,
) ;
}
return ;
@@ -846,26 +859,33 @@ export default {
const derived = deriveDecisionInputFromPrompt ( prompt , ctx . messageProvider , ctx . channelId ) ;
const policy = resolvePolicy ( live , derived . channelId , policyState . channelPolicies ) ;
const isGroupChat = derived . conv . is_group_chat === true || derived . conv . is_group_chat === "true" ;
const instruction = buildEndMarkerInstruction ( policy . endSymbols , isGroupChat ) ;
const schedulingId = live . schedulingIdentifier || "➡️" ;
const instruction = buildEndMarkerInstruction ( policy . endSymbols , isGroupChat , schedulingId ) ;
// Inject agent identity for group chats
// Inject agent identity for group chats (includes userId now)
let identity = "" ;
if ( isGroupChat && ctx . agentId ) {
const idStr = buildAgentIdentity ( api , ctx . agentId ) ;
if ( idStr ) identity = idStr + "\n\n" ;
}
// Add scheduling identifier instruction for group chats
let schedulingInstruction = "" ;
if ( isGroupChat ) {
schedulingInstruction = buildSchedulingIdentifierInstruction ( schedulingId ) ;
}
// Mark session as injected (one-time injection)
sessionInjected . add ( key ) ;
api . logger . info ( ` whispergate : prepend end marker instruction for session=${ key } , reason= ${ rec . decision . reason } isGroupChat= ${ isGroupChat } ` ) ;
return { prependContext : identity + instruction } ;
api . logger . info ( ` dirigent : prepend end marker instruction for session=${ key } , reason= ${ rec . decision . reason } isGroupChat= ${ isGroupChat } ` ) ;
return { prependContext : identity + instruction + schedulingInstruction } ;
} ) ;
// Register slash commands for Discord
api . registerCommand ( {
name : "whispergate " ,
description : "WhisperGate 频道策略管理 " ,
name : "dirigent " ,
description : "Dirigent channel policy management " ,
acceptsArgs : true ,
handler : async ( cmdCtx ) = > {
const args = cmdCtx . args || "" ;
@@ -873,11 +893,11 @@ export default {
const subCmd = parts [ 0 ] || "help" ;
if ( subCmd === "help" ) {
return { text : ` WhisperGate 命令 :\ n ` +
` /whispergate status - 显示当前频道状态 \ n ` +
` /whispergate turn-status - 显示轮流发言状态 \ n ` +
` /whispergate turn-advance - 手动推进轮流 \ n ` +
` /whispergate turn-reset - 重置轮流顺序 ` } ;
return { text : ` Dirigent commands :\ n ` +
` /dirigent status - Show current channel status \ n ` +
` /dirigent turn-status - Show turn-based speaking status \ n ` +
` /dirigent turn-advance - Manually advance turn \ n ` +
` /dirigent turn-reset - Reset turn order ` } ;
}
if ( subCmd === "status" ) {
@@ -886,65 +906,52 @@ export default {
if ( subCmd === "turn-status" ) {
const channelId = cmdCtx . channelId ;
if ( ! channelId ) return { text : "无法获取频道 ID" , isError : true } ;
if ( ! channelId ) return { text : "Cannot get channel ID" , isError : true } ;
return { text : JSON.stringify ( getTurnDebugInfo ( channelId ) , null , 2 ) } ;
}
if ( subCmd === "turn-advance" ) {
const channelId = cmdCtx . channelId ;
if ( ! channelId ) return { text : "无法获取频道 ID" , isError : true } ;
if ( ! channelId ) return { text : "Cannot get channel ID" , isError : true } ;
const next = advanceTurn ( channelId ) ;
return { text : JSON.stringify ( { ok : true , nextSpeaker : next } ) } ;
}
if ( subCmd === "turn-reset" ) {
const channelId = cmdCtx . channelId ;
if ( ! channelId ) return { text : "无法获取频道 ID" , isError : true } ;
if ( ! channelId ) return { text : "Cannot get channel ID" , isError : true } ;
resetTurn ( channelId ) ;
return { text : JSON.stringify ( { ok : true } ) } ;
}
return { text : ` 未知子命令 : ${ subCmd } ` , isError : true } ;
return { text : ` Unknown subcommand : ${ subCmd } ` , isError : true } ;
} ,
} ) ;
// Handle NO_REPLY detection before message write
// This is where we detect if agent output is NO_REPLY and handle turn advancement
// NOTE: This hook is synchronous, do not use async/await
api . on ( "before_message_write" , ( event , ctx ) = > {
try {
// Debug: print all available keys in event and ctx
api . logger . info (
` whispergate : DEBUG before_message_write eventKeys=${ JSON . stringify ( Object . keys ( event ? ? { } ))} ctxKeys= ${ JSON . stringify ( Object . keys ( ctx ? ? { } ))} ` ,
` dirigent : DEBUG before_message_write eventKeys=${ JSON . stringify ( Object . keys ( event ? ? { } ))} ctxKeys= ${ JSON . stringify ( Object . keys ( ctx ? ? { } ))} ` ,
) ;
// before_message_write ctx only has { agentId, sessionKey }.
// Use session mappings populated during before_model_resolve for channelId/accountId.
// Content comes from event.message (AgentMessage).
let key = ctx . sessionKey ;
let channelId : string | undefined ;
let accountId : string | undefined ;
// Get from session mapping (set in before_model_resolve)
if ( key ) {
channelId = sessionChannelId . get ( key ) ;
accountId = sessionAccountId . get ( key ) ;
}
// Extract content from event.message (AgentMessage)
// Only process assistant messages — before_message_write fires for both
// user (incoming) and assistant (outgoing) messages. Incoming messages may
// contain end symbols from OTHER agents, which would incorrectly advance the turn.
let content = "" ;
const msg = ( event as Record < string , unknown > ) . message as Record < string , unknown > | undefined ;
if ( msg ) {
const role = msg . role as string | undefined ;
if ( role && role !== "assistant" ) return ;
// AgentMessage may have content as string or nested
if ( typeof msg . content === "string" ) {
content = msg . content ;
} else if ( Array . isArray ( msg . content ) ) {
// content might be an array of parts (Anthropic format)
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" ) {
@@ -953,30 +960,25 @@ export default {
}
}
}
// Fallback to event.content
if ( ! content ) {
content = ( ( event as Record < string , unknown > ) . content as string ) || "" ;
}
// Always log for debugging - show all available info
api . logger . info (
` whispergate : DEBUG before_message_write session=${ key ? ? "undefined" } channel= ${ channelId ? ? "undefined" } accountId= ${ accountId ? ? "undefined" } contentType= ${ typeof content } content= ${ String ( content ) . slice ( 0 , 200 ) } ` ,
` 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 ;
// Only the current speaker should advance the turn.
// Other agents also trigger before_message_write (for incoming messages or forced no-reply),
// but they must not affect turn state.
const currentTurn = getTurnDebugInfo ( channelId ) ;
if ( currentTurn . currentSpeaker !== accountId ) {
api . logger . info (
` whispergate : before_message_write skipping non-current-speaker session=${ key } accountId= ${ accountId } currentSpeaker= ${ currentTurn . currentSpeaker } ` ,
` dirigent : before_message_write skipping non-current-speaker session=${ key } accountId= ${ accountId } currentSpeaker= ${ currentTurn . currentSpeaker } ` ,
) ;
return ;
}
const live = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & DebugConfig ;
const live = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & DebugConfig ;
ensurePolicyStateLoaded ( api , live ) ;
const policy = resolvePolicy ( live , channelId , policyState . channelPolicies ) ;
@@ -987,83 +989,76 @@ export default {
const hasEndSymbol = ! ! lastChar && policy . endSymbols . includes ( lastChar ) ;
const wasNoReply = isEmpty || isNoReply ;
// Log turn state for debugging
const turnDebug = getTurnDebugInfo ( channelId ) ;
api . logger . info (
` whispergate : DEBUG turn state channel=${ channelId } turnOrder= ${ JSON . stringify ( turnDebug . turnOrder ) } currentSpeaker= ${ turnDebug . currentSpeaker } noRepliedThisCycle= ${ JSON . stringify ( [ . . . turnDebug . noRepliedThisCycle ] ) } ` ,
` dirigent : DEBUG turn state channel=${ channelId } turnOrder= ${ JSON . stringify ( turnDebug . turnOrder ) } currentSpeaker= ${ turnDebug . currentSpeaker } noRepliedThisCycle= ${ JSON . stringify ( [ . . . turnDebug . noRepliedThisCycle ] ) } ` ,
) ;
// Check if this session was forced no-reply or allowed to speak
const wasAllowed = sessionAllowed . get ( key ) ;
if ( wasNoReply ) {
api . logger . info (
` whispergate : DEBUG NO_REPLY detected session=${ key } wasAllowed= ${ wasAllowed } ` ,
` dirigent : DEBUG NO_REPLY detected session=${ key } wasAllowed= ${ wasAllowed } ` ,
) ;
if ( wasAllowed === undefined ) return ; // No record, skip
if ( wasAllowed === undefined ) return ;
if ( wasAllowed === false ) {
// Forced no-reply - do not advance turn
sessionAllowed . delete ( key ) ;
api . logger . info (
` whispergate : before_message_write forced no-reply session=${ key } channel= ${ channelId } - not advancing turn ` ,
` dirigent : before_message_write forced no-reply session=${ key } channel= ${ channelId } - not advancing turn ` ,
) ;
return ;
}
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn
ensureTurnOrder ( api , channelId , live ) ;
ensureTurnOrder ( api , channelId ) ;
const nextSpeaker = onSpeakerDone ( channelId , accountId , true ) ;
sessionAllowed . delete ( key ) ;
sessionTurnHandled . add ( key ) ;
api . logger . info (
` whispergate : before_message_write real no-reply session=${ key } channel= ${ channelId } nextSpeaker= ${ nextSpeaker ? ? "dormant" } ` ,
` dirigent : before_message_write real no-reply session=${ key } channel= ${ channelId } nextSpeaker= ${ nextSpeaker ? ? "dormant" } ` ,
) ;
// If all agents NO_REPLY'd (dormant), don't trigger handoff
if ( ! nextSpeaker ) {
if ( shouldDebugLog ( live , channelId ) ) {
api . logger . info (
` whispergate : before_message_write all agents no-reply, going dormant - no handoff` ,
` dirigent : before_message_write all agents no-reply, going dormant - no handoff` ,
) ;
}
return ;
}
// Trigger moderator handoff message (fire-and-forget, don't await)
// Trigger moderator handoff message using scheduling identifier format
if ( live . moderatorBotToken ) {
const nextUserId = resolveDiscordUserId ( api , nextSpeaker ) ;
if ( nextUserId ) {
const handoffMsg = ` 轮到(<@ ${ nextUserId } >) 了, 如果没有想说的请直接回复NO_REPLY ` ;
const schedulingId = live . schedulingIdentifier || "➡️" ;
const handoffMsg = ` <@ ${ nextUserId } > ${ schedulingId } ` ;
void sendModeratorMessage ( live . moderatorBotToken , channelId , handoffMsg , api . logger ) . catch ( ( err ) = > {
api . logger . warn ( ` whispergate : before_message_write handoff failed: ${ String ( err ) } ` ) ;
api . logger . warn ( ` dirigent : before_message_write handoff failed: ${ String ( err ) } ` ) ;
} ) ;
} else {
api . logger . warn ( ` whispergate : cannot resolve Discord userId for next speaker accountId=${ nextSpeaker } ` ) ;
api . logger . warn ( ` dirigent : cannot resolve Discord userId for next speaker accountId=${ nextSpeaker } ` ) ;
}
}
} else if ( hasEndSymbol ) {
// End symbol detected — advance turn NOW (before message is broadcast to other agents)
// This prevents the race condition where other agents receive the message
// before message_sent fires and advances the turn.
ensureTurnOrder ( api , channelId , live ) ;
ensureTurnOrder ( api , channelId ) ;
const nextSpeaker = onSpeakerDone ( channelId , accountId , false ) ;
sessionAllowed . delete ( key ) ;
sessionTurnHandled . add ( key ) ;
api . logger . info (
` whispergate : before_message_write end-symbol turn advance session=${ key } channel= ${ channelId } nextSpeaker= ${ nextSpeaker ? ? "dormant" } ` ,
` dirigent : before_message_write end-symbol turn advance session=${ key } channel= ${ channelId } nextSpeaker= ${ nextSpeaker ? ? "dormant" } ` ,
) ;
} else {
api . logger . info (
` whispergate : before_message_write no turn action needed session=${ key } channel= ${ channelId } ` ,
` dirigent : before_message_write no turn action needed session=${ key } channel= ${ channelId } ` ,
) ;
return ;
}
} catch ( err ) {
api . logger . warn ( ` whispergate : before_message_write hook failed: ${ String ( err ) } ` ) ;
api . logger . warn ( ` dirigent : before_message_write hook failed: ${ String ( err ) } ` ) ;
}
} ) ;
@@ -1074,22 +1069,17 @@ export default {
const c = ( ctx || { } ) as Record < string , unknown > ;
const e = ( event || { } ) as Record < string , unknown > ;
// Always log raw context first for debugging
api . logger . info (
` whispergate : DEBUG message_sent RAW ctxKeys=${ JSON . stringify ( Object . keys ( c ) ) } eventKeys= ${ JSON . stringify ( Object . keys ( e ) ) } ` +
` 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" } ` ,
) ;
// ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake.
// Extract real Discord channel ID from conversationId or event.to.
let channelId = extractDiscordChannelId ( c , e ) ;
// Fallback: sessionKey mapping
if ( ! channelId && key ) {
channelId = sessionChannelId . get ( key ) ;
}
// Fallback: parse from sessionKey
if ( ! channelId && key ) {
const skMatch = key . match ( /:channel:(\d+)$/ ) ;
if ( skMatch ) channelId = skMatch [ 1 ] ;
@@ -1097,14 +1087,13 @@ export default {
const accountId = ( ctx . accountId as string | undefined ) || ( key ? sessionAccountId . get ( key ) : undefined ) ;
const content = ( event . content as string ) || "" ;
// Debug log
api . logger . info (
` whispergate : DEBUG message_sent RESOLVED session=${ key ? ? "undefined" } channelId= ${ channelId ? ? "undefined" } accountId= ${ accountId ? ? "undefined" } content= ${ content . slice ( 0 , 100 ) } ` ,
` 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 = getLivePluginConfig ( api , baseConfig as WhisperGate Config) as WhisperGate Config & DebugConfig ;
const live = getLivePluginConfig ( api , baseConfig as Dirigent Config) as Dirigent Config & DebugConfig ;
ensurePolicyStateLoaded ( api , live ) ;
const policy = resolvePolicy ( live , channelId , policyState . channelPolicies ) ;
@@ -1119,7 +1108,7 @@ export default {
if ( key && sessionTurnHandled . has ( key ) ) {
sessionTurnHandled . delete ( key ) ;
api . logger . info (
` whispergate : message_sent skipping turn advance (already handled in before_message_write) session=${ key } channel= ${ channelId } ` ,
` dirigent : message_sent skipping turn advance (already handled in before_message_write) session=${ key } channel= ${ channelId } ` ,
) ;
return ;
}
@@ -1128,22 +1117,22 @@ export default {
const nextSpeaker = onSpeakerDone ( channelId , accountId , wasNoReply ) ;
const trigger = wasNoReply ? ( isEmpty ? "empty" : "no_reply_keyword" ) : "end_symbol" ;
api . logger . info (
` whispergate : turn onSpeakerDone channel=${ channelId } from= ${ accountId } next= ${ nextSpeaker ? ? "dormant" } trigger= ${ trigger } ` ,
` dirigent : turn onSpeakerDone channel=${ channelId } from= ${ accountId } next= ${ nextSpeaker ? ? "dormant" } trigger= ${ trigger } ` ,
) ;
// Moderator handoff: when current speaker NO_REPLY'd and there's a next speaker,
// send a handoff message via the moderator bot to trigger the next agent
// Moderator handoff using scheduling identifier format
if ( wasNoReply && nextSpeaker && live . moderatorBotToken ) {
const nextUserId = resolveDiscordUserId ( api , nextSpeaker ) ;
if ( nextUserId ) {
const handoffMsg = ` 轮到(<@ ${ nextUserId } >) 了, 如果没有想说的请直接回复NO_REPLY ` ;
const schedulingId = live . schedulingIdentifier || "➡️" ;
const handoffMsg = ` <@ ${ nextUserId } > ${ schedulingId } ` ;
sendModeratorMessage ( live . moderatorBotToken , channelId , handoffMsg , api . logger ) ;
} else {
api . logger . warn ( ` whispergate : cannot resolve Discord userId for next speaker accountId=${ nextSpeaker } ` ) ;
api . logger . warn ( ` dirigent : cannot resolve Discord userId for next speaker accountId=${ nextSpeaker } ` ) ;
}
}
}
} catch ( err ) {
api . logger . warn ( ` whispergate : message_sent hook failed: ${ String ( err ) } ` ) ;
api . logger . warn ( ` dirigent : message_sent hook failed: ${ String ( err ) } ` ) ;
}
} ) ;
} ,