dev/dirigent-tasks #11

Merged
hzhang merged 8 commits from dev/dirigent-tasks into main 2026-03-03 16:54:44 +00:00
34 changed files with 740 additions and 1649 deletions

View File

@@ -1,18 +1,29 @@
# Changelog # Changelog
## 0.2.0
- **Project renamed from WhisperGate to Dirigent**
- All plugin ids, tool names, config keys, file paths, docs updated
- Legacy `whispergate` config key still supported as fallback
- **Identity prompt enhancements**: Discord userId now included in agent identity injection
- **Scheduling identifier**: Added configurable `schedulingIdentifier` (default: `➡️`)
- Moderator handoff now sends `<@USER_ID>➡️` instead of semantic messages
- Agent prompt explains the identifier is meaningless — check chat history and decide
- **All prompts in English**: End-marker instructions, group chat rules, slash command help text
## 0.1.0-mvp ## 0.1.0-mvp
- 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 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,12 @@
# WhisperGate # Dirigent
Rule-based no-reply gate + turn manager for OpenClaw (Discord). Rule-based no-reply gate + turn manager for OpenClaw (Discord).
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
## 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
@@ -13,8 +15,13 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
4. Otherwise → route to no-reply model/provider 4. Otherwise → route to no-reply model/provider
- **End-symbol enforcement** - **End-symbol enforcement**
- Injects instruction like: `你的这次发言必须以🔚作为结尾…` - Injects instruction: `Your response MUST end with 🔚…`
- In group chats, also injects: “无关/不需要回应就 NO_REPLY - In group chats, also injects: "If not relevant, reply NO_REPLY"
- **Scheduling identifier (moderator handoff)**
- Configurable identifier (default: `➡️`) used by the moderator bot
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic, just a scheduling signal)
- Agent receives instruction explaining the identifier is meaningless — check chat history and decide
- **Turn-based speaking (multi-bot)** - **Turn-based speaking (multi-bot)**
- Only the current speaker is allowed to respond - Only the current speaker is allowed to respond
@@ -22,16 +29,16 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
- Turn advances on **end-symbol** or **NO_REPLY** - Turn advances on **end-symbol** or **NO_REPLY**
- If all bots NO_REPLY, channel becomes **dormant** until a new human message - If all bots NO_REPLY, channel becomes **dormant** until a new human message
- **Moderator handoff (optional)** - **Agent identity injection**
- When the current speaker NO_REPLYs, a moderator bot can post a handoff message to wake the next speaker - Injects agent name, Discord accountId, and Discord userId into group chat prompts
- **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`
--- ---
@@ -39,7 +46,7 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence) - `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY` - `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
- `discord-control-api/` — Discord 管理扩展 API私密频道 + 成员列表) - `discord-control-api/` — Discord admin extension API (private channels + member list)
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis - `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
- `scripts/` — smoke/dev/helper checks - `scripts/` — smoke/dev/helper checks
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`) - `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
@@ -61,13 +68,13 @@ node scripts/render-openclaw-config.mjs
``` ```
See `docs/RUN_MODES.md` for Docker mode. See `docs/RUN_MODES.md` for Docker mode.
Discord 扩展能力见:`docs/DISCORD_CONTROL.md` Discord extension capabilities: `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 +84,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
``` ```
--- ---
@@ -92,6 +99,7 @@ Common options (see `docs/INTEGRATION.md`):
- `listMode`: `human-list` or `agent-list` - `listMode`: `human-list` or `agent-list`
- `humanList`, `agentList` - `humanList`, `agentList`
- `endSymbols` - `endSymbols`
- `schedulingIdentifier` (default `➡️`)
- `channelPoliciesFile` (per-channel overrides) - `channelPoliciesFile` (per-channel overrides)
- `moderatorBotToken` (handoff messages) - `moderatorBotToken` (handoff messages)
- `enableDebugLogs`, `debugLogChannelIds` - `enableDebugLogs`, `debugLogChannelIds`

View File

