feat: complete Dirigent rename + all TASKLIST items

- Task 1: Identity prompt now includes Discord userId
- Task 2: Added configurable schedulingIdentifier (default: ➡️)
- Task 3: Moderator handoff uses <@userId>+identifier instead of semantic messages
- Task 4: All prompts/comments/help text converted to English
- Task 5: Full project rename WhisperGate → Dirigent across all files

Breaking: config key changed from plugins.entries.whispergate to plugins.entries.dirigent
Breaking: channel policies file renamed to dirigent-channel-policies.json
Breaking: tool name changed from whispergate_tools to dirigent_tools
This commit is contained in:
zhi
2026-03-03 10:10:27 +00:00
parent 2afb982c04
commit af33d747d9
32 changed files with 291 additions and 1434 deletions

View File

@@ -4,15 +4,15 @@
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) - Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Added optional bearer auth (`AUTH_TOKEN`) - 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 discord-specific 🔚 prompt injection for bypass/end-symbol paths
- Added containerization (`Dockerfile`, `docker-compose.yml`) - Added containerization (`Dockerfile`, `docker-compose.yml`)
- Added helper scripts for smoke/dev lifecycle and rule validation - Added helper scripts for smoke/dev lifecycle and rule validation
- Added no-touch config rendering and integration docs - 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` - supports `--install` / `--uninstall`
- uninstall restores all recorded changes - 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: - Added discord-control-api with:
- `channel-private-create` (create private channel for allowlist) - `channel-private-create` (create private channel for allowlist)
- `channel-private-update` (update allowlist/overwrites for existing channel) - `channel-private-update` (update allowlist/overwrites for existing channel)

View File

@@ -1,10 +1,10 @@
# WhisperGate # Dirigent
Rule-based no-reply gate + turn manager for OpenClaw (Discord). Rule-based no-reply gate + turn manager for OpenClaw (Discord).
## What it does ## 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)** - **Rule gate (before_model_resolve)**
1. Non-Discord → skip 1. Non-Discord → skip
@@ -27,11 +27,11 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
- **Per-channel policy runtime** - **Per-channel policy runtime**
- Policies stored in a standalone JSON file - 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)** - **Discord control actions (optional)**
- Private channel create/update + member list - 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 ## Runtime tools & commands
### Tool: `whispergate_tools` ### Tool: `dirigent_tools`
Actions: Actions:
- `policy-get`, `policy-set-channel`, `policy-delete-channel` - `policy-get`, `policy-set-channel`, `policy-delete-channel`
@@ -77,10 +77,10 @@ Actions:
### Slash command (Discord) ### Slash command (Discord)
``` ```
/whispergate status /dirigent status
/whispergate turn-status /dirigent turn-status
/whispergate turn-advance /dirigent turn-advance
/whispergate turn-reset /dirigent turn-reset
``` ```
--- ---

View File

@@ -3,32 +3,47 @@
> Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs). > Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs).
## 1) Identity Prompt Enhancements ## 1) Identity Prompt Enhancements
- Current prompt only includes agent-id + discord name. - ✅ Added Discord userId to identity injection via `resolveDiscordUserId()`.
- **Add Discord userId** to identity injection. - Identity format now: `You are <name> (Discord account: <accountId>, Discord userId: <userId>).`
## 2) Scheduling Identifier (Default: ➡️) ## 2) Scheduling Identifier (Default: ➡️)
- Add a **configurable scheduling identifier** (default: `➡️`). - Added `schedulingIdentifier` config field (default: `➡️`) to `DirigentConfig` and `openclaw.plugin.json`.
- Update agent prompt to explain: - Updated `buildEndMarkerInstruction()` to explain scheduling identifier semantics to agents:
- The scheduling identifier itself is meaningless. - The identifier itself is meaningless.
- When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply. - When receiving `<@USER_ID>` + identifier, check chat history and decide whether to reply.
- If no reply needed, return `NO_REPLY`. - If nothing to say, reply `NO_REPLY`.
## 3) Moderator Handoff Message Format ## 3) Moderator Handoff Message Format
- Moderator should **no longer send semantic messages** to activate agents. - Moderator no longer sends semantic messages.
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`). - Handoff format is now: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
## 4) Prompt Language ## 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 ## 5) Full Project Rename
- Project name changed to **Dirigent**. - ✅ Plugin id: `whispergate``dirigent`
- Update **all strings** across repo: - ✅ Plugin name: `WhisperGate``Dirigent`
- plugin name/id - ✅ Tool name: `whispergate_tools``dirigent_tools`
- tool name(s) - ✅ Config type: `WhisperGateConfig``DirigentConfig`
- docs, config, scripts, examples - ✅ Config lookup key: `entries.whispergate``entries.dirigent`
- any text mentions - ✅ 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 ## 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. - 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.

