feat: complete Dirigent rename + all TASKLIST items

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

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

View File

@@ -4,15 +4,15 @@
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Added optional bearer auth (`AUTH_TOKEN`)
- Added WhisperGate plugin with deterministic rule gate
- Added Dirigent plugin with deterministic rule gate
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
- Added containerization (`Dockerfile`, `docker-compose.yml`)
- Added helper scripts for smoke/dev lifecycle and rule validation
- Added no-touch config rendering and integration docs
- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`)
- Added installer script with rollback (`scripts/install-dirigent-openclaw.sh`)
- supports `--install` / `--uninstall`
- uninstall restores all recorded changes
- writes install/uninstall records under `~/.openclaw/whispergate-install-records/`
- writes install/uninstall records under `~/.openclaw/dirigent-install-records/`
- Added discord-control-api with:
- `channel-private-create` (create private channel for allowlist)
- `channel-private-update` (update allowlist/overwrites for existing channel)

View File

@@ -1,10 +1,10 @@
# WhisperGate
# Dirigent
Rule-based no-reply gate + turn manager for OpenClaw (Discord).
## What it does
WhisperGate adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
- **Rule gate (before_model_resolve)**
1. Non-Discord → skip
@@ -27,11 +27,11 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
- **Per-channel policy runtime**
- Policies stored in a standalone JSON file
- Update at runtime via `whispergate_tools` (memory first → persist to file)
- Update at runtime via `dirigent_tools` (memory first → persist to file)
- **Discord control actions (optional)**
- Private channel create/update + member list
- Unified via `whispergate_tools`
- Unified via `dirigent_tools`
---
@@ -67,7 +67,7 @@ Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。
## Runtime tools & commands
### Tool: `whispergate_tools`
### Tool: `dirigent_tools`
Actions:
- `policy-get`, `policy-set-channel`, `policy-delete-channel`
@@ -77,10 +77,10 @@ Actions:
### Slash command (Discord)
```
/whispergate status
/whispergate turn-status
/whispergate turn-advance
/whispergate turn-reset
/dirigent status
/dirigent turn-status
/dirigent turn-advance
/dirigent turn-reset
```
---

View File

@@ -3,32 +3,47 @@
> Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs).
## 1) Identity Prompt Enhancements
- Current prompt only includes agent-id + discord name.
- **Add Discord userId** to identity injection.
- ✅ Added Discord userId to identity injection via `resolveDiscordUserId()`.
- Identity format now: `You are <name> (Discord account: <accountId>, Discord userId: <userId>).`
## 2) Scheduling Identifier (Default: ➡️)
- Add a **configurable scheduling identifier** (default: `➡️`).
- Update agent prompt to explain:
- The scheduling identifier itself is meaningless.
- When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply.
- If no reply needed, return `NO_REPLY`.
- Added `schedulingIdentifier` config field (default: `➡️`) to `DirigentConfig` and `openclaw.plugin.json`.
- Updated `buildEndMarkerInstruction()` to explain scheduling identifier semantics to agents:
- The identifier itself is meaningless.
- When receiving `<@USER_ID>` + identifier, check chat history and decide whether to reply.
- If nothing to say, reply `NO_REPLY`.
## 3) Moderator Handoff Message Format
- Moderator should **no longer send semantic messages** to activate agents.
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
- Moderator no longer sends semantic messages.
- Handoff format is now: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
## 4) Prompt Language
- **All prompts must be in English** (including end-marker instructions and group-chat rules).
- All prompts converted to English:
- `buildEndMarkerInstruction()` — English with scheduling identifier explanation
- `buildAgentIdentity()` — English format
- Slash command help text — English
- Error messages — English
- Code comments — English
## 5) Full Project Rename
- Project name changed to **Dirigent**.
- Update **all strings** across repo:
- plugin name/id
- tool name(s)
- docs, config, scripts, examples
- any text mentions
- ✅ Plugin id: `whispergate``dirigent`
- ✅ Plugin name: `WhisperGate``Dirigent`
- ✅ Tool name: `whispergate_tools``dirigent_tools`
- ✅ Config type: `WhisperGateConfig``DirigentConfig`
- ✅ Config lookup key: `entries.whispergate``entries.dirigent`
- ✅ Channel policies file: `whispergate-channel-policies.json``dirigent-channel-policies.json`
- ✅ Log prefixes: `whispergate:``dirigent:`
- ✅ Slash command: `/whispergate``/dirigent`
- ✅ Gateway browser/device identifier: `whispergate``dirigent`
- ✅ Scripts renamed: `install-whispergate-*``install-dirigent-*`
- ✅ All docs, configs, examples updated
- ✅ dist/ folder: `dist/whispergate/``dist/dirigent/`
- ✅ package.json names updated
- ✅ README.md, CHANGELOG.md updated
- ✅ Version bumped to 0.2.0
---
## Open Items / Notes
- User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed.
- **Migration note**: Existing deployments need to update their `openclaw.json` config from `plugins.entries.whispergate``plugins.entries.dirigent` and rename the channel policies file.

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,15 +1,15 @@
# PR Summary (WhisperGate + Discord Control)
# PR Summary (Dirigent + Discord Control)
## Scope
This PR delivers two tracks:
1. WhisperGate deterministic no-reply gate for Discord sessions
1. Dirigent deterministic no-reply gate for Discord sessions
2. Discord control extension API for private-channel/member-list gaps
## Delivered Features
### WhisperGate
### Dirigent
- Deterministic rule chain:
1) non-discord => skip

View File

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

View File

@@ -1,4 +1,4 @@
# WhisperGate Rollout Checklist
# Dirigent Rollout Checklist
## Stage 0: Local sanity
@@ -29,5 +29,5 @@
## Rollback
- Disable plugin entry `whispergate.enabled=false` OR remove plugin path
- Disable plugin entry `dirigent.enabled=false` OR remove plugin path
- Keep API service running; it is inert when plugin disabled

View File

@@ -1,6 +1,6 @@
# Run Modes
WhisperGate has two runtime components:
Dirigent has two runtime components:
1. `plugin/` (OpenClaw plugin)
2. `no-reply-api/` (deterministic NO_REPLY service)
@@ -20,7 +20,7 @@ Then configure OpenClaw provider `baseURL` to `http://127.0.0.1:8787/v1`.
```bash
./scripts/dev-up.sh
# or: docker compose up -d --build whispergate-no-reply-api
# or: docker compose up -d --build dirigent-no-reply-api
```
Stop:

View File

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

View File

@@ -2,7 +2,7 @@
## Context
WhisperGate implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
Dirigent implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
## The Problem
@@ -12,7 +12,7 @@ When the current speaker responds with **NO_REPLY** (decides the message is not
1. A message arrives in the Discord channel
2. OpenClaw routes it to **all** agent sessions in that channel simultaneously
3. The WhisperGate plugin intercepts at `before_model_resolve`:
3. The Dirigent plugin intercepts at `before_model_resolve`:
- Current speaker → allowed to process
- Everyone else → forced to no-reply model (message is "consumed" silently)
4. Current speaker processes the message and returns NO_REPLY

View File

@@ -1,4 +1,4 @@
# WhisperGate Quick Verification
# Dirigent Quick Verification
## 1) Start no-reply API
@@ -15,7 +15,7 @@ npm start
curl -sS http://127.0.0.1:8787/health
curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hi"}]}'
```
Or run bundled smoke check:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import path from "node:path";
const root = process.cwd();
const pluginDir = path.join(root, "plugin");
const outDir = path.join(root, "dist", "whispergate");
const outDir = path.join(root, "dist", "dirigent");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });

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

View File

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