From af33d747d9208f0ec4da432d779960eef899f791 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 3 Mar 2026 10:10:27 +0000 Subject: [PATCH] feat: complete Dirigent rename + all TASKLIST items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 1: Identity prompt now includes Discord userId - Task 2: Added configurable schedulingIdentifier (default: ➡️) - Task 3: Moderator handoff uses <@userId>+identifier instead of semantic messages - Task 4: All prompts/comments/help text converted to English - Task 5: Full project rename WhisperGate → Dirigent across all files Breaking: config key changed from plugins.entries.whispergate to plugins.entries.dirigent Breaking: channel policies file renamed to dirigent-channel-policies.json Breaking: tool name changed from whispergate_tools to dirigent_tools --- CHANGELOG.md | 6 +- README.md | 18 +- TASKLIST.md | 47 +- discord-control-api/package.json | 2 +- dist/whispergate/index.ts | 1150 ----------------- docker-compose.yml | 6 +- docs/CONFIG.example.json | 12 +- docs/DISCORD_CONTROL.md | 4 +- docs/IMPLEMENTATION.md | 4 +- docs/INTEGRATION.md | 28 +- docs/PR_SUMMARY.md | 6 +- docs/RELEASE.md | 12 +- docs/ROLLOUT.md | 4 +- docs/RUN_MODES.md | 4 +- docs/TEST_REPORT.md | 16 +- docs/TURN-WAKEUP-PROBLEM.md | 4 +- docs/VERIFY.md | 4 +- no-reply-api/package-lock.json | 4 +- no-reply-api/package.json | 2 +- no-reply-api/server.mjs | 12 +- plugin/README.md | 14 +- plugin/index.ts | 247 ++-- plugin/moderator-presence.ts | 24 +- plugin/openclaw.plugin.json | 11 +- plugin/package.json | 6 +- plugin/rules.ts | 8 +- scripts/dev-up.sh | 8 +- ...claw.mjs => install-dirigent-openclaw.mjs} | 48 +- ...enclaw.sh => install-dirigent-openclaw.sh} | 2 +- scripts/package-plugin.mjs | 2 +- scripts/render-openclaw-config.mjs | 6 +- scripts/smoke-no-reply-api.sh | 4 +- 32 files changed, 291 insertions(+), 1434 deletions(-) delete mode 100644 dist/whispergate/index.ts rename scripts/{install-whispergate-openclaw.mjs => install-dirigent-openclaw.mjs} (79%) rename scripts/{install-whispergate-openclaw.sh => install-dirigent-openclaw.sh} (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6150ee7..799fefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,15 @@ - 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 Dirigent 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..4d9e0a9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# WhisperGate +# Dirigent Rule-based no-reply gate + turn manager for OpenClaw (Discord). ## 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 @@ -27,11 +27,11 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based - **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` --- @@ -67,7 +67,7 @@ Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。 ## Runtime tools & commands -### Tool: `whispergate_tools` +### Tool: `dirigent_tools` Actions: - `policy-get`, `policy-set-channel`, `policy-delete-channel` @@ -77,10 +77,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 ``` --- diff --git a/TASKLIST.md b/TASKLIST.md index d5342bb..9d8b7a7 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -3,32 +3,47 @@ > Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs). ## 1) Identity Prompt Enhancements -- Current prompt only includes agent-id + discord name. -- **Add Discord userId** to identity injection. +- ✅ Added Discord userId to identity injection via `resolveDiscordUserId()`. +- Identity format now: `You are (Discord account: , Discord userId: ).` ## 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`. +- ✅ Added `schedulingIdentifier` config field (default: `➡️`) to `DirigentConfig` and `openclaw.plugin.json`. +- ✅ Updated `buildEndMarkerInstruction()` to explain scheduling identifier semantics to agents: + - The identifier itself is meaningless. + - When receiving `<@USER_ID>` + identifier, check chat history and decide whether to reply. + - If nothing to say, reply `NO_REPLY`. ## 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>➡️`). +- ✅ Moderator no longer sends semantic messages. +- Handoff format is now: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`). ## 4) Prompt Language -- **All prompts must be in English** (including end-marker instructions and group-chat rules). +- ✅ All prompts converted to English: + - `buildEndMarkerInstruction()` — English with scheduling identifier explanation + - `buildAgentIdentity()` — English format + - Slash command help text — English + - Error messages — English + - Code comments — English ## 5) Full Project Rename -- Project name changed to **Dirigent**. -- Update **all strings** across repo: - - plugin name/id - - tool name(s) - - docs, config, scripts, examples - - any text mentions +- ✅ Plugin id: `whispergate` → `dirigent` +- ✅ Plugin name: `WhisperGate` → `Dirigent` +- ✅ Tool name: `whispergate_tools` → `dirigent_tools` +- ✅ Config type: `WhisperGateConfig` → `DirigentConfig` +- ✅ Config lookup key: `entries.whispergate` → `entries.dirigent` +- ✅ Channel policies file: `whispergate-channel-policies.json` → `dirigent-channel-policies.json` +- ✅ Log prefixes: `whispergate:` → `dirigent:` +- ✅ Slash command: `/whispergate` → `/dirigent` +- ✅ Gateway browser/device identifier: `whispergate` → `dirigent` +- ✅ Scripts renamed: `install-whispergate-*` → `install-dirigent-*` +- ✅ All docs, configs, examples updated +- ✅ dist/ folder: `dist/whispergate/` → `dist/dirigent/` +- ✅ package.json names updated +- ✅ README.md, CHANGELOG.md updated +- ✅ Version bumped to 0.2.0 --- ## Open Items / Notes - User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed. +- **Migration note**: Existing deployments need to update their `openclaw.json` config from `plugins.entries.whispergate` → `plugins.entries.dirigent` and rename the channel policies file. 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..07af9eb 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,8 +13,8 @@ "humanList": ["561921120408698910"], "agentList": [], "endSymbols": ["🔚"], - "channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", - "noReplyProvider": "whisper-gateway", + "channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json", + "noReplyProvider": "dirigentway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, "enableWhispergatePolicyTool": true, @@ -29,7 +29,7 @@ }, "models": { "providers": { - "whisper-gateway": { + "dirigentway": { "apiKey": "", "baseUrl": "http://127.0.0.1:8787/v1", "api": "openai-completions", @@ -52,7 +52,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/plugin/README.md b/plugin/README.md index db01bbf..4281bca 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,4 +1,4 @@ -# WhisperGate Plugin +# Dirigent Plugin ## Hook strategy @@ -33,7 +33,7 @@ Optional: - `enableWhispergatePolicyTool` (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` - `bypassUserIds` (deprecated alias of `humanList`) @@ -51,14 +51,14 @@ 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` +## Optional tool: `dirigent_tools` -This plugin registers one unified optional tool: `whispergateway_tools`. +This plugin registers one unified optional tool: `dirigent_tools`. To use it, add tool allowlist entry for either: -- tool name: `whispergateway_tools` -- plugin id: `whispergate` +- tool name: `dirigent_tools` +- plugin id: `dirigent` Supported actions: - Discord: `channel-private-create`, `channel-private-update`, `member-list` diff --git a/plugin/index.ts b/plugin/index.ts index d838633..6df4b4c 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; @@ -31,15 +31,20 @@ const sessionAccountId = new Map(); // 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 +175,33 @@ function pruneDecisionMap(now = Date.now()) { } -function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { +function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig { const root = (api.config as Record) || {}; 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 +218,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 +301,7 @@ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { /** * Build agent identity string for injection into group chat prompts. + * Includes agent name, Discord accountId, and Discord userId. */ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined { const root = (api.config as Record) || {}; @@ -318,9 +326,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 +364,7 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string } /** Get the moderator bot's Discord user ID from its token */ -function getModeratorUserId(config: WhisperGateConfig): string | undefined { +function getModeratorUserId(config: DirigentConfig): string | undefined { if (!config.moderatorBotToken) return undefined; return userIdFromToken(config.moderatorBotToken); } @@ -367,13 +382,13 @@ async function sendModeratorMessage(token: string, channelId: string, content: s }); if (!r.ok) { const text = await r.text(); - logger.warn(`whispergate: moderator send failed (${r.status}): ${text}`); + logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); return false; } - logger.info(`whispergate: moderator message sent to channel=${channelId}`); + logger.info(`dirigent: moderator message sent to channel=${channelId}`); return true; } catch (err) { - logger.warn(`whispergate: moderator send error: ${String(err)}`); + logger.warn(`dirigent: moderator send error: ${String(err)}`); return false; } } @@ -386,7 +401,7 @@ function persistPolicies(api: OpenClawPluginApi): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); - api.logger.info(`whispergate: policy file persisted: ${filePath}`); + api.logger.info(`dirigent: policy file persisted: ${filePath}`); } function pickDefined(input: Record) { @@ -401,7 +416,7 @@ function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { if (!cfg.enableDebugLogs) return false; const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; if (allow.length === 0) return true; - if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景 + if (!channelId) return true; return allow.includes(channelId); } @@ -431,36 +446,37 @@ function debugCtxSummary(ctx: Record, event: 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 +544,14 @@ export default { const text = await r.text(); if (!r.ok) { return { - content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }], + content: [{ type: "text", text: `dirigent_tools discord failed (${r.status}): ${text}` }], isError: true, }; } return { content: [{ type: "text", text }] }; } - if (live.enableWhispergatePolicyTool === false) { + if (live.enableDirigentPolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } @@ -613,9 +629,9 @@ export default { // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake. // Extract the real Discord channel ID from conversationId or event.to. const preChannelId = extractDiscordChannelId(c, e); - const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); + api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } // Turn management on message received @@ -631,7 +647,7 @@ export default { const moderatorUserId = getModeratorUserId(livePre); if (moderatorUserId && from === moderatorUserId) { if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: ignoring moderator message in channel=${preChannelId}`); + api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); } // Don't call onNewMessage — moderator messages are transparent to turn logic } else { @@ -645,18 +661,18 @@ export default { if (isNew) { // Re-initialize turn order with updated channel membership ensureTurnOrder(api, preChannelId); - api.logger.info(`whispergate: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); + api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); } } onNewMessage(preChannelId, senderAccountId, isHuman); if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); + api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); } } } } catch (err) { - api.logger.warn(`whispergate: message hook failed: ${String(err)}`); + api.logger.warn(`dirigent: message hook failed: ${String(err)}`); } }); @@ -664,14 +680,14 @@ export default { const key = ctx.sessionKey; if (!key) return; - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const prompt = ((event as Record).prompt as string) || ""; if (live.enableDebugLogs) { api.logger.info( - `whispergate: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + + `dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + `promptPreview=${prompt.slice(0, 300)}`, ); } @@ -711,7 +727,7 @@ export default { pruneDecisionMap(); if (shouldDebugLog(live, derived.channelId)) { api.logger.info( - `whispergate: debug before_model_resolve recompute session=${key} ` + + `dirigent: debug before_model_resolve recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + @@ -732,7 +748,7 @@ export default { // Forced no-reply - record this session as not allowed to speak sessionAllowed.set(key, false); api.logger.info( - `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, + `dirigent: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, ); return { providerOverride: live.noReplyProvider, @@ -745,7 +761,6 @@ export default { } if (!rec.decision.shouldUseNoReply) { - // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 if (rec.needsRestore) { sessionDecision.delete(key); return { @@ -756,16 +771,14 @@ export default { return; } - // 标记这次执行了 no-reply,下次需要恢复模型 rec.needsRestore = true; sessionDecision.set(key, rec); - // 无论是否有缓存,只要 debug flag 开启就打印决策详情 if (live.enableDebugLogs) { const prompt = ((event as Record).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 +789,7 @@ export default { } api.logger.info( - `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, + `dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, ); return { @@ -789,7 +802,7 @@ export default { const key = ctx.sessionKey; if (!key) return; - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; ensurePolicyStateLoaded(api, live); let rec = sessionDecision.get(key); @@ -810,7 +823,7 @@ export default { rec = { decision, createdAt: Date.now() }; if (shouldDebugLog(live, derived.channelId)) { api.logger.info( - `whispergate: debug before_prompt_build recompute session=${key} ` + + `dirigent: debug before_prompt_build recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + @@ -826,7 +839,7 @@ export default { if (sessionInjected.has(key)) { if (shouldDebugLog(live, undefined)) { api.logger.info( - `whispergate: debug before_prompt_build session=${key} inject skipped (already injected)`, + `dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`, ); } return; @@ -835,7 +848,7 @@ export default { if (!rec.decision.shouldInjectEndMarkerPrompt) { if (shouldDebugLog(live, undefined)) { api.logger.info( - `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, + `dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, ); } return; @@ -846,26 +859,33 @@ export default { const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; - const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat); + const schedulingId = live.schedulingIdentifier || "➡️"; + const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId); - // Inject agent identity for group chats + // Inject agent identity for group chats (includes userId now) let identity = ""; if (isGroupChat && ctx.agentId) { const idStr = buildAgentIdentity(api, ctx.agentId); if (idStr) identity = idStr + "\n\n"; } + // Add scheduling identifier instruction for group chats + let schedulingInstruction = ""; + if (isGroupChat) { + schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); + } + // Mark session as injected (one-time injection) sessionInjected.add(key); - api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { prependContext: identity + instruction }; + api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + return { prependContext: identity + instruction + schedulingInstruction }; }); // Register slash commands for Discord api.registerCommand({ - name: "whispergate", - description: "WhisperGate 频道策略管理", + name: "dirigent", + description: "Dirigent channel policy management", acceptsArgs: true, handler: async (cmdCtx) => { const args = cmdCtx.args || ""; @@ -873,11 +893,11 @@ export default { const subCmd = parts[0] || "help"; if (subCmd === "help") { - return { text: `WhisperGate 命令:\n` + - `/whispergate status - 显示当前频道状态\n` + - `/whispergate turn-status - 显示轮流发言状态\n` + - `/whispergate turn-advance - 手动推进轮流\n` + - `/whispergate turn-reset - 重置轮流顺序` }; + return { text: `Dirigent commands:\n` + + `/dirigent status - Show current channel status\n` + + `/dirigent turn-status - Show turn-based speaking status\n` + + `/dirigent turn-advance - Manually advance turn\n` + + `/dirigent turn-reset - Reset turn order` }; } if (subCmd === "status") { @@ -886,65 +906,52 @@ export default { if (subCmd === "turn-status") { const channelId = cmdCtx.channelId; - if (!channelId) return { text: "无法获取频道ID", isError: true }; + if (!channelId) return { text: "Cannot get channel ID", isError: true }; return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }; } if (subCmd === "turn-advance") { const channelId = cmdCtx.channelId; - if (!channelId) return { text: "无法获取频道ID", isError: true }; + if (!channelId) return { text: "Cannot get channel ID", isError: true }; const next = advanceTurn(channelId); return { text: JSON.stringify({ ok: true, nextSpeaker: next }) }; } if (subCmd === "turn-reset") { const channelId = cmdCtx.channelId; - if (!channelId) return { text: "无法获取频道ID", isError: true }; + if (!channelId) return { text: "Cannot get channel ID", isError: true }; resetTurn(channelId); return { text: JSON.stringify({ ok: true }) }; } - return { text: `未知子命令: ${subCmd}`, isError: true }; + return { text: `Unknown subcommand: ${subCmd}`, isError: true }; }, }); // Handle NO_REPLY detection before message write - // This is where we detect if agent output is NO_REPLY and handle turn advancement - // NOTE: This hook is synchronous, do not use async/await api.on("before_message_write", (event, ctx) => { try { - // Debug: print all available keys in event and ctx api.logger.info( - `whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, + `dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, ); - // before_message_write ctx only has { agentId, sessionKey }. - // Use session mappings populated during before_model_resolve for channelId/accountId. - // Content comes from event.message (AgentMessage). let key = ctx.sessionKey; let channelId: string | undefined; let accountId: string | undefined; - // Get from session mapping (set in before_model_resolve) if (key) { channelId = sessionChannelId.get(key); accountId = sessionAccountId.get(key); } - // Extract content from event.message (AgentMessage) - // Only process assistant messages — before_message_write fires for both - // user (incoming) and assistant (outgoing) messages. Incoming messages may - // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).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 +960,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 +989,76 @@ export default { const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const wasNoReply = isEmpty || isNoReply; - // Log turn state for debugging const turnDebug = getTurnDebugInfo(channelId); api.logger.info( - `whispergate: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, + `dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, ); - // Check if this session was forced no-reply or allowed to speak const wasAllowed = sessionAllowed.get(key); if (wasNoReply) { api.logger.info( - `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, + `dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, ); - if (wasAllowed === undefined) return; // No record, skip + if (wasAllowed === undefined) return; if (wasAllowed === false) { - // Forced no-reply - do not advance turn sessionAllowed.delete(key); api.logger.info( - `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + `dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, ); return; } - // Allowed to speak (current speaker) but chose NO_REPLY - advance turn - ensureTurnOrder(api, channelId, live); + ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, true); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( - `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + `dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); - // If all agents NO_REPLY'd (dormant), don't trigger handoff if (!nextSpeaker) { if (shouldDebugLog(live, channelId)) { api.logger.info( - `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, + `dirigent: before_message_write all agents no-reply, going dormant - no handoff`, ); } return; } - // Trigger moderator handoff message (fire-and-forget, don't await) + // Trigger moderator handoff message using scheduling identifier format if (live.moderatorBotToken) { const nextUserId = resolveDiscordUserId(api, nextSpeaker); if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; + const schedulingId = live.schedulingIdentifier || "➡️"; + const handoffMsg = `<@${nextUserId}>${schedulingId}`; void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); + api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); }); } else { - api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); } } } else if (hasEndSymbol) { - // End symbol detected — advance turn NOW (before message is broadcast to other agents) - // This prevents the race condition where other agents receive the message - // before message_sent fires and advances the turn. - ensureTurnOrder(api, channelId, live); + ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, false); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( - `whispergate: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, + `dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); } else { api.logger.info( - `whispergate: before_message_write no turn action needed session=${key} channel=${channelId}`, + `dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`, ); return; } } catch (err) { - api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`); + api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`); } }); @@ -1074,22 +1069,17 @@ export default { const c = (ctx || {}) as Record; 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 +1087,13 @@ export default { const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined); const content = (event.content as string) || ""; - // Debug log api.logger.info( - `whispergate: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, + `dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, ); if (!channelId || !accountId) return; - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); @@ -1119,7 +1108,7 @@ export default { if (key && sessionTurnHandled.has(key)) { sessionTurnHandled.delete(key); api.logger.info( - `whispergate: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, + `dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, ); return; } @@ -1128,22 +1117,22 @@ export default { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; api.logger.info( - `whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, + `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, ); - // Moderator handoff: when current speaker NO_REPLY'd and there's a next speaker, - // send a handoff message via the moderator bot to trigger the next agent + // Moderator handoff using scheduling identifier format if (wasNoReply && nextSpeaker && live.moderatorBotToken) { const nextUserId = resolveDiscordUserId(api, nextSpeaker); if (nextUserId) { - const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; + const schedulingId = live.schedulingIdentifier || "➡️"; + const handoffMsg = `<@${nextUserId}>${schedulingId}`; sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger); } else { - api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); } } } } catch (err) { - api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); + api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`); } }); }, 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..9cb5b86 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,9 +13,10 @@ "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 }, 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-whispergate-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs similarity index 79% rename from scripts/install-whispergate-openclaw.mjs rename to scripts/install-dirigent-openclaw.mjs index be64876..9f6a951 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-dirigent-openclaw.mjs @@ -6,7 +6,7 @@ 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"); + console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall"); process.exit(2); } const mode = modeArg === "--install" ? "install" : "uninstall"; @@ -14,7 +14,7 @@ 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 PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "dirigent"); 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"; @@ -22,19 +22,19 @@ 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_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 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 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-whispergate-${mode}-${ts}`; -const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`); +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.whispergate"; +const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent"; const PATH_PROVIDERS = "models.providers"; function runOpenclaw(args, { allowFail = false } = {}) { @@ -83,7 +83,7 @@ function findLatestInstallRecord() { if (!fs.existsSync(STATE_DIR)) return ""; const files = fs .readdirSync(STATE_DIR) - .filter((f) => /^whispergate-\d+\.json$/.test(f)) + .filter((f) => /^dirigent-\d+\.json$/.test(f)) .sort() .reverse(); for (const f of files) { @@ -99,18 +99,18 @@ function findLatestInstallRecord() { } if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { - console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`); + console.error(`[dirigent] 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}`); + console.log(`[dirigent] 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}`); + console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); } const before = { @@ -127,7 +127,7 @@ if (mode === "install") { 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 = { + plugins.entries.dirigent = { enabled: true, config: { enabled: true, @@ -169,23 +169,23 @@ if (mode === "install") { [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"); + 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(`[whispergate] install failed; rollback complete: ${String(e)}`); + console.error(`[dirigent] 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."); + console.error("[dirigent] 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}`); + console.log(`[dirigent] backup before uninstall: ${BACKUP_PATH}`); const rec = readRecord(recFile); const before = rec.applied || {}; @@ -200,8 +200,8 @@ if (mode === "install") { 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; + if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.dirigent = target[PATH_PLUGIN_ENTRY].value; + else delete plugins.entries.dirigent; setJson("plugins", plugins); @@ -214,11 +214,11 @@ if (mode === "install") { [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), }; writeRecord("uninstall", before, after); - console.log("[whispergate] uninstall ok"); - console.log(`[whispergate] record: ${RECORD_PATH}`); + console.log("[dirigent] uninstall ok"); + console.log(`[dirigent] record: ${RECORD_PATH}`); } catch (e) { fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); - console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`); + console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`); process.exit(1); } } diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-dirigent-openclaw.sh similarity index 56% rename from scripts/install-whispergate-openclaw.sh rename to scripts/install-dirigent-openclaw.sh index 8ba5a65..84606cd 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-dirigent-openclaw.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@" +exec node "$SCRIPT_DIR/install-dirigent-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"