diff --git a/CHANGELOG.md b/CHANGELOG.md index 6150ee7..c828e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,29 @@ # Changelog +## 0.2.0 + +- **Project renamed from WhisperGate to Dirigent** + - All plugin ids, tool names, config keys, file paths, docs updated + - Legacy `whispergate` config key still supported as fallback +- **Identity prompt enhancements**: Discord userId now included in agent identity injection +- **Scheduling identifier**: Added configurable `schedulingIdentifier` (default: `➡️`) + - Moderator handoff now sends `<@USER_ID>➡️` instead of semantic messages + - Agent prompt explains the identifier is meaningless — check chat history and decide +- **All prompts in English**: End-marker instructions, group chat rules, slash command help text + ## 0.1.0-mvp - Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) - Added optional bearer auth (`AUTH_TOKEN`) -- Added WhisperGate plugin with deterministic rule gate +- Added plugin with deterministic rule gate - Added discord-specific 🔚 prompt injection for bypass/end-symbol paths - Added containerization (`Dockerfile`, `docker-compose.yml`) - Added helper scripts for smoke/dev lifecycle and rule validation - Added no-touch config rendering and integration docs -- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`) +- Added installer script with rollback (`scripts/install-dirigent-openclaw.sh`) - supports `--install` / `--uninstall` - uninstall restores all recorded changes - - writes install/uninstall records under `~/.openclaw/whispergate-install-records/` + - writes install/uninstall records under `~/.openclaw/dirigent-install-records/` - Added discord-control-api with: - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) diff --git a/README.md b/README.md index 8a2bfd7..0a614f6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# WhisperGate +# Dirigent Rule-based no-reply gate + turn manager for OpenClaw (Discord). +> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0. + ## What it does -WhisperGate adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels: +Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels: - **Rule gate (before_model_resolve)** 1. Non-Discord → skip @@ -13,8 +15,13 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based 4. Otherwise → route to no-reply model/provider - **End-symbol enforcement** - - Injects instruction like: `你的这次发言必须以🔚作为结尾…` - - In group chats, also injects: “无关/不需要回应就 NO_REPLY” + - Injects instruction: `Your response MUST end with 🔚…` + - In group chats, also injects: "If not relevant, reply NO_REPLY" + +- **Scheduling identifier (moderator handoff)** + - Configurable identifier (default: `➡️`) used by the moderator bot + - Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic, just a scheduling signal) + - Agent receives instruction explaining the identifier is meaningless — check chat history and decide - **Turn-based speaking (multi-bot)** - Only the current speaker is allowed to respond @@ -22,16 +29,16 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based - Turn advances on **end-symbol** or **NO_REPLY** - If all bots NO_REPLY, channel becomes **dormant** until a new human message -- **Moderator handoff (optional)** - - When the current speaker NO_REPLYs, a moderator bot can post a handoff message to wake the next speaker +- **Agent identity injection** + - Injects agent name, Discord accountId, and Discord userId into group chat prompts - **Per-channel policy runtime** - Policies stored in a standalone JSON file - - Update at runtime via `whispergate_tools` (memory first → persist to file) + - Update at runtime via `dirigent_tools` (memory first → persist to file) - **Discord control actions (optional)** - Private channel create/update + member list - - Unified via `whispergate_tools` + - Unified via `dirigent_tools` --- @@ -39,7 +46,7 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based - `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence) - `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY` -- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表) +- `discord-control-api/` — Discord admin extension API (private channels + member list) - `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis - `scripts/` — smoke/dev/helper checks - `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`) @@ -61,13 +68,13 @@ node scripts/render-openclaw-config.mjs ``` See `docs/RUN_MODES.md` for Docker mode. -Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。 +Discord extension capabilities: `docs/DISCORD_CONTROL.md`. --- ## Runtime tools & commands -### Tool: `whispergate_tools` +### Tool: `dirigent_tools` Actions: - `policy-get`, `policy-set-channel`, `policy-delete-channel` @@ -77,10 +84,10 @@ Actions: ### Slash command (Discord) ``` -/whispergate status -/whispergate turn-status -/whispergate turn-advance -/whispergate turn-reset +/dirigent status +/dirigent turn-status +/dirigent turn-advance +/dirigent turn-reset ``` --- @@ -92,6 +99,7 @@ Common options (see `docs/INTEGRATION.md`): - `listMode`: `human-list` or `agent-list` - `humanList`, `agentList` - `endSymbols` +- `schedulingIdentifier` (default `➡️`) - `channelPoliciesFile` (per-channel overrides) - `moderatorBotToken` (handoff messages) - `enableDebugLogs`, `debugLogChannelIds` diff --git a/TASKLIST.md b/TASKLIST.md index d5342bb..ed5e557 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -2,31 +2,40 @@ > Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs). -## 1) Identity Prompt Enhancements +## 1) Identity Prompt Enhancements ✅ - Current prompt only includes agent-id + discord name. - **Add Discord userId** to identity injection. +- **Done**: `buildAgentIdentity()` now resolves and includes Discord userId via `resolveDiscordUserId()`. -## 2) Scheduling Identifier (Default: ➡️) +## 2) Scheduling Identifier (Default: ➡️) ✅ - Add a **configurable scheduling identifier** (default: `➡️`). - Update agent prompt to explain: - The scheduling identifier itself is meaningless. - When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply. - If no reply needed, return `NO_REPLY`. +- **Done**: Added `schedulingIdentifier` config field; `buildSchedulingIdentifierInstruction()` injected for group chats. -## 3) Moderator Handoff Message Format +## 3) Moderator Handoff Message Format ✅ - Moderator should **no longer send semantic messages** to activate agents. - Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`). +- **Done**: Both `before_message_write` and `message_sent` handoff messages now use `<@userId>` + scheduling identifier format. -## 4) Prompt Language +## 4) Prompt Language ✅ - **All prompts must be in English** (including end-marker instructions and group-chat rules). +- **Done**: `buildEndMarkerInstruction()` and `buildSchedulingIdentifierInstruction()` output English. Slash command help text in English. -## 5) Full Project Rename +## 5) Full Project Rename ✅ - Project name changed to **Dirigent**. - Update **all strings** across repo: - - plugin name/id - - tool name(s) + - plugin name/id → `dirigent` + - tool name → `dirigent_tools` + - slash command → `/dirigent` - docs, config, scripts, examples - any text mentions + - dist output dir → `dist/dirigent` + - docker service → `dirigent-no-reply-api` + - config key fallback: still reads legacy `whispergate` entry if `dirigent` not found +- **Done**: All files updated. --- diff --git a/discord-control-api/package.json b/discord-control-api/package.json index adb396c..ef1f8c6 100644 --- a/discord-control-api/package.json +++ b/discord-control-api/package.json @@ -1,5 +1,5 @@ { - "name": "whispergate-discord-control-api", + "name": "dirigent-discord-control-api", "version": "0.1.0", "private": true, "type": "module", diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts deleted file mode 100644 index d838633..0000000 --- a/dist/whispergate/index.ts +++ /dev/null @@ -1,1150 +0,0 @@ -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 { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; -import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; - -type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; - -type DecisionRecord = { - decision: Decision; - createdAt: number; - needsRestore?: boolean; -}; - -type PolicyState = { - filePath: string; - channelPolicies: Record; -}; - -type DebugConfig = { - enableDebugLogs?: boolean; - debugLogChannelIds?: string[]; -}; - -const sessionDecision = new Map(); -const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) -const sessionInjected = new Set(); // Track which sessions have already injected the end marker -const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping -const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping -const sessionTurnHandled = new Set(); // 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 { - const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; - let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; - if (isGroupChat) { - instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`; - } - return instruction; -} - -const policyState: PolicyState = { - filePath: "", - channelPolicies: {}, -}; - -function normalizeChannel(ctx: Record): string { - const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); - } - return ""; -} - -/** - * Extract the actual Discord channel ID from a conversationId or "to" field. - * OpenClaw uses format "channel:" for Discord conversations. - * Also tries event.to and event.metadata.to as fallbacks. - */ -function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { - const candidates: unknown[] = [ - ctx.conversationId, - event?.to, - (event?.metadata as Record)?.to, - ]; - for (const c of candidates) { - if (typeof c === "string" && c.trim()) { - const s = c.trim(); - // Handle "channel:123456" format - if (s.startsWith("channel:")) { - const id = s.slice("channel:".length); - if (/^\d+$/.test(id)) return id; - } - // Handle "discord:channel:123456" format - if (s.startsWith("discord:channel:")) { - const id = s.slice("discord:channel:".length); - if (/^\d+$/.test(id)) return id; - } - // If it's a raw snowflake (all digits), use directly - if (/^\d{15,}$/.test(s)) return s; - } - } - return undefined; -} - -function normalizeSender(event: Record, ctx: Record): string | undefined { - const direct = [ctx.senderId, ctx.from, event.from]; - for (const v of direct) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - const meta = (event.metadata || ctx.metadata) as Record | undefined; - if (!meta) return undefined; - const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; - for (const v of metaCandidates) { - if (typeof v === "string" && v.trim()) return v.trim(); - } - - return undefined; -} - -function extractUntrustedConversationInfo(text: string): Record | undefined { - const marker = "Conversation info (untrusted metadata):"; - const idx = text.indexOf(marker); - if (idx < 0) return undefined; - const tail = text.slice(idx + marker.length); - const m = tail.match(/```json\s*([\s\S]*?)\s*```/i); - if (!m) return undefined; - try { - const parsed = JSON.parse(m[1]); - return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; - } catch { - return undefined; - } -} - -function deriveDecisionInputFromPrompt( - prompt: string, - messageProvider?: string, - channelIdFromCtx?: string, -): { - channel: string; - channelId?: string; - senderId?: string; - content: string; - conv: Record; -} { - const conv = extractUntrustedConversationInfo(prompt) || {}; - const channel = (messageProvider || "").toLowerCase(); - - // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id - let channelId = channelIdFromCtx; - if (!channelId) { - // Try chat_id field (format "channel:123456") - if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) { - channelId = conv.chat_id.slice("channel:".length); - } - // Try conversation_label (format "Guild #name channel id:123456") - if (!channelId && typeof conv.conversation_label === "string") { - const labelMatch = conv.conversation_label.match(/channel id:(\d+)/); - if (labelMatch) channelId = labelMatch[1]; - } - // Try channel_id field directly - if (!channelId && typeof conv.channel_id === "string" && conv.channel_id) { - channelId = conv.channel_id; - } - } - - const senderId = - (typeof conv.sender_id === "string" && conv.sender_id) || - (typeof conv.sender === "string" && conv.sender) || - undefined; - - return { channel, channelId, senderId, content: prompt, conv }; -} - -function pruneDecisionMap(now = Date.now()) { - for (const [k, v] of sessionDecision.entries()) { - if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); - } - - if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; - const keys = sessionDecision.keys(); - while (sessionDecision.size > MAX_SESSION_DECISIONS) { - const k = keys.next(); - if (k.done) break; - sessionDecision.delete(k.value); - } -} - - -function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { - const root = (api.config as Record) || {}; - const plugins = (root.plugins as Record) || {}; - const entries = (plugins.entries as Record) || {}; - const entry = (entries.whispergate as Record) || {}; - const cfg = (entry.config as Record) || {}; - if (Object.keys(cfg).length > 0) { - // Merge with defaults to ensure optional fields have values - return { - enableDiscordControlTool: true, - enableWhispergatePolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", - enableDebugLogs: false, - debugLogChannelIds: [], - ...cfg, - } as WhisperGateConfig; - } - return fallback; -} - -function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string { - return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json"); -} - -function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) { - if (policyState.filePath) return; - const filePath = resolvePoliciesPath(api, config); - policyState.filePath = filePath; - - try { - if (!fs.existsSync(filePath)) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, "{}\n", "utf8"); - policyState.channelPolicies = {}; - return; - } - - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw) as Record; - policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; - } catch (err) { - api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); - policyState.channelPolicies = {}; - } -} - -/** Resolve agentId → Discord accountId from config bindings */ -function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return undefined; - for (const b of bindings) { - if (b.agentId === agentId) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - return match.accountId; - } - } - } - return undefined; -} - -/** - * Get all Discord bot accountIds from config bindings. - */ -function getAllBotAccountIds(api: OpenClawPluginApi): string[] { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - if (!Array.isArray(bindings)) return []; - const ids: string[] = []; - for (const b of bindings) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - ids.push(match.accountId); - } - } - return ids; -} - -/** - * Track which bot accountIds have been seen in each channel via message_received. - * Key: channelId, Value: Set of accountIds seen. - */ -const channelSeenAccounts = new Map>(); - -/** - * Record a bot accountId seen in a channel. - * Returns true if this is a new account for this channel (turn order should be updated). - */ -function recordChannelAccount(channelId: string, accountId: string): boolean { - let seen = channelSeenAccounts.get(channelId); - if (!seen) { - seen = new Set(); - channelSeenAccounts.set(channelId, seen); - } - if (seen.has(accountId)) return false; - seen.add(accountId); - return true; -} - -/** - * Get the list of bot accountIds seen in a channel. - * Only returns accounts that are also in the global bindings (actual bots). - */ -function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] { - const allBots = new Set(getAllBotAccountIds(api)); - const seen = channelSeenAccounts.get(channelId); - if (!seen) return []; - return [...seen].filter(id => allBots.has(id)); -} - -/** - * Ensure turn order is initialized for a channel. - * Uses only bot accounts that have been seen in this channel. - */ -function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { - const botAccounts = getChannelBotAccountIds(api, channelId); - if (botAccounts.length > 0) { - initTurnOrder(channelId, botAccounts); - } -} - -/** - * Build agent identity string for injection into group chat prompts. - */ -function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined { - const root = (api.config as Record) || {}; - const bindings = root.bindings as Array> | undefined; - const agents = ((root.agents as Record)?.list as Array>) || []; - if (!Array.isArray(bindings)) return undefined; - - // Find accountId for this agent - let accountId: string | undefined; - for (const b of bindings) { - if (b.agentId === agentId) { - const match = b.match as Record | undefined; - if (match?.channel === "discord" && typeof match.accountId === "string") { - accountId = match.accountId; - break; - } - } - } - if (!accountId) return undefined; - - // Find agent name - const agent = agents.find((a: Record) => 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})。`; -} - -// --- Moderator bot helpers --- - -/** Extract Discord user ID from a bot token (base64-encoded in first segment) */ -function userIdFromToken(token: string): string | undefined { - try { - const segment = token.split(".")[0]; - // Add padding - const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); - return Buffer.from(padded, "base64").toString("utf8"); - } catch { - return undefined; - } -} - -/** Resolve accountId → Discord user ID by reading the account's bot token from config */ -function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined { - const root = (api.config as Record) || {}; - const channels = (root.channels as Record) || {}; - const discord = (channels.discord as Record) || {}; - const accounts = (discord.accounts as Record>) || {}; - const acct = accounts[accountId]; - if (!acct?.token || typeof acct.token !== "string") return undefined; - return userIdFromToken(acct.token); -} - -/** Get the moderator bot's Discord user ID from its token */ -function getModeratorUserId(config: WhisperGateConfig): string | undefined { - if (!config.moderatorBotToken) return undefined; - return userIdFromToken(config.moderatorBotToken); -} - -/** Send a message as the moderator bot via Discord REST API */ -async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise { - try { - const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - "Authorization": `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ content }), - }); - if (!r.ok) { - const text = await r.text(); - logger.warn(`whispergate: moderator send failed (${r.status}): ${text}`); - return false; - } - logger.info(`whispergate: moderator message sent to channel=${channelId}`); - return true; - } catch (err) { - logger.warn(`whispergate: moderator send error: ${String(err)}`); - return false; - } -} - -function persistPolicies(api: OpenClawPluginApi): void { - const filePath = policyState.filePath; - if (!filePath) throw new Error("policy file path not initialized"); - const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; - const tmp = `${filePath}.tmp`; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(tmp, before, "utf8"); - fs.renameSync(tmp, filePath); - api.logger.info(`whispergate: policy file persisted: ${filePath}`); -} - -function pickDefined(input: Record) { - const out: Record = {}; - for (const [k, v] of Object.entries(input)) { - if (v !== undefined) out[k] = v; - } - return out; -} - -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 为空的场景 - return allow.includes(channelId); -} - -function debugCtxSummary(ctx: Record, event: Record) { - const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; - return { - sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined, - commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined, - messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined, - channel: typeof ctx.channel === "string" ? ctx.channel : undefined, - channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, - senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined, - from: typeof ctx.from === "string" ? ctx.from : undefined, - metaSenderId: - typeof meta.senderId === "string" - ? meta.senderId - : typeof meta.sender_id === "string" - ? meta.sender_id - : undefined, - metaUserId: - typeof meta.userId === "string" - ? meta.userId - : typeof meta.user_id === "string" - ? meta.user_id - : undefined, - }; -} - -export default { - id: "whispergate", - name: "WhisperGate", - register(api: OpenClawPluginApi) { - // Merge pluginConfig with defaults (in case config is missing from openclaw.json) - const baseConfig = { - enableDiscordControlTool: true, - enableWhispergatePolicyTool: true, - discordControlApiBaseUrl: "http://127.0.0.1:8790", - ...(api.pluginConfig || {}), - } as WhisperGateConfig & { - enableDiscordControlTool: boolean; - discordControlApiBaseUrl: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - enableWhispergatePolicyTool: boolean; - }; - - const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - 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.registerTool( - { - name: "whispergate_tools", - description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - action: { - type: "string", - enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"], - }, - guildId: { type: "string" }, - name: { type: "string" }, - type: { type: "number" }, - parentId: { type: "string" }, - topic: { type: "string" }, - position: { type: "number" }, - nsfw: { type: "boolean" }, - allowedUserIds: { type: "array", items: { type: "string" } }, - allowedRoleIds: { type: "array", items: { type: "string" } }, - allowMask: { type: "string" }, - denyEveryoneMask: { type: "string" }, - channelId: { type: "string" }, - mode: { type: "string", enum: ["merge", "replace"] }, - addUserIds: { type: "array", items: { type: "string" } }, - addRoleIds: { type: "array", items: { type: "string" } }, - removeTargetIds: { type: "array", items: { type: "string" } }, - denyMask: { type: "string" }, - limit: { type: "number" }, - after: { type: "string" }, - fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, - dryRun: { type: "boolean" }, - listMode: { type: "string", enum: ["human-list", "agent-list"] }, - humanList: { type: "array", items: { type: "string" } }, - agentList: { type: "array", items: { type: "string" } }, - endSymbols: { type: "array", items: { type: "string" } }, - }, - required: ["action"], - }, - async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - enableDiscordControlTool?: boolean; - enableWhispergatePolicyTool?: boolean; - }; - ensurePolicyStateLoaded(api, live); - - const action = String(params.action || ""); - const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]); - - if (discordActions.has(action)) { - if (live.enableDiscordControlTool === false) { - return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; - } - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action: action as DiscordControlAction }); - const headers: Record = { "Content-Type": "application/json" }; - if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; - if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; - - const r = await fetch(`${baseUrl}/v1/discord/action`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const text = await r.text(); - if (!r.ok) { - return { - content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }], - isError: true, - }; - } - return { content: [{ type: "text", text }] }; - } - - if (live.enableWhispergatePolicyTool === false) { - return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; - } - - if (action === "policy-get") { - return { - content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], - }; - } - - if (action === "policy-set-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - const next: ChannelPolicy = { - listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, - humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, - agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, - endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, - }; - policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - } - - if (action === "policy-delete-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - delete policyState.channelPolicies[channelId]; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - } - - if (action === "turn-status") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] }; - } - - if (action === "turn-advance") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const next = advanceTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] }; - } - - if (action === "turn-reset") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - resetTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] }; - } - - return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; - }, - }, - { optional: false }, - ); - - api.on("message_received", async (event, ctx) => { - try { - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - // 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; - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); - } - - // Turn management on message received - if (preChannelId) { - ensureTurnOrder(api, preChannelId); - // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. - // The actual sender ID is in event.metadata.senderId. - const metadata = (e as Record).metadata as Record | undefined; - const from = (typeof metadata?.senderId === "string" && metadata.senderId) - || (typeof (e as Record).from === "string" ? (e as Record).from as string : ""); - - // Ignore moderator bot messages — they don't affect turn state - const moderatorUserId = getModeratorUserId(livePre); - if (moderatorUserId && from === moderatorUserId) { - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: ignoring moderator message in channel=${preChannelId}`); - } - // Don't call onNewMessage — moderator messages are transparent to turn logic - } else { - const humanList = livePre.humanList || livePre.bypassUserIds || []; - const isHuman = humanList.includes(from); - const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; - - // Track which bot accounts are present in this channel - if (senderAccountId && senderAccountId !== "default") { - const isNew = recordChannelAccount(preChannelId, senderAccountId); - 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`); - } - } - - onNewMessage(preChannelId, senderAccountId, isHuman); - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); - } - } - } - } catch (err) { - api.logger.warn(`whispergate: message hook failed: ${String(err)}`); - } - }); - - api.on("before_model_resolve", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; - - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - - const prompt = ((event as Record).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 })} ` + - `promptPreview=${prompt.slice(0, 300)}`, - ); - } - - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - // Fallback: extract channelId from sessionKey (format "agent::discord:channel:") - if (!derived.channelId && key) { - const skMatch = key.match(/:channel:(\d+)$/); - if (skMatch) derived.channelId = skMatch[1]; - } - // Only proceed if: discord channel AND prompt contains untrusted metadata - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; - - // Always save channelId and accountId mappings for use in later hooks - if (derived.channelId) { - sessionChannelId.set(key, derived.channelId); - } - const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); - if (resolvedAccountId) { - sessionAccountId.set(key, resolvedAccountId); - } - - 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, - senderId: derived.senderId, - content: derived.content, - }); - rec = { decision, createdAt: Date.now() }; - sessionDecision.set(key, rec); - pruneDecisionMap(); - if (shouldDebugLog(live, derived.channelId)) { - api.logger.info( - `whispergate: debug before_model_resolve recompute session=${key} ` + - `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + - `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } - - // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result. - // This ensures only the current speaker can respond even for human messages. - if (derived.channelId) { - ensureTurnOrder(api, derived.channelId); - const accountId = resolveAccountId(api, ctx.agentId || ""); - if (accountId) { - const turnCheck = checkTurn(derived.channelId, accountId); - if (!turnCheck.allowed) { - // 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}`, - ); - return { - providerOverride: live.noReplyProvider, - modelOverride: live.noReplyModel, - }; - } - // Allowed to speak - record this session as allowed - sessionAllowed.set(key, true); - } - } - - if (!rec.decision.shouldUseNoReply) { - // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 - if (rec.needsRestore) { - sessionDecision.delete(key); - return { - providerOverride: undefined, - modelOverride: undefined, - }; - } - return; - } - - // 标记这次执行了 no-reply,下次需要恢复模型 - rec.needsRestore = true; - sessionDecision.set(key, rec); - - // 无论是否有缓存,只要 debug flag 开启就打印决策详情 - if (live.enableDebugLogs) { - const prompt = ((event as Record).prompt as string) || ""; - const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); - api.logger.info( - `whispergate: DEBUG_NO_REPLY_TRIGGER session=${key} ` + - `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + - `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `decision=${rec.decision.reason} ` + - `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + - `hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`, - ); - } - - api.logger.info( - `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, - ); - - return { - providerOverride: live.noReplyProvider, - modelOverride: live.noReplyModel, - }; - }); - - api.on("before_prompt_build", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; - - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & 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).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); - - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies, - senderId: derived.senderId, - content: derived.content, - }); - rec = { decision, createdAt: Date.now() }; - if (shouldDebugLog(live, derived.channelId)) { - api.logger.info( - `whispergate: debug before_prompt_build recompute session=${key} ` + - `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + - `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } - - sessionDecision.delete(key); - - // Only inject once per session (one-time injection) - if (sessionInjected.has(key)) { - if (shouldDebugLog(live, undefined)) { - api.logger.info( - `whispergate: debug before_prompt_build session=${key} inject skipped (already injected)`, - ); - } - return; - } - - if (!rec.decision.shouldInjectEndMarkerPrompt) { - if (shouldDebugLog(live, undefined)) { - api.logger.info( - `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, - ); - } - return; - } - - // Resolve end symbols from config/policy for dynamic instruction - const prompt = ((event as Record).prompt as string) || ""; - 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); - - // Inject agent identity for group chats - let identity = ""; - if (isGroupChat && ctx.agentId) { - const idStr = buildAgentIdentity(api, ctx.agentId); - if (idStr) identity = idStr + "\n\n"; - } - - // 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 }; - }); - - // Register slash commands for Discord - api.registerCommand({ - name: "whispergate", - description: "WhisperGate 频道策略管理", - acceptsArgs: true, - handler: async (cmdCtx) => { - const args = cmdCtx.args || ""; - const parts = args.trim().split(/\s+/); - 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 - 重置轮流顺序` }; - } - - if (subCmd === "status") { - return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) }; - } - - if (subCmd === "turn-status") { - const channelId = cmdCtx.channelId; - if (!channelId) return { text: "无法获取频道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 }; - 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 }; - resetTurn(channelId); - return { text: JSON.stringify({ ok: true }) }; - } - - return { text: `未知子命令: ${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 ?? {}))}`, - ); - - // 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).message as Record | 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).text === "string") { - content += (part as Record).text; - } - } - } - } - // Fallback to event.content - if (!content) { - content = ((event as Record).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)}`, - ); - - 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}`, - ); - return; - } - - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - - const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); - const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; - 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])}`, - ); - - // 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}`, - ); - - if (wasAllowed === undefined) return; // No record, skip - - 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`, - ); - return; - } - - // Allowed to speak (current speaker) but chose NO_REPLY - advance turn - ensureTurnOrder(api, channelId, live); - 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"}`, - ); - - // 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`, - ); - } - return; - } - - // Trigger moderator handoff message (fire-and-forget, don't await) - if (live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; - void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); - }); - } else { - api.logger.warn(`whispergate: 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); - 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"}`, - ); - } else { - api.logger.info( - `whispergate: 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)}`); - } - }); - - // Turn advance: when an agent sends a message, check if it signals end of turn - api.on("message_sent", async (event, ctx) => { - try { - const key = ctx.sessionKey; - const c = (ctx || {}) as Record; - const e = (event || {}) as Record; - - // 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))} ` + - `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]; - } - 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)}`, - ); - - if (!channelId || !accountId) return; - - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - - const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); - const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; - const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); - const wasNoReply = isEmpty || isNoReply; - - // Skip if turn was already advanced in before_message_write - 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}`, - ); - return; - } - - if (wasNoReply || hasEndSymbol) { - 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}`, - ); - // 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 - if (wasNoReply && nextSpeaker && live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; - sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger); - } else { - api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); - } - } - } - } catch (err) { - api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); - } - }); - }, -}; diff --git a/docker-compose.yml b/docker-compose.yml index af60335..e550422 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ services: - whispergate-no-reply-api: + dirigent-no-reply-api: build: context: ./no-reply-api - container_name: whispergate-no-reply-api + container_name: dirigent-no-reply-api ports: - "8787:8787" environment: - PORT=8787 - - NO_REPLY_MODEL=whispergate-no-reply-v1 + - NO_REPLY_MODEL=dirigent-no-reply-v1 restart: unless-stopped diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index d14f120..f2c75ff 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -1,10 +1,10 @@ { "plugins": { "load": { - "paths": ["/path/to/WhisperGate/dist/whispergate"] + "paths": ["/path/to/Dirigent/dist/dirigent"] }, "entries": { - "whispergate": { + "dirigent": { "enabled": true, "config": { "enabled": true, @@ -13,11 +13,12 @@ "humanList": ["561921120408698910"], "agentList": [], "endSymbols": ["🔚"], - "channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", - "noReplyProvider": "whisper-gateway", + "schedulingIdentifier": "➡️", + "channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json", + "noReplyProvider": "dirigentway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, - "enableWhispergatePolicyTool": true, + "enableDirigentPolicyTool": true, "enableDebugLogs": false, "debugLogChannelIds": [], "discordControlApiBaseUrl": "http://127.0.0.1:8790", @@ -29,7 +30,7 @@ }, "models": { "providers": { - "whisper-gateway": { + "dirigentway": { "apiKey": "", "baseUrl": "http://127.0.0.1:8787/v1", "api": "openai-completions", @@ -52,7 +53,7 @@ { "id": "main", "tools": { - "allow": ["whispergate"] + "allow": ["dirigent"] } } ] diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 0df2780..de9119e 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -2,8 +2,8 @@ 目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: -> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。 -> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。 +> 现在可以通过 Dirigent 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。 +> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `dirigent` 或 `discord_control`)。 1. 创建指定名单可见的私人频道 2. 查看 server 成员列表(分页) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 96e748d..2a17266 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,8 +1,8 @@ -# WhisperGate Implementation Notes +# Dirigent Implementation Notes ## Decision path -WhisperGate evaluates in strict order: +Dirigent evaluates in strict order: 1. channel check (discord-only) 2. bypass sender check diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 9e3d239..e04cbb5 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -1,4 +1,4 @@ -# WhisperGate Integration (No-touch Template) +# Dirigent Integration (No-touch Template) This guide **does not** change your current OpenClaw config automatically. It only generates a JSON snippet you can review. @@ -7,9 +7,9 @@ It only generates a JSON snippet you can review. ```bash node scripts/render-openclaw-config.mjs \ - /absolute/path/to/WhisperGate/plugin \ + /absolute/path/to/Dirigent/plugin \ openai \ - whispergate-no-reply-v1 \ + dirigent-no-reply-v1 \ 561921120408698910 ``` @@ -23,7 +23,7 @@ Arguments: The script prints JSON for: - `plugins.load.paths` -- `plugins.entries.whispergate.config` +- `plugins.entries.dirigent.config` You can merge this snippet manually into your `openclaw.json`. @@ -32,20 +32,20 @@ You can merge this snippet manually into your `openclaw.json`. For production-like install with automatic rollback on error (Node-only installer): ```bash -node ./scripts/install-whispergate-openclaw.mjs --install +node ./scripts/install-dirigent-openclaw.mjs --install # or wrapper -./scripts/install-whispergate-openclaw.sh --install +./scripts/install-dirigent-openclaw.sh --install ``` Uninstall (revert all recorded config changes): ```bash -node ./scripts/install-whispergate-openclaw.mjs --uninstall +node ./scripts/install-dirigent-openclaw.mjs --uninstall # or wrapper -./scripts/install-whispergate-openclaw.sh --uninstall +./scripts/install-dirigent-openclaw.sh --uninstall # or specify a record explicitly -# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \ -# node ./scripts/install-whispergate-openclaw.mjs --uninstall +# RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \ +# node ./scripts/install-dirigent-openclaw.mjs --uninstall ``` Environment overrides: @@ -66,15 +66,15 @@ The script: - writes via `openclaw config set ... --json` - creates config backup first - restores backup automatically if any install step fails -- restarts gateway during install, then validates `whisper-gateway/no-reply` is visible via `openclaw models list/status` +- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status` - writes a change record for every install/uninstall: - - directory: `~/.openclaw/whispergate-install-records/` - - latest pointer: `~/.openclaw/whispergate-install-record-latest.json` + - directory: `~/.openclaw/dirigent-install-records/` + - latest pointer: `~/.openclaw/dirigent-install-record-latest.json` Policy state semantics: - channel policy file is loaded once into memory on startup - runtime decisions use in-memory state -- use `whispergate_policy` tool to update state (memory first, then file persist) +- use `dirigent_policy` tool to update state (memory first, then file persist) - manual file edits do not auto-apply until next restart ## Notes diff --git a/docs/PR_SUMMARY.md b/docs/PR_SUMMARY.md index 380e0a8..64be235 100644 --- a/docs/PR_SUMMARY.md +++ b/docs/PR_SUMMARY.md @@ -1,15 +1,15 @@ -# PR Summary (WhisperGate + Discord Control) +# PR Summary (Dirigent + Discord Control) ## Scope This PR delivers two tracks: -1. WhisperGate deterministic no-reply gate for Discord sessions +1. Dirigent deterministic no-reply gate for Discord sessions 2. Discord control extension API for private-channel/member-list gaps ## Delivered Features -### WhisperGate +### Dirigent - Deterministic rule chain: 1) non-discord => skip diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8d646d1..b96ee69 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -8,17 +8,17 @@ node scripts/package-plugin.mjs Output: -- `dist/whispergate/index.ts` -- `dist/whispergate/rules.ts` -- `dist/whispergate/openclaw.plugin.json` -- `dist/whispergate/README.md` -- `dist/whispergate/package.json` +- `dist/dirigent/index.ts` +- `dist/dirigent/rules.ts` +- `dist/dirigent/openclaw.plugin.json` +- `dist/dirigent/README.md` +- `dist/dirigent/package.json` ## Use packaged plugin path Point OpenClaw `plugins.load.paths` to: -`/absolute/path/to/WhisperGate/dist/whispergate` +`/absolute/path/to/Dirigent/dist/dirigent` ## Verify package completeness diff --git a/docs/ROLLOUT.md b/docs/ROLLOUT.md index 48a60ee..88cfefd 100644 --- a/docs/ROLLOUT.md +++ b/docs/ROLLOUT.md @@ -1,4 +1,4 @@ -# WhisperGate Rollout Checklist +# Dirigent Rollout Checklist ## Stage 0: Local sanity @@ -29,5 +29,5 @@ ## Rollback -- Disable plugin entry `whispergate.enabled=false` OR remove plugin path +- Disable plugin entry `dirigent.enabled=false` OR remove plugin path - Keep API service running; it is inert when plugin disabled diff --git a/docs/RUN_MODES.md b/docs/RUN_MODES.md index 57957e9..259f8e2 100644 --- a/docs/RUN_MODES.md +++ b/docs/RUN_MODES.md @@ -1,6 +1,6 @@ # Run Modes -WhisperGate has two runtime components: +Dirigent has two runtime components: 1. `plugin/` (OpenClaw plugin) 2. `no-reply-api/` (deterministic NO_REPLY service) @@ -20,7 +20,7 @@ Then configure OpenClaw provider `baseURL` to `http://127.0.0.1:8787/v1`. ```bash ./scripts/dev-up.sh -# or: docker compose up -d --build whispergate-no-reply-api +# or: docker compose up -d --build dirigent-no-reply-api ``` Stop: diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index ad8aa43..53c0635 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,4 @@ -# WhisperGate 测试记录报告(阶段性) +# Dirigent 测试记录报告(阶段性) 日期:2026-02-25 @@ -6,19 +6,19 @@ 本轮覆盖: -1. WhisperGate 基础静态与脚本测试 +1. Dirigent 基础静态与脚本测试 2. no-reply-api 隔离集成测试 3. discord-control-api 功能测试(dryRun + 实操) 未覆盖: -- WhisperGate 插件真实挂载 OpenClaw 后的端到端(E2E) +- Dirigent 插件真实挂载 OpenClaw 后的端到端(E2E) --- ## 二、测试环境 -- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate` +- 代码仓库:`/root/.openclaw/workspace-operator/Dirigent` - OpenClaw 配置来源:本机已有配置(读取 Discord token) - Discord guild(server)ID:`1368531017534537779` - allowlist user IDs: @@ -29,7 +29,7 @@ ## 三、已执行测试与结果 -### A. WhisperGate 基础测试 +### A. Dirigent 基础测试 命令: @@ -95,7 +95,7 @@ make check check-rules test-api ## 五、待测项(下一阶段) -### 1) WhisperGate 插件 E2E(需临时接入 OpenClaw 配置) +### 1) Dirigent 插件 E2E(需临时接入 OpenClaw 配置) 目标:验证插件真实挂载后的完整链路。 @@ -113,7 +113,7 @@ make check check-rules test-api ### 2) 回归测试 -- discord-control-api 引入后,不影响 WhisperGate 原有流程 +- discord-control-api 引入后,不影响 Dirigent 原有流程 - 规则校验脚本在最新代码继续稳定通过 ### 3) 运行与安全校验 @@ -127,4 +127,4 @@ make check check-rules test-api ## 六、当前结论 - 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。 -- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。 +- 项目剩余主要测试工作集中在 Dirigent 插件与 OpenClaw 的真实 E2E 联调。 diff --git a/docs/TURN-WAKEUP-PROBLEM.md b/docs/TURN-WAKEUP-PROBLEM.md index f56b59f..d9c42ea 100644 --- a/docs/TURN-WAKEUP-PROBLEM.md +++ b/docs/TURN-WAKEUP-PROBLEM.md @@ -2,7 +2,7 @@ ## Context -WhisperGate implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override. +Dirigent implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override. ## The Problem @@ -12,7 +12,7 @@ When the current speaker responds with **NO_REPLY** (decides the message is not 1. A message arrives in the Discord channel 2. OpenClaw routes it to **all** agent sessions in that channel simultaneously -3. The WhisperGate plugin intercepts at `before_model_resolve`: +3. The Dirigent plugin intercepts at `before_model_resolve`: - Current speaker → allowed to process - Everyone else → forced to no-reply model (message is "consumed" silently) 4. Current speaker processes the message and returns NO_REPLY diff --git a/docs/VERIFY.md b/docs/VERIFY.md index 52ea5d3..f242d9d 100644 --- a/docs/VERIFY.md +++ b/docs/VERIFY.md @@ -1,4 +1,4 @@ -# WhisperGate Quick Verification +# Dirigent Quick Verification ## 1) Start no-reply API @@ -15,7 +15,7 @@ npm start curl -sS http://127.0.0.1:8787/health curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \ -H 'Content-Type: application/json' \ - -d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}' + -d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hi"}]}' ``` Or run bundled smoke check: diff --git a/no-reply-api/package-lock.json b/no-reply-api/package-lock.json index 3e29cb5..841c161 100644 --- a/no-reply-api/package-lock.json +++ b/no-reply-api/package-lock.json @@ -1,11 +1,11 @@ { - "name": "whispergate-no-reply-api", + "name": "dirigent-no-reply-api", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "whispergate-no-reply-api", + "name": "dirigent-no-reply-api", "version": "0.1.0" } } diff --git a/no-reply-api/package.json b/no-reply-api/package.json index 0798fd9..212323f 100644 --- a/no-reply-api/package.json +++ b/no-reply-api/package.json @@ -1,5 +1,5 @@ { - "name": "whispergate-no-reply-api", + "name": "dirigent-no-reply-api", "version": "0.1.0", "private": true, "type": "module", diff --git a/no-reply-api/server.mjs b/no-reply-api/server.mjs index f56a1dd..c04927a 100644 --- a/no-reply-api/server.mjs +++ b/no-reply-api/server.mjs @@ -1,7 +1,7 @@ import http from "node:http"; const port = Number(process.env.PORT || 8787); -const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1"; +const modelName = process.env.NO_REPLY_MODEL || "dirigent-no-reply-v1"; const authToken = process.env.AUTH_TOKEN || ""; function sendJson(res, status, payload) { @@ -17,7 +17,7 @@ function isAuthorized(req) { function noReplyChatCompletion(reqBody) { return { - id: `chatcmpl_whispergate_${Date.now()}`, + id: `chatcmpl_dirigent_${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: reqBody?.model || modelName, @@ -34,7 +34,7 @@ function noReplyChatCompletion(reqBody) { function noReplyResponses(reqBody) { return { - id: `resp_whispergate_${Date.now()}`, + id: `resp_dirigent_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), model: reqBody?.model || modelName, @@ -57,7 +57,7 @@ function listModels() { id: modelName, object: "model", created: Math.floor(Date.now() / 1000), - owned_by: "whispergate" + owned_by: "dirigent" } ] }; @@ -65,7 +65,7 @@ function listModels() { const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/health") { - return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api", model: modelName }); + return sendJson(res, 200, { ok: true, service: "dirigent-no-reply-api", model: modelName }); } if (req.method === "GET" && req.url === "/v1/models") { @@ -108,5 +108,5 @@ const server = http.createServer((req, res) => { }); server.listen(port, () => { - console.log(`[whispergate-no-reply-api] listening on :${port}`); + console.log(`[dirigent-no-reply-api] listening on :${port}`); }); diff --git a/package.json b/package.json new file mode 100644 index 0000000..44692d6 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hangman-lab/dirigent", + "version": "0.2.0", + "description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw", + "type": "module", + "files": [ + "dist/", + "plugin/", + "no-reply-api/", + "discord-control-api/", + "docs/", + "scripts/install-dirigent-openclaw.mjs", + "docker-compose.yml", + "Makefile", + "README.md", + "CHANGELOG.md", + "TASKLIST.md" + ], + "scripts": { + "prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/", + "postinstall": "node scripts/install-dirigent-openclaw.mjs --install", + "uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall" + }, + "keywords": [ + "openclaw", + "plugin", + "discord", + "moderation", + "turn-management" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.hangman-lab.top/nav/Dirigent.git" + }, + "engines": { + "node": ">=20" + } +} diff --git a/plugin/README.md b/plugin/README.md index db01bbf..cd6592d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,12 +1,10 @@ -# WhisperGate Plugin +# Dirigent Plugin ## Hook strategy - `message:received` caches a per-session decision from deterministic rules. - `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply. -- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is: - - `bypass_sender` - - `end_symbol:*` +- `before_prompt_build` prepends end-marker instruction + scheduling identifier instruction when decision allows speaking. ## Rules (in order) @@ -30,12 +28,14 @@ Optional: - `humanList` (default []) - `agentList` (default []) - `channelPoliciesFile` (per-channel overrides in a standalone JSON file) -- `enableWhispergatePolicyTool` (default true) +- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier +- `enableDirigentPolicyTool` (default true) Unified optional tool: -- `whispergateway_tools` +- `dirigent_tools` - Discord actions: `channel-private-create`, `channel-private-update`, `member-list` - Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel` + - Turn actions: `turn-status`, `turn-advance`, `turn-reset` - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) @@ -51,20 +51,23 @@ Policy file behavior: - loaded once on startup into memory - runtime decisions read memory state only - direct file edits do NOT affect memory state -- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write) +- `dirigent_tools` policy actions update memory first, then persist to file (atomic write) -## Optional tool: `whispergateway_tools` +## Moderator handoff format -This plugin registers one unified optional tool: `whispergateway_tools`. -To use it, add tool allowlist entry for either: -- tool name: `whispergateway_tools` -- plugin id: `whispergate` +When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡️` -Supported actions: -- Discord: `channel-private-create`, `channel-private-update`, `member-list` -- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel` +This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak. + +## Slash command (Discord) + +``` +/dirigent status +/dirigent turn-status +/dirigent turn-advance +/dirigent turn-reset +``` Debug logging: - set `enableDebugLogs: true` to emit detailed hook diagnostics - optionally set `debugLogChannelIds` to only log selected channel IDs -- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build` diff --git a/plugin/index.ts b/plugin/index.ts index d838633..eb2748e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,10 +1,50 @@ import fs from "node:fs"; import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; 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"; +// ── No-Reply API child process lifecycle ────────────────────────────── +let noReplyProcess: ChildProcess | null = null; + +function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void { + if (noReplyProcess) { + logger.info("dirigent: no-reply API already running, skipping"); + return; + } + + const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs"); + if (!fs.existsSync(serverPath)) { + logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`); + return; + } + + noReplyProcess = spawn(process.execPath, [serverPath], { + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`)); + noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`)); + + noReplyProcess.on("exit", (code, signal) => { + logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`); + noReplyProcess = null; + }); + + logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`); +} + +function stopNoReplyApi(logger: { info: (m: string) => void }): void { + if (!noReplyProcess) return; + logger.info("dirigent: stopping no-reply API"); + noReplyProcess.kill("SIGTERM"); + noReplyProcess = null; +} + type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; type DecisionRecord = { @@ -31,15 +71,20 @@ const sessionAccountId = new Map(); // Track sessionKey -> accou const sessionTurnHandled = new Set(); // 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 +215,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) || {}; const plugins = (root.plugins as Record) || {}; const entries = (plugins.entries as Record) || {}; - const entry = (entries.whispergate as Record) || {}; + // Support both "dirigent" and legacy "whispergate" config keys + const entry = (entries.dirigent as Record) || (entries.whispergate as Record) || {}; const cfg = (entry.config as Record) || {}; 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 +258,7 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf const parsed = JSON.parse(raw) as Record; 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 +341,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) || {}; @@ -318,9 +366,16 @@ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | u const agent = agents.find((a: Record) => 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 +404,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 +422,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 +441,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) { @@ -401,7 +456,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 +486,51 @@ function debugCtxSummary(ctx: Record, event: Record { + startNoReplyApi(api.logger, pluginDir); + + const live = getLivePluginConfig(api, baseConfig as DirigentConfig); + if (live.moderatorBotToken) { + startModeratorPresence(live.moderatorBotToken, api.logger); + api.logger.info("dirigent: moderator bot presence starting"); + } + }); + + api.on("gateway_stop", () => { + stopNoReplyApi(api.logger); + stopModeratorPresence(); + api.logger.info("dirigent: gateway stopping, services shut down"); + }); 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 +568,12 @@ export default { required: ["action"], }, async execute(_id: string, params: Record) { - 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 +598,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 +683,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 +701,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 +715,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 +734,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).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 +781,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).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + @@ -732,7 +802,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 +815,6 @@ export default { } if (!rec.decision.shouldUseNoReply) { - // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 if (rec.needsRestore) { sessionDecision.delete(key); return { @@ -756,16 +825,14 @@ export default { return; } - // 标记这次执行了 no-reply,下次需要恢复模型 rec.needsRestore = true; sessionDecision.set(key, rec); - // 无论是否有缓存,只要 debug flag 开启就打印决策详情 if (live.enableDebugLogs) { const prompt = ((event as Record).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).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + @@ -776,7 +843,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 +856,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 +877,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).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + @@ -826,7 +893,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 +902,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 +913,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 +947,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 +960,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).message as Record | 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).text === "string") { @@ -953,30 +1014,25 @@ export default { } } } - // Fallback to event.content if (!content) { content = ((event as Record).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 +1043,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 +1123,17 @@ export default { const c = (ctx || {}) as Record; const e = (event || {}) as Record; - // 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 +1141,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 +1162,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 +1171,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)}`); } }); }, diff --git a/plugin/moderator-presence.ts b/plugin/moderator-presence.ts index dfe311e..0f914a9 100644 --- a/plugin/moderator-presence.ts +++ b/plugin/moderator-presence.ts @@ -94,7 +94,7 @@ function connect(token: string, logger: Logger, isResume = false) { try { ws = new WebSocket(url); } catch (err) { - logger.warn(`whispergate: moderator ws constructor failed: ${String(err)}`); + logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`); scheduleReconnect(token, logger, false); return; } @@ -119,8 +119,8 @@ function connect(token: string, logger: Logger, isResume = false) { intents: 0, properties: { os: "linux", - browser: "whispergate", - device: "whispergate", + browser: "dirigent", + device: "dirigent", }, presence: { status: "online", @@ -154,19 +154,19 @@ function connect(token: string, logger: Logger, isResume = false) { if (t === "READY") { sessionId = d.session_id; resumeUrl = d.resume_gateway_url; - logger.info("whispergate: moderator bot connected and online"); + logger.info("dirigent: moderator bot connected and online"); } if (t === "RESUMED") { - logger.info("whispergate: moderator bot resumed"); + logger.info("dirigent: moderator bot resumed"); } break; case 7: // Reconnect request - logger.info("whispergate: moderator bot reconnect requested by Discord"); + logger.info("dirigent: moderator bot reconnect requested by Discord"); cleanup(); scheduleReconnect(token, logger, true); break; case 9: // Invalid Session - logger.warn(`whispergate: moderator bot invalid session, resumable=${d}`); + logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`); cleanup(); sessionId = d ? sessionId : null; // Wait longer before re-identifying @@ -189,18 +189,18 @@ function connect(token: string, logger: Logger, isResume = false) { // Non-recoverable codes — stop reconnecting if (code === 4004) { - logger.warn("whispergate: moderator bot token invalid (4004), stopping"); + logger.warn("dirigent: moderator bot token invalid (4004), stopping"); started = false; return; } if (code === 4010 || code === 4011 || code === 4013 || code === 4014) { - logger.warn(`whispergate: moderator bot fatal close (${code}), re-identifying`); + logger.warn(`dirigent: moderator bot fatal close (${code}), re-identifying`); sessionId = null; scheduleReconnect(token, logger, false); return; } - logger.info(`whispergate: moderator bot disconnected (code=${code}), will reconnect`); + logger.info(`dirigent: moderator bot disconnected (code=${code}), will reconnect`); const canResume = !!sessionId && code !== 4012; scheduleReconnect(token, logger, canResume); }; @@ -220,7 +220,7 @@ function scheduleReconnect(token: string, logger: Logger, resume: boolean) { const jitter = Math.random() * 1000; const delay = baseDelay + jitter; - logger.info(`whispergate: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`); + logger.info(`dirigent: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`); reconnectTimer = setTimeout(() => { reconnectTimer = null; @@ -234,7 +234,7 @@ function scheduleReconnect(token: string, logger: Logger, resume: boolean) { */ export function startModeratorPresence(token: string, logger: Logger): void { if (started) { - logger.info("whispergate: moderator presence already started, skipping"); + logger.info("dirigent: moderator presence already started, skipping"); return; } started = true; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1cb7fab..1886edf 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,8 +1,8 @@ { - "id": "whispergate", - "name": "WhisperGate", - "version": "0.1.0", - "description": "Rule-based no-reply gate with provider/model override", + "id": "dirigent", + "name": "Dirigent", + "version": "0.2.0", + "description": "Rule-based no-reply gate with provider/model override and turn management", "entry": "./index.ts", "configSchema": { "type": "object", @@ -13,13 +13,14 @@ "listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" }, "humanList": { "type": "array", "items": { "type": "string" }, "default": [] }, "agentList": { "type": "array", "items": { "type": "string" }, "default": [] }, - "channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" }, + "channelPoliciesFile": { "type": "string", "default": "~/.openclaw/dirigent-channel-policies.json" }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, + "schedulingIdentifier": { "type": "string", "default": "➡️" }, "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, "enableDiscordControlTool": { "type": "boolean", "default": true }, - "enableWhispergatePolicyTool": { "type": "boolean", "default": true }, + "enableDirigentPolicyTool": { "type": "boolean", "default": true }, "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, "discordControlApiToken": { "type": "string" }, "discordControlCallerId": { "type": "string" }, diff --git a/plugin/package.json b/plugin/package.json index 35b00ba..2170881 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,9 +1,9 @@ { - "name": "whispergate-plugin", - "version": "0.1.0", + "name": "dirigent-plugin", + "version": "0.2.0", "private": true, "type": "module", - "description": "WhisperGate OpenClaw plugin", + "description": "Dirigent OpenClaw plugin", "scripts": { "check": "node ../scripts/check-plugin-files.mjs", "check:rules": "node ../scripts/validate-rules.mjs" diff --git a/plugin/rules.ts b/plugin/rules.ts index 9abcda9..26f181b 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -1,4 +1,4 @@ -export type WhisperGateConfig = { +export type DirigentConfig = { enabled?: boolean; discordOnly?: boolean; listMode?: "human-list" | "agent-list"; @@ -8,6 +8,8 @@ export type WhisperGateConfig = { // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; + /** Scheduling identifier sent by moderator to activate agents (default: ➡️) */ + schedulingIdentifier?: string; noReplyProvider: string; noReplyModel: string; /** Discord bot token for the moderator bot (used for turn handoff messages) */ @@ -51,7 +53,7 @@ function getLastChar(input: string): string { return chars[chars.length - 1] || ""; } -export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { +export function resolvePolicy(config: DirigentConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; @@ -76,7 +78,7 @@ export function resolvePolicy(config: WhisperGateConfig, channelId?: string, cha } export function evaluateDecision(params: { - config: WhisperGateConfig; + config: DirigentConfig; channel?: string; channelId?: string; channelPolicies?: Record; diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index eda0d39..e876085 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -4,10 +4,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -echo "[whispergate] building/starting no-reply API container" -docker compose up -d --build whispergate-no-reply-api +echo "[dirigent] building/starting no-reply API container" +docker compose up -d --build dirigent-no-reply-api -echo "[whispergate] health check" +echo "[dirigent] health check" curl -sS http://127.0.0.1:8787/health -echo "[whispergate] done" +echo "[dirigent] done" diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs new file mode 100755 index 0000000..99bfde0 --- /dev/null +++ b/scripts/install-dirigent-openclaw.mjs @@ -0,0 +1,352 @@ +#!/usr/bin/env node +/** + * Dirigent plugin installer/uninstaller with delta-tracking. + * Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys. + */ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFileSync, spawnSync } from "node:child_process"; + +const modeArg = process.argv[2]; +if (modeArg !== "--install" && modeArg !== "--uninstall") { + console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall"); + process.exit(2); +} +const mode = modeArg === "--install" ? "install" : "uninstall"; + +const env = process.env; +const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); +const __dirname = path.dirname(new URL(import.meta.url).pathname); + +// Ensure dist/dirigent exists (handles git clone without npm prepare) +const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent"); +const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin"); +if (mode === "install" && !fs.existsSync(DIST_DIR)) { + console.log("[dirigent] dist/ not found, syncing from plugin/..."); + fs.mkdirSync(DIST_DIR, { recursive: true }); + for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) { + fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f)); + } +} + +const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR; +const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway"; +const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; +const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; +const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token"; +const LIST_MODE = env.LIST_MODE || "human-list"; +const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; +const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; +const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); +const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; +const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; +const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️"; + +const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir()); +const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); + +const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); +const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`; +const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`); + +const PATH_PLUGINS_LOAD = "plugins.load.paths"; +const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent"; +const PATH_PROVIDERS = "models.providers"; +const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`; +const PATH_PLUGINS_ALLOW = "plugins.allow"; + +function runOpenclaw(args, { allowFail = false } = {}) { + +function runOpenclaw(args, { allowFail = false } = {}) { + try { + return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); + } catch (e) { + if (allowFail) return null; + throw e; + } +} + +function getJson(pathKey) { + const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); + if (out == null || out === "") return undefined; + return JSON.parse(out); +} + +function setJson(pathKey, value) { + runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); +} + +function unsetPath(pathKey) { + runOpenclaw(["config", "unset", pathKey], { allowFail: true }); +} + +function writeRecord(modeName, delta) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const rec = { + mode: modeName, + timestamp: ts, + openclawConfigPath: OPENCLAW_CONFIG_PATH, + backupPath: BACKUP_PATH, + delta, // { added: {...}, replaced: {...}, removed: {...} } + }; + fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); + fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); + return rec; +} + +function readRecord(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function findLatestInstallRecord() { + if (!fs.existsSync(STATE_DIR)) return ""; + const files = fs + .readdirSync(STATE_DIR) + .filter((f) => /^dirigent-\d+\.json$/.test(f)) + .sort() + .reverse(); + for (const f of files) { + const p = path.join(STATE_DIR, f); + try { + const rec = readRecord(p); + if (rec?.mode === "install") return p; + } catch { + // ignore broken records + } + } + return ""; +} + +// Deep clone +function clone(v) { + return JSON.parse(JSON.stringify(v)); +} + +// Check if two values are deeply equal +function deepEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { + console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`); + process.exit(1); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// INSTALL (with auto-reinstall if already installed) +// ═══════════════════════════════════════════════════════════════════════════ +if (mode === "install") { + // Check if already installed - if so, uninstall first + const existingRecord = findLatestInstallRecord(); + if (existingRecord) { + console.log("[dirigent] existing installation detected, uninstalling first..."); + process.env.RECORD_FILE = existingRecord; + // Re-exec ourselves in uninstall mode + const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], { + env: process.env, + stdio: ["inherit", "inherit", "inherit"], + }); + if (result.status !== 0) { + console.error("[dirigent] reinstall failed during uninstall phase"); + process.exit(1); + } + console.log("[dirigent] previous installation removed, proceeding with fresh install..."); + } + + fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); + console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); + + if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { + fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); + fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); + console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); + } + + const delta = { added: {}, replaced: {}, removed: {} }; + + try { + // ── plugins.load.paths ──────────────────────────────────────────────── + const plugins = getJson("plugins") || {}; + const oldPaths = clone(plugins.load?.paths) || []; + const newPaths = clone(oldPaths); + const pathIndex = newPaths.indexOf(PLUGIN_PATH); + if (pathIndex === -1) { + newPaths.push(PLUGIN_PATH); + delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path + } else { + // already present, no change + } + // save old paths for potential future rollback of this specific change + delta._prev = delta._prev || {}; + delta._prev[PATH_PLUGINS_LOAD] = oldPaths; + plugins.load = plugins.load || {}; + plugins.load.paths = newPaths; + + // ── plugins.entries.dirigent ────────────────────────────────────────── + const oldEntry = clone(plugins.entries?.dirigent); + const newEntry = { + enabled: true, + config: { + enabled: true, + discordOnly: true, + listMode: LIST_MODE, + humanList: JSON.parse(HUMAN_LIST_JSON), + agentList: JSON.parse(AGENT_LIST_JSON), + channelPoliciesFile: CHANNEL_POLICIES_FILE, + endSymbols: JSON.parse(END_SYMBOLS_JSON), + schedulingIdentifier: SCHEDULING_IDENTIFIER, + noReplyProvider: NO_REPLY_PROVIDER_ID, + noReplyModel: NO_REPLY_MODEL_ID, + }, + }; + if (oldEntry === undefined) { + delta.added[PATH_PLUGIN_ENTRY] = newEntry; + } else { + delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry; + } + plugins.entries = plugins.entries || {}; + plugins.entries.dirigent = newEntry; + setJson("plugins", plugins); + + // ── models.providers. ───────────────────────────────────── + const providers = getJson(PATH_PROVIDERS) || {}; + const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]); + const newProvider = { + baseUrl: NO_REPLY_BASE_URL, + apiKey: NO_REPLY_API_KEY, + api: "openai-completions", + models: [ + { + id: NO_REPLY_MODEL_ID, + name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }; + if (oldProvider === undefined) { + delta.added[PATH_PROVIDER_ENTRY] = newProvider; + } else { + delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider; + } + providers[NO_REPLY_PROVIDER_ID] = newProvider; + setJson(PATH_PROVIDERS, providers); + + // ── plugins.allow ───────────────────────────────────────────────────── + const allowList = getJson(PATH_PLUGINS_ALLOW) || []; + const oldAllow = clone(allowList); + if (!allowList.includes("dirigent")) { + allowList.push("dirigent"); + delta.added[PATH_PLUGINS_ALLOW] = "dirigent"; + delta._prev = delta._prev || {}; + delta._prev[PATH_PLUGINS_ALLOW] = oldAllow; + setJson(PATH_PLUGINS_ALLOW, allowList); + console.log("[dirigent] added 'dirigent' to plugins.allow"); + } + + writeRecord("install", delta); + console.log("[dirigent] install ok (config written)"); + console.log(`[dirigent] record: ${RECORD_PATH}`); + console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); + } catch (e) { + fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); + console.error(`[dirigent] install failed; rollback complete: ${String(e)}`); + process.exit(1); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// UNINSTALL +// ═══════════════════════════════════════════════════════════════════════════ +else { + const recFile = env.RECORD_FILE || findLatestInstallRecord(); + if (!recFile || !fs.existsSync(recFile)) { + console.log("[dirigent] no install record found, nothing to uninstall."); + process.exit(0); + } + + fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); + console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); + + const rec = readRecord(recFile); + const delta = rec.delta || { added: {}, replaced: {}, removed: {} }; + + try { + // ── Handle ADDED entries: remove them ───────────────────────────────── + if (delta.added[PATH_PLUGIN_ENTRY] !== undefined) { + const plugins = getJson("plugins") || {}; + plugins.entries = plugins.entries || {}; + delete plugins.entries.dirigent; + setJson("plugins", plugins); + console.log("[dirigent] removed plugins.entries.dirigent"); + } + + if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) { + const providers = getJson(PATH_PROVIDERS) || {}; + delete providers[NO_REPLY_PROVIDER_ID]; + setJson(PATH_PROVIDERS, providers); + console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`); + } + + if (delta.added[PATH_PLUGINS_LOAD] !== undefined) { + const plugins = getJson("plugins") || {}; + const paths = plugins.load?.paths || []; + const idx = paths.indexOf(PLUGIN_PATH); + if (idx !== -1) { + paths.splice(idx, 1); + plugins.load.paths = paths; + setJson("plugins", plugins); + console.log("[dirigent] removed plugin path from plugins.load.paths"); + } + } + + // ── Handle plugins.allow ────────────────────────────────────────────── + if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) { + const allowList = getJson(PATH_PLUGINS_ALLOW) || []; + const idx = allowList.indexOf("dirigent"); + if (idx !== -1) { + allowList.splice(idx, 1); + setJson(PATH_PLUGINS_ALLOW, allowList); + console.log("[dirigent] removed 'dirigent' from plugins.allow"); + } + } + + // ── Handle REPLACED entries: restore old value ──────────────────────── + if (delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) { + const plugins = getJson("plugins") || {}; + plugins.entries = plugins.entries || {}; + plugins.entries.dirigent = delta.replaced[PATH_PLUGIN_ENTRY]; + setJson("plugins", plugins); + console.log("[dirigent] restored previous plugins.entries.dirigent"); + } + + if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) { + const providers = getJson(PATH_PROVIDERS) || {}; + providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY]; + setJson(PATH_PROVIDERS, providers); + console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`); + } + + // Handle plugins.load.paths restoration (if it was replaced, not added) + if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) { + const plugins = getJson("plugins") || {}; + plugins.load = plugins.load || {}; + plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD]; + setJson("plugins", plugins); + console.log("[dirigent] restored previous plugins.load.paths"); + } + + writeRecord("uninstall", delta); + console.log("[dirigent] uninstall ok"); + console.log(`[dirigent] record: ${RECORD_PATH}`); + console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); + } catch (e) { + fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); + console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`); + process.exit(1); + } +} diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs deleted file mode 100755 index be64876..0000000 --- a/scripts/install-whispergate-openclaw.mjs +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { execFileSync } from "node:child_process"; - -const modeArg = process.argv[2]; -if (modeArg !== "--install" && modeArg !== "--uninstall") { - console.error("Usage: install-whispergate-openclaw.mjs --install | --uninstall"); - process.exit(2); -} -const mode = modeArg === "--install" ? "install" : "uninstall"; - -const env = process.env; -const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); -const __dirname = path.dirname(new URL(import.meta.url).pathname); -const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate"); -const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway"; -const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; -const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; -const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token"; -const LIST_MODE = env.LIST_MODE || "human-list"; -const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; -const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; -const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/whispergate-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); -const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; -const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; - -const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").replace(/^~(?=$|\/)/, os.homedir()); -const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/whispergate-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); - -const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); -const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`; -const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`); - -const PATH_PLUGINS_LOAD = "plugins.load.paths"; -const PATH_PLUGIN_ENTRY = "plugins.entries.whispergate"; -const PATH_PROVIDERS = "models.providers"; - -function runOpenclaw(args, { allowFail = false } = {}) { - try { - return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); - } catch (e) { - if (allowFail) return null; - throw e; - } -} - - -function getJson(pathKey) { - const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); - if (out == null || out === "") return { exists: false }; - return { exists: true, value: JSON.parse(out) }; -} - -function setJson(pathKey, value) { - runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); -} - -function unsetPath(pathKey) { - runOpenclaw(["config", "unset", pathKey], { allowFail: true }); -} - -function writeRecord(modeName, before, after) { - fs.mkdirSync(STATE_DIR, { recursive: true }); - const rec = { - mode: modeName, - timestamp: ts, - openclawConfigPath: OPENCLAW_CONFIG_PATH, - backupPath: BACKUP_PATH, - paths: before, - applied: after, - }; - fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); - fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); -} - -function readRecord(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function findLatestInstallRecord() { - if (!fs.existsSync(STATE_DIR)) return ""; - const files = fs - .readdirSync(STATE_DIR) - .filter((f) => /^whispergate-\d+\.json$/.test(f)) - .sort() - .reverse(); - for (const f of files) { - const p = path.join(STATE_DIR, f); - try { - const rec = readRecord(p); - if (rec?.mode === "install") return p; - } catch { - // ignore broken records - } - } - return ""; -} - -if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { - console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`); - process.exit(1); -} - -if (mode === "install") { - fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[whispergate] backup: ${BACKUP_PATH}`); - - if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { - fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); - fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); - console.log(`[whispergate] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); - } - - const before = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; - - try { - const pluginsNow = getJson("plugins").value || {}; - const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; - plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; - const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : []; - if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH); - plugins.load.paths = paths; - plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; - plugins.entries.whispergate = { - enabled: true, - config: { - enabled: true, - discordOnly: true, - listMode: LIST_MODE, - humanList: JSON.parse(HUMAN_LIST_JSON), - agentList: JSON.parse(AGENT_LIST_JSON), - channelPoliciesFile: CHANNEL_POLICIES_FILE, - endSymbols: JSON.parse(END_SYMBOLS_JSON), - noReplyProvider: NO_REPLY_PROVIDER_ID, - noReplyModel: NO_REPLY_MODEL_ID, - }, - }; - setJson("plugins", plugins); - - const providersNow = getJson(PATH_PROVIDERS).value || {}; - const providers = typeof providersNow === "object" ? providersNow : {}; - providers[NO_REPLY_PROVIDER_ID] = { - baseUrl: NO_REPLY_BASE_URL, - apiKey: NO_REPLY_API_KEY, - api: "openai-completions", - models: [ - { - id: NO_REPLY_MODEL_ID, - name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 8192, - }, - ], - }; - setJson(PATH_PROVIDERS, providers); - - const after = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; - writeRecord("install", before, after); - console.log("[whispergate] install ok (config written)"); - console.log(`[whispergate] record: ${RECORD_PATH}`); - console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart"); - } catch (e) { - fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); - console.error(`[whispergate] install failed; rollback complete: ${String(e)}`); - process.exit(1); - } -} else { - const recFile = env.RECORD_FILE || findLatestInstallRecord(); - if (!recFile || !fs.existsSync(recFile)) { - console.error("[whispergate] no install record found. set RECORD_FILE= to an install record."); - process.exit(1); - } - - fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[whispergate] backup before uninstall: ${BACKUP_PATH}`); - - const rec = readRecord(recFile); - const before = rec.applied || {}; - const target = rec.paths || {}; - - try { - const pluginsNow = getJson("plugins").value || {}; - const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; - plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; - plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; - - if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value; - else delete plugins.load.paths; - - if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value; - else delete plugins.entries.whispergate; - - setJson("plugins", plugins); - - if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value); - else unsetPath(PATH_PROVIDERS); - - const after = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; - writeRecord("uninstall", before, after); - console.log("[whispergate] uninstall ok"); - console.log(`[whispergate] record: ${RECORD_PATH}`); - } catch (e) { - fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); - console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`); - process.exit(1); - } -} diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh deleted file mode 100755 index 8ba5a65..0000000 --- a/scripts/install-whispergate-openclaw.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@" diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 29be6d3..7132cb6 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -3,7 +3,7 @@ import path from "node:path"; const root = process.cwd(); const pluginDir = path.join(root, "plugin"); -const outDir = path.join(root, "dist", "whispergate"); +const outDir = path.join(root, "dist", "dirigent"); fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true }); diff --git a/scripts/render-openclaw-config.mjs b/scripts/render-openclaw-config.mjs index 9a50eb5..c0c39a3 100644 --- a/scripts/render-openclaw-config.mjs +++ b/scripts/render-openclaw-config.mjs @@ -1,13 +1,13 @@ -const pluginPath = process.argv[2] || "/opt/WhisperGate/plugin"; +const pluginPath = process.argv[2] || "/opt/Dirigent/plugin"; const provider = process.argv[3] || "openai"; -const model = process.argv[4] || "whispergate-no-reply-v1"; +const model = process.argv[4] || "dirigent-no-reply-v1"; const bypass = (process.argv[5] || "").split(",").filter(Boolean); const payload = { plugins: { load: { paths: [pluginPath] }, entries: { - whispergate: { + dirigent: { enabled: true, config: { enabled: true, diff --git a/scripts/smoke-no-reply-api.sh b/scripts/smoke-no-reply-api.sh index 87a5ac9..2a2cddb 100755 --- a/scripts/smoke-no-reply-api.sh +++ b/scripts/smoke-no-reply-api.sh @@ -19,14 +19,14 @@ echo "[3] chat/completions" curl -sS -X POST "${BASE_URL}/v1/chat/completions" \ -H 'Content-Type: application/json' \ "${AUTH_HEADER[@]}" \ - -d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \ + -d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \ | sed -n '1,20p' echo "[4] responses" curl -sS -X POST "${BASE_URL}/v1/responses" \ -H 'Content-Type: application/json' \ "${AUTH_HEADER[@]}" \ - -d '{"model":"whispergate-no-reply-v1","input":"hello"}' \ + -d '{"model":"dirigent-no-reply-v1","input":"hello"}' \ | sed -n '1,20p' echo "smoke ok"