feat: complete Dirigent rename + all TASKLIST items
- Task 1: Identity prompt now includes Discord userId
- Task 2: Added configurable schedulingIdentifier (default: ➡️)
- Task 3: Moderator handoff uses <@userId>+identifier instead of semantic messages
- Task 4: All prompts/comments/help text converted to English
- Task 5: Full project rename WhisperGate → Dirigent across all files
Breaking: config key changed from plugins.entries.whispergate to plugins.entries.dirigent
Breaking: channel policies file renamed to dirigent-channel-policies.json
Breaking: tool name changed from whispergate_tools to dirigent_tools
This commit is contained in:
247
plugin/index.ts
247
plugin/index.ts
@@ -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 WhisperGateConfig } from "./rules.js";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } 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: WhisperGateConfig): WhisperGateConfig {
|
||||
function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
|
||||
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,
|
||||
enableWhispergatePolicyTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
||||
enableDebugLogs: false,
|
||||
debugLogChannelIds: [],
|
||||
schedulingIdentifier: "➡️",
|
||||
...cfg,
|
||||
} as WhisperGateConfig;
|
||||
} as DirigentConfig;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json");
|
||||
function resolvePoliciesPath(api: OpenClawPluginApi, config: DirigentConfig): string {
|
||||
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/dirigent-channel-policies.json");
|
||||
}
|
||||
|
||||
function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) {
|
||||
function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: DirigentConfig) {
|
||||
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 account token (not available directly)
|
||||
// We'll use accountId as the identifier
|
||||
return `你是 ${name}(Discord 账号: ${accountId})。`;
|
||||
// Resolve Discord userId from bot 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: WhisperGateConfig): string | undefined {
|
||||
function getModeratorUserId(config: DirigentConfig): 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,
|
||||
enableWhispergatePolicyTool: true,
|
||||
enableDirigentPolicyTool: true,
|
||||
discordControlApiBaseUrl: "http://127.0.0.1:8790",
|
||||
schedulingIdentifier: "➡️",
|
||||
...(api.pluginConfig || {}),
|
||||
} as WhisperGateConfig & {
|
||||
} as DirigentConfig & {
|
||||
enableDiscordControlTool: boolean;
|
||||
discordControlApiBaseUrl: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableWhispergatePolicyTool: boolean;
|
||||
enableDirigentPolicyTool: boolean;
|
||||
};
|
||||
|
||||
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
||||
const liveAtRegister = getLivePluginConfig(api, baseConfig as DirigentConfig);
|
||||
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 WhisperGateConfig) as WhisperGateConfig & {
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
|
||||
discordControlApiBaseUrl?: string;
|
||||
discordControlApiToken?: string;
|
||||
discordControlCallerId?: string;
|
||||
enableDiscordControlTool?: boolean;
|
||||
enableWhispergatePolicyTool?: boolean;
|
||||
enableDirigentPolicyTool?: 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.enableWhispergatePolicyTool === false) {
|
||||
if (live.enableDirigentPolicyTool === 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 WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & 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 WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & 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 WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & 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 WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & 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 WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & 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)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user