@@ -2,31 +2,40 @@
> 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. - Current prompt only includes agent-id + discord name.
- **Add Discord userId** to identity injection. - **Add Discord userId** to identity injection.
- **Done**: `buildAgentIdentity()` now resolves and includes Discord userId via `resolveDiscordUserId()`.
## 2) Scheduling Identifier (Default: ➡️) ## 2) Scheduling Identifier (Default: ➡️)
- Add a **configurable scheduling identifier** (default: `➡️`). - Add a **configurable scheduling identifier** (default: `➡️`).
- Update agent prompt to explain: - Update agent prompt to explain:
- The scheduling identifier itself is meaningless. - The scheduling 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>` + scheduling identifier, the agent should check chat history and decide whether to reply.
- If no reply needed, return `NO_REPLY`. - If no reply needed, return `NO_REPLY`.
- **Done**: Added `schedulingIdentifier` config field; `buildSchedulingIdentifierInstruction()` injected for group chats.
## 3) Moderator Handoff Message Format ## 3) Moderator Handoff Message Format
- Moderator should **no longer send semantic messages** to activate agents. - Moderator should **no longer send semantic messages** to activate agents.
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`). - Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
- **Done**: Both `before_message_write` and `message_sent` handoff messages now use `<@userId>` + scheduling identifier format.
## 4) Prompt Language ## 4) Prompt Language
- **All prompts must be in English** (including end-marker instructions and group-chat rules). - **All prompts must be in English** (including end-marker instructions and group-chat rules).
- **Done**: `buildEndMarkerInstruction()` and `buildSchedulingIdentifierInstruction()` output English. Slash command help text in English.
## 5) Full Project Rename ## 5) Full Project Rename
- Project name changed to **Dirigent**. - Project name changed to **Dirigent**.
- Update **all strings** across repo: - Update **all strings** across repo:
- plugin name/id - plugin name/id`dirigent`
- tool name(s) - tool name`dirigent_tools`
- slash command → `/dirigent`
- docs, config, scripts, examples - docs, config, scripts, examples
- any text mentions - any text mentions
- dist output dir → `dist/dirigent`
- docker service → `dirigent-no-reply-api`
- config key fallback: still reads legacy `whispergate` entry if `dirigent` not found
- **Done**: All files updated.
--- ---

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,11 +13,12 @@
"humanList": ["561921120408698910"], "humanList": ["561921120408698910"],
"agentList": [], "agentList": [],
"endSymbols": ["🔚"], "endSymbols": ["🔚"],
"channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", "schedulingIdentifier": "➡️",
"noReplyProvider": "whisper-gateway", "channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
"noReplyProvider": "dirigentway",
"noReplyModel": "no-reply", "noReplyModel": "no-reply",
"enableDiscordControlTool": true, "enableDiscordControlTool": true,
"enableWhispergatePolicyTool": true, "enableDirigentPolicyTool": true,
"enableDebugLogs": false, "enableDebugLogs": false,
"debugLogChannelIds": [], "debugLogChannelIds": [],
"discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiBaseUrl": "http://127.0.0.1:8790",
@@ -29,7 +30,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 +53,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}`);
}); });

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@hangman-lab/dirigent",
"version": "0.2.0",
"description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw",
"type": "module",
"files": [
"dist/",
"plugin/",
"no-reply-api/",
"discord-control-api/",
"docs/",
"scripts/install-dirigent-openclaw.mjs",
"docker-compose.yml",
"Makefile",
"README.md",
"CHANGELOG.md",
"TASKLIST.md"
],
"scripts": {
"prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/",
"postinstall": "node scripts/install-dirigent-openclaw.mjs --install",
"uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall"
},
"keywords": [
"openclaw",
"plugin",
"discord",
"moderation",
"turn-management"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.hangman-lab.top/nav/Dirigent.git"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -1,12 +1,10 @@
# WhisperGate Plugin # Dirigent Plugin
## Hook strategy ## Hook strategy
- `message:received` caches a per-session decision from deterministic rules. - `message:received` caches a per-session decision from deterministic rules.
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply. - `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is: - `before_prompt_build` prepends end-marker instruction + scheduling identifier instruction when decision allows speaking.
- `bypass_sender`
- `end_symbol:*`
## Rules (in order) ## Rules (in order)
@@ -30,12 +28,14 @@ Optional:
- `humanList` (default []) - `humanList` (default [])
- `agentList` (default []) - `agentList` (default [])
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file) - `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
- `enableWhispergatePolicyTool` (default true) - `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
- `enableDirigentPolicyTool` (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`
- Turn actions: `turn-status`, `turn-advance`, `turn-reset`
- `bypassUserIds` (deprecated alias of `humanList`) - `bypassUserIds` (deprecated alias of `humanList`)
- `endSymbols` (default ["🔚"]) - `endSymbols` (default ["🔚"])
- `enableDiscordControlTool` (default true) - `enableDiscordControlTool` (default true)
@@ -51,20 +51,23 @@ 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` ## Moderator handoff format
This plugin registers one unified optional tool: `whispergateway_tools`. When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡️`
To use it, add tool allowlist entry for either:
- tool name: `whispergateway_tools`
- plugin id: `whispergate`
Supported actions: This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak.
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel` ## Slash command (Discord)
```
/dirigent status
/dirigent turn-status
/dirigent turn-advance
/dirigent turn-reset
```
Debug logging: Debug logging:
- set `enableDebugLogs: true` to emit detailed hook diagnostics - set `enableDebugLogs: true` to emit detailed hook diagnostics
- optionally set `debugLogChannelIds` to only log selected channel IDs - optionally set `debugLogChannelIds` to only log selected channel IDs
- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build`

View File

@@ -1,10 +1,50 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
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";
// ── No-Reply API child process lifecycle ──────────────────────────────
let noReplyProcess: ChildProcess | null = null;
function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void {
if (noReplyProcess) {
logger.info("dirigent: no-reply API already running, skipping");
return;
}
const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs");
if (!fs.existsSync(serverPath)) {
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
return;
}
noReplyProcess = spawn(process.execPath, [serverPath], {
env: { ...process.env, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
noReplyProcess.on("exit", (code, signal) => {
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
noReplyProcess = null;
});
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
}
function stopNoReplyApi(logger: { info: (m: string) => void }): void {
if (!noReplyProcess) return;
logger.info("dirigent: stopping no-reply API");
noReplyProcess.kill("SIGTERM");
noReplyProcess = null;
}
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
type DecisionRecord = { type DecisionRecord = {
@@ -31,15 +71,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 +215,33 @@ function pruneDecisionMap(now = Date.now()) {
} }
function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
const root = (api.config as Record<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 +258,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 +341,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 +366,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 +404,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 +422,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 +441,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 +456,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 +486,51 @@ 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) // Resolve plugin directory for locating sibling modules (no-reply-api/)
if (liveAtRegister.moderatorBotToken) { const pluginDir = path.dirname(new URL(import.meta.url).pathname);
startModeratorPresence(liveAtRegister.moderatorBotToken, api.logger);
api.logger.info("whispergate: moderator bot presence starting"); // Gateway lifecycle: start/stop no-reply API and moderator bot with the gateway
} api.on("gateway_start", () => {
startNoReplyApi(api.logger, pluginDir);
const live = getLivePluginConfig(api, baseConfig as DirigentConfig);
if (live.moderatorBotToken) {
startModeratorPresence(live.moderatorBotToken, api.logger);
api.logger.info("dirigent: moderator bot presence starting");
}
});
api.on("gateway_stop", () => {
stopNoReplyApi(api.logger);
stopModeratorPresence();
api.logger.info("dirigent: gateway stopping, services shut down");
});
api.registerTool( 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 +568,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 +598,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 +683,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 +701,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 +715,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 +734,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 +781,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 +802,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 +815,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 +825,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 +843,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 +856,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 +877,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 +893,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 +902,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 +913,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 +947,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 +960,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 +1014,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 +1043,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 +1123,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 +1141,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 +1162,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 +1171,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,13 +13,14 @@
"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 },
"enableWhispergatePolicyTool": { "type": "boolean", "default": true }, "enableDirigentPolicyTool": { "type": "boolean", "default": true },
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
"discordControlApiToken": { "type": "string" }, "discordControlApiToken": { "type": "string" },
"discordControlCallerId": { "type": "string" }, "discordControlCallerId": { "type": "string" },

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

@@ -0,0 +1,352 @@
#!/usr/bin/env node
/**
* Dirigent plugin installer/uninstaller with delta-tracking.
* Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
const modeArg = process.argv[2];
if (modeArg !== "--install" && modeArg !== "--uninstall") {
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall");
process.exit(2);
}
const mode = modeArg === "--install" ? "install" : "uninstall";
const env = process.env;
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
const __dirname = path.dirname(new URL(import.meta.url).pathname);
// Ensure dist/dirigent exists (handles git clone without npm prepare)
const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent");
const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin");
if (mode === "install" && !fs.existsSync(DIST_DIR)) {
console.log("[dirigent] dist/ not found, syncing from plugin/...");
fs.mkdirSync(DIST_DIR, { recursive: true });
for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) {
fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f));
}
}
const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR;
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway";
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
const LIST_MODE = env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️";
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir());
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths";
const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent";
const PATH_PROVIDERS = "models.providers";
const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`;
const PATH_PLUGINS_ALLOW = "plugins.allow";
function runOpenclaw(args, { allowFail = false } = {}) {
function runOpenclaw(args, { allowFail = false } = {}) {
try {
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
} catch (e) {
if (allowFail) return null;
throw e;
}
}
function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
if (out == null || out === "") return undefined;
return JSON.parse(out);
}
function setJson(pathKey, value) {
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
}
function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
}
function writeRecord(modeName, delta) {
fs.mkdirSync(STATE_DIR, { recursive: true });
const rec = {
mode: modeName,
timestamp: ts,
openclawConfigPath: OPENCLAW_CONFIG_PATH,
backupPath: BACKUP_PATH,
delta, // { added: {...}, replaced: {...}, removed: {...} }
};
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
return rec;
}
function readRecord(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function findLatestInstallRecord() {
if (!fs.existsSync(STATE_DIR)) return "";
const files = fs
.readdirSync(STATE_DIR)
.filter((f) => /^dirigent-\d+\.json$/.test(f))
.sort()
.reverse();
for (const f of files) {
const p = path.join(STATE_DIR, f);
try {
const rec = readRecord(p);
if (rec?.mode === "install") return p;
} catch {
// ignore broken records
}
}
return "";
}
// Deep clone
function clone(v) {
return JSON.parse(JSON.stringify(v));
}
// Check if two values are deeply equal
function deepEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1);
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTALL (with auto-reinstall if already installed)
// ═══════════════════════════════════════════════════════════════════════════
if (mode === "install") {
// Check if already installed - if so, uninstall first
const existingRecord = findLatestInstallRecord();
if (existingRecord) {
console.log("[dirigent] existing installation detected, uninstalling first...");
process.env.RECORD_FILE = existingRecord;
// Re-exec ourselves in uninstall mode
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
env: process.env,
stdio: ["inherit", "inherit", "inherit"],
});
if (result.status !== 0) {
console.error("[dirigent] reinstall failed during uninstall phase");
process.exit(1);
}
console.log("[dirigent] previous installation removed, proceeding with fresh install...");
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
}
const delta = { added: {}, replaced: {}, removed: {} };
try {
// ── plugins.load.paths ────────────────────────────────────────────────
const plugins = getJson("plugins") || {};
const oldPaths = clone(plugins.load?.paths) || [];
const newPaths = clone(oldPaths);
const pathIndex = newPaths.indexOf(PLUGIN_PATH);
if (pathIndex === -1) {
newPaths.push(PLUGIN_PATH);
delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path
} else {
// already present, no change
}
// save old paths for potential future rollback of this specific change
delta._prev = delta._prev || {};
delta._prev[PATH_PLUGINS_LOAD] = oldPaths;
plugins.load = plugins.load || {};
plugins.load.paths = newPaths;
// ── plugins.entries.dirigent ──────────────────────────────────────────
const oldEntry = clone(plugins.entries?.dirigent);
const newEntry = {
enabled: true,
config: {
enabled: true,
discordOnly: true,
listMode: LIST_MODE,
humanList: JSON.parse(HUMAN_LIST_JSON),
agentList: JSON.parse(AGENT_LIST_JSON),
channelPoliciesFile: CHANNEL_POLICIES_FILE,
endSymbols: JSON.parse(END_SYMBOLS_JSON),
schedulingIdentifier: SCHEDULING_IDENTIFIER,
noReplyProvider: NO_REPLY_PROVIDER_ID,
noReplyModel: NO_REPLY_MODEL_ID,
},
};
if (oldEntry === undefined) {
delta.added[PATH_PLUGIN_ENTRY] = newEntry;
} else {
delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry;
}
plugins.entries = plugins.entries || {};
plugins.entries.dirigent = newEntry;
setJson("plugins", plugins);
// ── models.providers.<providerId> ─────────────────────────────────────
const providers = getJson(PATH_PROVIDERS) || {};
const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]);
const newProvider = {
baseUrl: NO_REPLY_BASE_URL,
apiKey: NO_REPLY_API_KEY,
api: "openai-completions",
models: [
{
id: NO_REPLY_MODEL_ID,
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
if (oldProvider === undefined) {
delta.added[PATH_PROVIDER_ENTRY] = newProvider;
} else {
delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider;
}
providers[NO_REPLY_PROVIDER_ID] = newProvider;
setJson(PATH_PROVIDERS, providers);
// ── plugins.allow ─────────────────────────────────────────────────────
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
const oldAllow = clone(allowList);
if (!allowList.includes("dirigent")) {
allowList.push("dirigent");
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
delta._prev = delta._prev || {};
delta._prev[PATH_PLUGINS_ALLOW] = oldAllow;
setJson(PATH_PLUGINS_ALLOW, allowList);
console.log("[dirigent] added 'dirigent' to plugins.allow");
}
writeRecord("install", delta);
console.log("[dirigent] install ok (config written)");
console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[dirigent] install failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UNINSTALL
// ═══════════════════════════════════════════════════════════════════════════
else {
const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) {
console.log("[dirigent] no install record found, nothing to uninstall.");
process.exit(0);
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
const rec = readRecord(recFile);
const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
try {
// ── Handle ADDED entries: remove them ─────────────────────────────────
if (delta.added[PATH_PLUGIN_ENTRY] !== undefined) {
const plugins = getJson("plugins") || {};
plugins.entries = plugins.entries || {};
delete plugins.entries.dirigent;
setJson("plugins", plugins);
console.log("[dirigent] removed plugins.entries.dirigent");
}
if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
const providers = getJson(PATH_PROVIDERS) || {};
delete providers[NO_REPLY_PROVIDER_ID];
setJson(PATH_PROVIDERS, providers);
console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`);
}
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
const plugins = getJson("plugins") || {};
const paths = plugins.load?.paths || [];
const idx = paths.indexOf(PLUGIN_PATH);
if (idx !== -1) {
paths.splice(idx, 1);
plugins.load.paths = paths;
setJson("plugins", plugins);
console.log("[dirigent] removed plugin path from plugins.load.paths");
}
}
// ── Handle plugins.allow ──────────────────────────────────────────────
if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) {
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
const idx = allowList.indexOf("dirigent");
if (idx !== -1) {
allowList.splice(idx, 1);
setJson(PATH_PLUGINS_ALLOW, allowList);
console.log("[dirigent] removed 'dirigent' from plugins.allow");
}
}
// ── Handle REPLACED entries: restore old value ────────────────────────
if (delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) {
const plugins = getJson("plugins") || {};
plugins.entries = plugins.entries || {};
plugins.entries.dirigent = delta.replaced[PATH_PLUGIN_ENTRY];
setJson("plugins", plugins);
console.log("[dirigent] restored previous plugins.entries.dirigent");
}
if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) {
const providers = getJson(PATH_PROVIDERS) || {};
providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY];
setJson(PATH_PROVIDERS, providers);
console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`);
}
// Handle plugins.load.paths restoration (if it was replaced, not added)
if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) {
const plugins = getJson("plugins") || {};
plugins.load = plugins.load || {};
plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD];
setJson("plugins", plugins);
console.log("[dirigent] restored previous plugins.load.paths");
}
writeRecord("uninstall", delta);
console.log("[dirigent] uninstall ok");
console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}