View File

@@ -1,5 +1,5 @@
{ {
"name": "whispergate-discord-control-api", "name": "dirigent-discord-control-api",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
services: services:
whispergate-no-reply-api: dirigent-no-reply-api:
build: build:
context: ./no-reply-api context: ./no-reply-api
container_name: whispergate-no-reply-api container_name: dirigent-no-reply-api
ports: ports:
- "8787:8787" - "8787:8787"
environment: environment:
- PORT=8787 - PORT=8787
- NO_REPLY_MODEL=whispergate-no-reply-v1 - NO_REPLY_MODEL=dirigent-no-reply-v1
restart: unless-stopped restart: unless-stopped

View File

@@ -1,10 +1,10 @@
{ {
"plugins": { "plugins": {
"load": { "load": {
"paths": ["/path/to/WhisperGate/dist/whispergate"] "paths": ["/path/to/Dirigent/dist/dirigent"]
}, },
"entries": { "entries": {
"whispergate": { "dirigent": {
"enabled": true, "enabled": true,
"config": { "config": {
"enabled": true, "enabled": true,
@@ -13,8 +13,8 @@
"humanList": ["561921120408698910"], "humanList": ["561921120408698910"],
"agentList": [], "agentList": [],
"endSymbols": ["🔚"], "endSymbols": ["🔚"],
"channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", "channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
"noReplyProvider": "whisper-gateway", "noReplyProvider": "dirigentway",
"noReplyModel": "no-reply", "noReplyModel": "no-reply",
"enableDiscordControlTool": true, "enableDiscordControlTool": true,
"enableWhispergatePolicyTool": true, "enableWhispergatePolicyTool": true,
@@ -29,7 +29,7 @@
}, },
"models": { "models": {
"providers": { "providers": {
"whisper-gateway": { "dirigentway": {
"apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>", "apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>",
"baseUrl": "http://127.0.0.1:8787/v1", "baseUrl": "http://127.0.0.1:8787/v1",
"api": "openai-completions", "api": "openai-completions",
@@ -52,7 +52,7 @@
{ {
"id": "main", "id": "main",
"tools": { "tools": {
"allow": ["whispergate"] "allow": ["dirigent"]
} }
} }
] ]

View File

@@ -2,8 +2,8 @@
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: 目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl > 现在可以通过 Dirigent 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl
> 注意:该工具是 optional需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。 > 注意:该工具是 optional需要在 agent tools allowlist 中显式允许(例如允许 `dirigent` 或 `discord_control`)。
1. 创建指定名单可见的私人频道 1. 创建指定名单可见的私人频道
2. 查看 server 成员列表(分页) 2. 查看 server 成员列表(分页)

View File

@@ -1,8 +1,8 @@
# WhisperGate Implementation Notes # Dirigent Implementation Notes
## Decision path ## Decision path
WhisperGate evaluates in strict order: Dirigent evaluates in strict order:
1. channel check (discord-only) 1. channel check (discord-only)
2. bypass sender check 2. bypass sender check

View File

@@ -1,4 +1,4 @@
# WhisperGate Integration (No-touch Template) # Dirigent Integration (No-touch Template)
This guide **does not** change your current OpenClaw config automatically. This guide **does not** change your current OpenClaw config automatically.
It only generates a JSON snippet you can review. It only generates a JSON snippet you can review.
@@ -7,9 +7,9 @@ It only generates a JSON snippet you can review.
```bash ```bash
node scripts/render-openclaw-config.mjs \ node scripts/render-openclaw-config.mjs \
/absolute/path/to/WhisperGate/plugin \ /absolute/path/to/Dirigent/plugin \
openai \ openai \
whispergate-no-reply-v1 \ dirigent-no-reply-v1 \
561921120408698910 561921120408698910
``` ```
@@ -23,7 +23,7 @@ Arguments:
The script prints JSON for: The script prints JSON for:
- `plugins.load.paths` - `plugins.load.paths`
- `plugins.entries.whispergate.config` - `plugins.entries.dirigent.config`
You can merge this snippet manually into your `openclaw.json`. 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): For production-like install with automatic rollback on error (Node-only installer):
```bash ```bash
node ./scripts/install-whispergate-openclaw.mjs --install node ./scripts/install-dirigent-openclaw.mjs --install
# or wrapper # or wrapper
./scripts/install-whispergate-openclaw.sh --install ./scripts/install-dirigent-openclaw.sh --install
``` ```
Uninstall (revert all recorded config changes): Uninstall (revert all recorded config changes):
```bash ```bash
node ./scripts/install-whispergate-openclaw.mjs --uninstall node ./scripts/install-dirigent-openclaw.mjs --uninstall
# or wrapper # or wrapper
./scripts/install-whispergate-openclaw.sh --uninstall ./scripts/install-dirigent-openclaw.sh --uninstall
# or specify a record explicitly # or specify a record explicitly
# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \ # RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \
# node ./scripts/install-whispergate-openclaw.mjs --uninstall # node ./scripts/install-dirigent-openclaw.mjs --uninstall
``` ```
Environment overrides: Environment overrides:
@@ -66,15 +66,15 @@ The script:
- writes via `openclaw config set ... --json` - writes via `openclaw config set ... --json`
- creates config backup first - creates config backup first
- restores backup automatically if any install step fails - 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: - writes a change record for every install/uninstall:
- directory: `~/.openclaw/whispergate-install-records/` - directory: `~/.openclaw/dirigent-install-records/`
- latest pointer: `~/.openclaw/whispergate-install-record-latest.json` - latest pointer: `~/.openclaw/dirigent-install-record-latest.json`
Policy state semantics: Policy state semantics:
- channel policy file is loaded once into memory on startup - channel policy file is loaded once into memory on startup
- runtime decisions use in-memory state - 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 - manual file edits do not auto-apply until next restart
## Notes ## Notes

View File

@@ -1,15 +1,15 @@
# PR Summary (WhisperGate + Discord Control) # PR Summary (Dirigent + Discord Control)
## Scope ## Scope
This PR delivers two tracks: 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 2. Discord control extension API for private-channel/member-list gaps
## Delivered Features ## Delivered Features
### WhisperGate ### Dirigent
- Deterministic rule chain: - Deterministic rule chain:
1) non-discord => skip 1) non-discord => skip

View File

@@ -8,17 +8,17 @@ node scripts/package-plugin.mjs
Output: Output:
- `dist/whispergate/index.ts` - `dist/dirigent/index.ts`
- `dist/whispergate/rules.ts` - `dist/dirigent/rules.ts`
- `dist/whispergate/openclaw.plugin.json` - `dist/dirigent/openclaw.plugin.json`
- `dist/whispergate/README.md` - `dist/dirigent/README.md`
- `dist/whispergate/package.json` - `dist/dirigent/package.json`
## Use packaged plugin path ## Use packaged plugin path
Point OpenClaw `plugins.load.paths` to: Point OpenClaw `plugins.load.paths` to:
`/absolute/path/to/WhisperGate/dist/whispergate` `/absolute/path/to/Dirigent/dist/dirigent`
## Verify package completeness ## Verify package completeness

View File

@@ -1,4 +1,4 @@
# WhisperGate Rollout Checklist # Dirigent Rollout Checklist
## Stage 0: Local sanity ## Stage 0: Local sanity
@@ -29,5 +29,5 @@
## Rollback ## 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 - Keep API service running; it is inert when plugin disabled

View File

@@ -1,6 +1,6 @@
# Run Modes # Run Modes
WhisperGate has two runtime components: Dirigent has two runtime components:
1. `plugin/` (OpenClaw plugin) 1. `plugin/` (OpenClaw plugin)
2. `no-reply-api/` (deterministic NO_REPLY service) 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 ```bash
./scripts/dev-up.sh ./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: Stop:

View File

@@ -1,4 +1,4 @@
# WhisperGate 测试记录报告(阶段性) # Dirigent 测试记录报告(阶段性)
日期2026-02-25 日期2026-02-25
@@ -6,19 +6,19 @@
本轮覆盖: 本轮覆盖:
1. WhisperGate 基础静态与脚本测试 1. Dirigent 基础静态与脚本测试
2. no-reply-api 隔离集成测试 2. no-reply-api 隔离集成测试
3. discord-control-api 功能测试dryRun + 实操) 3. discord-control-api 功能测试dryRun + 实操)
未覆盖: 未覆盖:
- WhisperGate 插件真实挂载 OpenClaw 后的端到端E2E - Dirigent 插件真实挂载 OpenClaw 后的端到端E2E
--- ---
## 二、测试环境 ## 二、测试环境
- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate` - 代码仓库:`/root/.openclaw/workspace-operator/Dirigent`
- OpenClaw 配置来源:本机已有配置(读取 Discord token - OpenClaw 配置来源:本机已有配置(读取 Discord token
- Discord guildserverID`1368531017534537779` - Discord guildserverID`1368531017534537779`
- allowlist user IDs - 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) 回归测试 ### 2) 回归测试
- discord-control-api 引入后,不影响 WhisperGate 原有流程 - discord-control-api 引入后,不影响 Dirigent 原有流程
- 规则校验脚本在最新代码继续稳定通过 - 规则校验脚本在最新代码继续稳定通过
### 3) 运行与安全校验 ### 3) 运行与安全校验
@@ -127,4 +127,4 @@ make check check-rules test-api
## 六、当前结论 ## 六、当前结论
- 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。 - 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。
- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。 - 项目剩余主要测试工作集中在 Dirigent 插件与 OpenClaw 的真实 E2E 联调。

View File

@@ -2,7 +2,7 @@
## Context ## 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 ## 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 1. A message arrives in the Discord channel
2. OpenClaw routes it to **all** agent sessions in that channel simultaneously 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 - Current speaker → allowed to process
- Everyone else → forced to no-reply model (message is "consumed" silently) - Everyone else → forced to no-reply model (message is "consumed" silently)
4. Current speaker processes the message and returns NO_REPLY 4. Current speaker processes the message and returns NO_REPLY

View File

@@ -1,4 +1,4 @@
# WhisperGate Quick Verification # Dirigent Quick Verification
## 1) Start no-reply API ## 1) Start no-reply API
@@ -15,7 +15,7 @@ npm start
curl -sS http://127.0.0.1:8787/health curl -sS http://127.0.0.1:8787/health
curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \ curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \
-H 'Content-Type: application/json' \ -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: Or run bundled smoke check:

View File

@@ -1,11 +1,11 @@
{ {
"name": "whispergate-no-reply-api", "name": "dirigent-no-reply-api",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "whispergate-no-reply-api", "name": "dirigent-no-reply-api",
"version": "0.1.0" "version": "0.1.0"
} }
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "whispergate-no-reply-api", "name": "dirigent-no-reply-api",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -1,7 +1,7 @@
import http from "node:http"; import http from "node:http";
const port = Number(process.env.PORT || 8787); 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 || ""; const authToken = process.env.AUTH_TOKEN || "";
function sendJson(res, status, payload) { function sendJson(res, status, payload) {
@@ -17,7 +17,7 @@ function isAuthorized(req) {
function noReplyChatCompletion(reqBody) { function noReplyChatCompletion(reqBody) {
return { return {
id: `chatcmpl_whispergate_${Date.now()}`, id: `chatcmpl_dirigent_${Date.now()}`,
object: "chat.completion", object: "chat.completion",
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName, model: reqBody?.model || modelName,
@@ -34,7 +34,7 @@ function noReplyChatCompletion(reqBody) {
function noReplyResponses(reqBody) { function noReplyResponses(reqBody) {
return { return {
id: `resp_whispergate_${Date.now()}`, id: `resp_dirigent_${Date.now()}`,
object: "response", object: "response",
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || modelName, model: reqBody?.model || modelName,
@@ -57,7 +57,7 @@ function listModels() {
id: modelName, id: modelName,
object: "model", object: "model",
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
owned_by: "whispergate" owned_by: "dirigent"
} }
] ]
}; };
@@ -65,7 +65,7 @@ function listModels() {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") { 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") { if (req.method === "GET" && req.url === "/v1/models") {
@@ -108,5 +108,5 @@ const server = http.createServer((req, res) => {
}); });
server.listen(port, () => { server.listen(port, () => {
console.log(`[whispergate-no-reply-api] listening on :${port}`); console.log(`[dirigent-no-reply-api] listening on :${port}`);
}); });

View File

@@ -1,4 +1,4 @@
# WhisperGate Plugin # Dirigent Plugin
## Hook strategy ## Hook strategy
@@ -33,7 +33,7 @@ Optional:
- `enableWhispergatePolicyTool` (default true) - `enableWhispergatePolicyTool` (default true)
Unified optional tool: Unified optional tool:
- `whispergateway_tools` - `dirigent_tools`
- Discord actions: `channel-private-create`, `channel-private-update`, `member-list` - Discord actions: `channel-private-create`, `channel-private-update`, `member-list`
- Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel` - Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel`
- `bypassUserIds` (deprecated alias of `humanList`) - `bypassUserIds` (deprecated alias of `humanList`)
@@ -51,14 +51,14 @@ Policy file behavior:
- loaded once on startup into memory - loaded once on startup into memory
- runtime decisions read memory state only - runtime decisions read memory state only
- direct file edits do NOT affect memory state - 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: To use it, add tool allowlist entry for either:
- tool name: `whispergateway_tools` - tool name: `dirigent_tools`
- plugin id: `whispergate` - plugin id: `dirigent`
Supported actions: Supported actions:
- Discord: `channel-private-create`, `channel-private-update`, `member-list` - Discord: `channel-private-create`, `channel-private-update`, `member-list`

View File

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

View File

@@ -94,7 +94,7 @@ function connect(token: string, logger: Logger, isResume = false) {
try { try {
ws = new WebSocket(url); ws = new WebSocket(url);
} catch (err) { } catch (err) {
logger.warn(`whispergate: moderator ws constructor failed: ${String(err)}`); logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`);
scheduleReconnect(token, logger, false); scheduleReconnect(token, logger, false);
return; return;
} }
@@ -119,8 +119,8 @@ function connect(token: string, logger: Logger, isResume = false) {
intents: 0, intents: 0,
properties: { properties: {
os: "linux", os: "linux",
browser: "whispergate", browser: "dirigent",
device: "whispergate", device: "dirigent",
}, },
presence: { presence: {
status: "online", status: "online",
@@ -154,19 +154,19 @@ function connect(token: string, logger: Logger, isResume = false) {
if (t === "READY") { if (t === "READY") {
sessionId = d.session_id; sessionId = d.session_id;
resumeUrl = d.resume_gateway_url; resumeUrl = d.resume_gateway_url;
logger.info("whispergate: moderator bot connected and online"); logger.info("dirigent: moderator bot connected and online");
} }
if (t === "RESUMED") { if (t === "RESUMED") {
logger.info("whispergate: moderator bot resumed"); logger.info("dirigent: moderator bot resumed");
} }
break; break;
case 7: // Reconnect request case 7: // Reconnect request
logger.info("whispergate: moderator bot reconnect requested by Discord"); logger.info("dirigent: moderator bot reconnect requested by Discord");
cleanup(); cleanup();
scheduleReconnect(token, logger, true); scheduleReconnect(token, logger, true);
break; break;
case 9: // Invalid Session case 9: // Invalid Session
logger.warn(`whispergate: moderator bot invalid session, resumable=${d}`); logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`);
cleanup(); cleanup();
sessionId = d ? sessionId : null; sessionId = d ? sessionId : null;
// Wait longer before re-identifying // Wait longer before re-identifying
@@ -189,18 +189,18 @@ function connect(token: string, logger: Logger, isResume = false) {
// Non-recoverable codes — stop reconnecting // Non-recoverable codes — stop reconnecting
if (code === 4004) { if (code === 4004) {
logger.warn("whispergate: moderator bot token invalid (4004), stopping"); logger.warn("dirigent: moderator bot token invalid (4004), stopping");
started = false; started = false;
return; return;
} }
if (code === 4010 || code === 4011 || code === 4013 || code === 4014) { 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; sessionId = null;
scheduleReconnect(token, logger, false); scheduleReconnect(token, logger, false);
return; 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; const canResume = !!sessionId && code !== 4012;
scheduleReconnect(token, logger, canResume); scheduleReconnect(token, logger, canResume);
}; };
@@ -220,7 +220,7 @@ function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
const jitter = Math.random() * 1000; const jitter = Math.random() * 1000;
const delay = baseDelay + jitter; 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 = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
@@ -234,7 +234,7 @@ function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
*/ */
export function startModeratorPresence(token: string, logger: Logger): void { export function startModeratorPresence(token: string, logger: Logger): void {
if (started) { if (started) {
logger.info("whispergate: moderator presence already started, skipping"); logger.info("dirigent: moderator presence already started, skipping");
return; return;
} }
started = true; started = true;

View File

@@ -1,8 +1,8 @@
{ {
"id": "whispergate", "id": "dirigent",
"name": "WhisperGate", "name": "Dirigent",
"version": "0.1.0", "version": "0.2.0",
"description": "Rule-based no-reply gate with provider/model override", "description": "Rule-based no-reply gate with provider/model override and turn management",
"entry": "./index.ts", "entry": "./index.ts",
"configSchema": { "configSchema": {
"type": "object", "type": "object",
@@ -13,9 +13,10 @@
"listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" }, "listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" },
"humanList": { "type": "array", "items": { "type": "string" }, "default": [] }, "humanList": { "type": "array", "items": { "type": "string" }, "default": [] },
"agentList": { "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": [] }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
"schedulingIdentifier": { "type": "string", "default": "➡️" },
"noReplyProvider": { "type": "string" }, "noReplyProvider": { "type": "string" },
"noReplyModel": { "type": "string" }, "noReplyModel": { "type": "string" },
"enableDiscordControlTool": { "type": "boolean", "default": true }, "enableDiscordControlTool": { "type": "boolean", "default": true },

View File

@@ -1,9 +1,9 @@
{ {
"name": "whispergate-plugin", "name": "dirigent-plugin",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "WhisperGate OpenClaw plugin", "description": "Dirigent OpenClaw plugin",
"scripts": { "scripts": {
"check": "node ../scripts/check-plugin-files.mjs", "check": "node ../scripts/check-plugin-files.mjs",
"check:rules": "node ../scripts/validate-rules.mjs" "check:rules": "node ../scripts/validate-rules.mjs"

View File

@@ -1,4 +1,4 @@
export type WhisperGateConfig = { export type DirigentConfig = {
enabled?: boolean; enabled?: boolean;
discordOnly?: boolean; discordOnly?: boolean;
listMode?: "human-list" | "agent-list"; listMode?: "human-list" | "agent-list";
@@ -8,6 +8,8 @@ export type WhisperGateConfig = {
// backward compatibility // backward compatibility
bypassUserIds?: string[]; bypassUserIds?: string[];
endSymbols?: string[]; endSymbols?: string[];
/** Scheduling identifier sent by moderator to activate agents (default: ➡️) */
schedulingIdentifier?: string;
noReplyProvider: string; noReplyProvider: string;
noReplyModel: string; noReplyModel: string;
/** Discord bot token for the moderator bot (used for turn handoff messages) */ /** 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] || ""; return chars[chars.length - 1] || "";
} }
export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record<string, ChannelPolicy>) { export function resolvePolicy(config: DirigentConfig, channelId?: string, channelPolicies?: Record<string, ChannelPolicy>) {
const globalMode = config.listMode || "human-list"; const globalMode = config.listMode || "human-list";
const globalHuman = config.humanList || config.bypassUserIds || []; const globalHuman = config.humanList || config.bypassUserIds || [];
const globalAgent = config.agentList || []; const globalAgent = config.agentList || [];
@@ -76,7 +78,7 @@ export function resolvePolicy(config: WhisperGateConfig, channelId?: string, cha
} }
export function evaluateDecision(params: { export function evaluateDecision(params: {
config: WhisperGateConfig; config: DirigentConfig;
channel?: string; channel?: string;
channelId?: string; channelId?: string;
channelPolicies?: Record<string, ChannelPolicy>; channelPolicies?: Record<string, ChannelPolicy>;

View File

@@ -4,10 +4,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
echo "[whispergate] building/starting no-reply API container" echo "[dirigent] building/starting no-reply API container"
docker compose up -d --build whispergate-no-reply-api 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 curl -sS http://127.0.0.1:8787/health
echo "[whispergate] done" echo "[dirigent] done"

View File

@@ -6,7 +6,7 @@ import { execFileSync } from "node:child_process";
const modeArg = process.argv[2]; const modeArg = process.argv[2];
if (modeArg !== "--install" && modeArg !== "--uninstall") { 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); process.exit(2);
} }
const mode = modeArg === "--install" ? "install" : "uninstall"; const mode = modeArg === "--install" ? "install" : "uninstall";
@@ -14,7 +14,7 @@ const mode = modeArg === "--install" ? "install" : "uninstall";
const env = process.env; const env = process.env;
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); 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 __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_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway";
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; const NO_REPLY_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 LIST_MODE = env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; 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 CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").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/whispergate-install-record-latest.json").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 ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`; const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`); const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths"; 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"; const PATH_PROVIDERS = "models.providers";
function runOpenclaw(args, { allowFail = false } = {}) { function runOpenclaw(args, { allowFail = false } = {}) {
@@ -83,7 +83,7 @@ function findLatestInstallRecord() {
if (!fs.existsSync(STATE_DIR)) return ""; if (!fs.existsSync(STATE_DIR)) return "";
const files = fs const files = fs
.readdirSync(STATE_DIR) .readdirSync(STATE_DIR)
.filter((f) => /^whispergate-\d+\.json$/.test(f)) .filter((f) => /^dirigent-\d+\.json$/.test(f))
.sort() .sort()
.reverse(); .reverse();
for (const f of files) { for (const f of files) {
@@ -99,18 +99,18 @@ function findLatestInstallRecord() {
} }
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { 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); process.exit(1);
} }
if (mode === "install") { if (mode === "install") {
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); 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)) { if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); 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 = { const before = {
@@ -127,7 +127,7 @@ if (mode === "install") {
if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH); if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH);
plugins.load.paths = paths; plugins.load.paths = paths;
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
plugins.entries.whispergate = { plugins.entries.dirigent = {
enabled: true, enabled: true,
config: { config: {
enabled: true, enabled: true,
@@ -169,23 +169,23 @@ if (mode === "install") {
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS), [PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
}; };
writeRecord("install", before, after); writeRecord("install", before, after);
console.log("[whispergate] install ok (config written)"); console.log("[dirigent] install ok (config written)");
console.log(`[whispergate] record: ${RECORD_PATH}`); console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart"); console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) { } catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); 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); process.exit(1);
} }
} else { } else {
const recFile = env.RECORD_FILE || findLatestInstallRecord(); const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) { if (!recFile || !fs.existsSync(recFile)) {
console.error("[whispergate] no install record found. set RECORD_FILE=<path> to an install record."); console.error("[dirigent] no install record found. set RECORD_FILE=<path> to an install record.");
process.exit(1); process.exit(1);
} }
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); 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 rec = readRecord(recFile);
const before = rec.applied || {}; 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; if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value;
else delete plugins.load.paths; else delete plugins.load.paths;
if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value; if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.dirigent = target[PATH_PLUGIN_ENTRY].value;
else delete plugins.entries.whispergate; else delete plugins.entries.dirigent;
setJson("plugins", plugins); setJson("plugins", plugins);
@@ -214,11 +214,11 @@ if (mode === "install") {
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS), [PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
}; };
writeRecord("uninstall", before, after); writeRecord("uninstall", before, after);
console.log("[whispergate] uninstall ok"); console.log("[dirigent] uninstall ok");
console.log(`[whispergate] record: ${RECORD_PATH}`); console.log(`[dirigent] record: ${RECORD_PATH}`);
} catch (e) { } catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); 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); process.exit(1);
} }
} }

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@" exec node "$SCRIPT_DIR/install-dirigent-openclaw.mjs" "$@"

View File

@@ -3,7 +3,7 @@ import path from "node:path";
const root = process.cwd(); const root = process.cwd();
const pluginDir = path.join(root, "plugin"); 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.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });

View File

@@ -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 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 bypass = (process.argv[5] || "").split(",").filter(Boolean);
const payload = { const payload = {
plugins: { plugins: {
load: { paths: [pluginPath] }, load: { paths: [pluginPath] },
entries: { entries: {
whispergate: { dirigent: {
enabled: true, enabled: true,
config: { config: {
enabled: true, enabled: true,

View File

@@ -19,14 +19,14 @@ echo "[3] chat/completions"
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \ curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \ "${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' | sed -n '1,20p'
echo "[4] responses" echo "[4] responses"
curl -sS -X POST "${BASE_URL}/v1/responses" \ curl -sS -X POST "${BASE_URL}/v1/responses" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \ "${AUTH_HEADER[@]}" \
-d '{"model":"whispergate-no-reply-v1","input":"hello"}' \ -d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
| sed -n '1,20p' | sed -n '1,20p'
echo "smoke ok" echo "smoke ok"