View File

@@ -1,224 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
const modeArg = process.argv[2];
if (modeArg !== "--install" && modeArg !== "--uninstall") {
console.error("Usage: install-whispergate-openclaw.mjs --install | --uninstall");
process.exit(2);
}
const mode = modeArg === "--install" ? "install" : "uninstall";
const env = process.env;
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate");
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway";
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
const LIST_MODE = env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/whispergate-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").replace(/^~(?=$|\/)/, os.homedir());
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/whispergate-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`;
const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths";
const PATH_PLUGIN_ENTRY = "plugins.entries.whispergate";
const PATH_PROVIDERS = "models.providers";
function runOpenclaw(args, { allowFail = false } = {}) {
try {
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
} catch (e) {
if (allowFail) return null;
throw e;
}
}
function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
if (out == null || out === "") return { exists: false };
return { exists: true, value: JSON.parse(out) };
}
function setJson(pathKey, value) {
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
}
function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
}
function writeRecord(modeName, before, after) {
fs.mkdirSync(STATE_DIR, { recursive: true });
const rec = {
mode: modeName,
timestamp: ts,
openclawConfigPath: OPENCLAW_CONFIG_PATH,
backupPath: BACKUP_PATH,
paths: before,
applied: after,
};
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
}
function readRecord(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function findLatestInstallRecord() {
if (!fs.existsSync(STATE_DIR)) return "";
const files = fs
.readdirSync(STATE_DIR)
.filter((f) => /^whispergate-\d+\.json$/.test(f))
.sort()
.reverse();
for (const f of files) {
const p = path.join(STATE_DIR, f);
try {
const rec = readRecord(p);
if (rec?.mode === "install") return p;
} catch {
// ignore broken records
}
}
return "";
}
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1);
}
if (mode === "install") {
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[whispergate] backup: ${BACKUP_PATH}`);
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
console.log(`[whispergate] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
}
const before = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
try {
const pluginsNow = getJson("plugins").value || {};
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : [];
if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH);
plugins.load.paths = paths;
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
plugins.entries.whispergate = {
enabled: true,
config: {
enabled: true,
discordOnly: true,
listMode: LIST_MODE,
humanList: JSON.parse(HUMAN_LIST_JSON),
agentList: JSON.parse(AGENT_LIST_JSON),
channelPoliciesFile: CHANNEL_POLICIES_FILE,
endSymbols: JSON.parse(END_SYMBOLS_JSON),
noReplyProvider: NO_REPLY_PROVIDER_ID,
noReplyModel: NO_REPLY_MODEL_ID,
},
};
setJson("plugins", plugins);
const providersNow = getJson(PATH_PROVIDERS).value || {};
const providers = typeof providersNow === "object" ? providersNow : {};
providers[NO_REPLY_PROVIDER_ID] = {
baseUrl: NO_REPLY_BASE_URL,
apiKey: NO_REPLY_API_KEY,
api: "openai-completions",
models: [
{
id: NO_REPLY_MODEL_ID,
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
setJson(PATH_PROVIDERS, providers);
const after = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
writeRecord("install", before, after);
console.log("[whispergate] install ok (config written)");
console.log(`[whispergate] record: ${RECORD_PATH}`);
console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[whispergate] install failed; rollback complete: ${String(e)}`);
process.exit(1);
}
} else {
const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) {
console.error("[whispergate] no install record found. set RECORD_FILE=<path> to an install record.");
process.exit(1);
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[whispergate] backup before uninstall: ${BACKUP_PATH}`);
const rec = readRecord(recFile);
const before = rec.applied || {};
const target = rec.paths || {};
try {
const pluginsNow = getJson("plugins").value || {};
const plugins = typeof pluginsNow === "object" ? pluginsNow : {};
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {};
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {};
if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value;
else delete plugins.load.paths;
if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value;
else delete plugins.entries.whispergate;
setJson("plugins", plugins);
if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value);
else unsetPath(PATH_PROVIDERS);
const after = {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
writeRecord("uninstall", before, after);
console.log("[whispergate] uninstall ok");
console.log(`[whispergate] record: ${RECORD_PATH}`);
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}

View File

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

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"