dev/dirigent-tasks #11
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,18 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- **Project renamed from WhisperGate to Dirigent**
|
||||
- All plugin ids, tool names, config keys, file paths, docs updated
|
||||
- Legacy `whispergate` config key still supported as fallback
|
||||
- **Identity prompt enhancements**: Discord userId now included in agent identity injection
|
||||
- **Scheduling identifier**: Added configurable `schedulingIdentifier` (default: `➡️`)
|
||||
- Moderator handoff now sends `<@USER_ID>➡️` instead of semantic messages
|
||||
- Agent prompt explains the identifier is meaningless — check chat history and decide
|
||||
- **All prompts in English**: End-marker instructions, group chat rules, slash command help text
|
||||
|
||||
## 0.1.0-mvp
|
||||
|
||||
- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
|
||||
- Added optional bearer auth (`AUTH_TOKEN`)
|
||||
- Added WhisperGate plugin with deterministic rule gate
|
||||
- Added plugin with deterministic rule gate
|
||||
- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths
|
||||
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
||||
- Added helper scripts for smoke/dev lifecycle and rule validation
|
||||
- Added no-touch config rendering and integration docs
|
||||
- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`)
|
||||
- Added installer script with rollback (`scripts/install-dirigent-openclaw.sh`)
|
||||
- supports `--install` / `--uninstall`
|
||||
- uninstall restores all recorded changes
|
||||
- writes install/uninstall records under `~/.openclaw/whispergate-install-records/`
|
||||
- writes install/uninstall records under `~/.openclaw/dirigent-install-records/`
|
||||
- Added discord-control-api with:
|
||||
- `channel-private-create` (create private channel for allowlist)
|
||||
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
||||
|
||||
38
README.md
38
README.md
@@ -1,10 +1,12 @@
|
||||
# WhisperGate
|
||||
# Dirigent
|
||||
|
||||
Rule-based no-reply gate + turn manager for OpenClaw (Discord).
|
||||
|
||||
> Formerly known as WhisperGate. Renamed to Dirigent in v0.2.0.
|
||||
|
||||
## What it does
|
||||
|
||||
WhisperGate adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
|
||||
Dirigent adds deterministic logic **before model selection** and **turn-based speaking** for multi-agent Discord channels:
|
||||
|
||||
- **Rule gate (before_model_resolve)**
|
||||
1. Non-Discord → skip
|
||||
@@ -13,8 +15,13 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
|
||||
4. Otherwise → route to no-reply model/provider
|
||||
|
||||
- **End-symbol enforcement**
|
||||
- Injects instruction like: `你的这次发言必须以🔚作为结尾…`
|
||||
- In group chats, also injects: “无关/不需要回应就 NO_REPLY”
|
||||
- Injects instruction: `Your response MUST end with 🔚…`
|
||||
- In group chats, also injects: "If not relevant, reply NO_REPLY"
|
||||
|
||||
- **Scheduling identifier (moderator handoff)**
|
||||
- Configurable identifier (default: `➡️`) used by the moderator bot
|
||||
- Handoff format: `<@TARGET_USER_ID>➡️` (non-semantic, just a scheduling signal)
|
||||
- Agent receives instruction explaining the identifier is meaningless — check chat history and decide
|
||||
|
||||
- **Turn-based speaking (multi-bot)**
|
||||
- Only the current speaker is allowed to respond
|
||||
@@ -22,16 +29,16 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
|
||||
- Turn advances on **end-symbol** or **NO_REPLY**
|
||||
- If all bots NO_REPLY, channel becomes **dormant** until a new human message
|
||||
|
||||
- **Moderator handoff (optional)**
|
||||
- When the current speaker NO_REPLYs, a moderator bot can post a handoff message to wake the next speaker
|
||||
- **Agent identity injection**
|
||||
- Injects agent name, Discord accountId, and Discord userId into group chat prompts
|
||||
|
||||
- **Per-channel policy runtime**
|
||||
- Policies stored in a standalone JSON file
|
||||
- Update at runtime via `whispergate_tools` (memory first → persist to file)
|
||||
- Update at runtime via `dirigent_tools` (memory first → persist to file)
|
||||
|
||||
- **Discord control actions (optional)**
|
||||
- Private channel create/update + member list
|
||||
- Unified via `whispergate_tools`
|
||||
- Unified via `dirigent_tools`
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +46,7 @@ WhisperGate adds deterministic logic **before model selection** and **turn-based
|
||||
|
||||
- `plugin/` — OpenClaw plugin (gate + turn manager + moderator presence)
|
||||
- `no-reply-api/` — OpenAI-compatible API that always returns `NO_REPLY`
|
||||
- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表)
|
||||
- `discord-control-api/` — Discord admin extension API (private channels + member list)
|
||||
- `docs/` — rollout, integration, run-mode notes, turn-wakeup analysis
|
||||
- `scripts/` — smoke/dev/helper checks
|
||||
- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`)
|
||||
@@ -61,13 +68,13 @@ node scripts/render-openclaw-config.mjs
|
||||
```
|
||||
|
||||
See `docs/RUN_MODES.md` for Docker mode.
|
||||
Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。
|
||||
Discord extension capabilities: `docs/DISCORD_CONTROL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime tools & commands
|
||||
|
||||
### Tool: `whispergate_tools`
|
||||
### Tool: `dirigent_tools`
|
||||
|
||||
Actions:
|
||||
- `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
@@ -77,10 +84,10 @@ Actions:
|
||||
### Slash command (Discord)
|
||||
|
||||
```
|
||||
/whispergate status
|
||||
/whispergate turn-status
|
||||
/whispergate turn-advance
|
||||
/whispergate turn-reset
|
||||
/dirigent status
|
||||
/dirigent turn-status
|
||||
/dirigent turn-advance
|
||||
/dirigent turn-reset
|
||||
```
|
||||
|
||||
---
|
||||
@@ -92,6 +99,7 @@ Common options (see `docs/INTEGRATION.md`):
|
||||
- `listMode`: `human-list` or `agent-list`
|
||||
- `humanList`, `agentList`
|
||||
- `endSymbols`
|
||||
- `schedulingIdentifier` (default `➡️`)
|
||||
- `channelPoliciesFile` (per-channel overrides)
|
||||
- `moderatorBotToken` (handoff messages)
|
||||
- `enableDebugLogs`, `debugLogChannelIds`
|
||||
|
||||
23
TASKLIST.md
23
TASKLIST.md
@@ -2,31 +2,40 @@
|
||||
|
||||
> Note: Project rename from WhisperGate → Dirigent implies updating all code/docs references (plugin/tool names, strings, files, configs).
|
||||
|
||||
## 1) Identity Prompt Enhancements
|
||||
## 1) Identity Prompt Enhancements ✅
|
||||
- Current prompt only includes agent-id + discord name.
|
||||
- **Add Discord userId** to identity injection.
|
||||
- **Done**: `buildAgentIdentity()` now resolves and includes Discord userId via `resolveDiscordUserId()`.
|
||||
|
||||
## 2) Scheduling Identifier (Default: ➡️)
|
||||
## 2) Scheduling Identifier (Default: ➡️) ✅
|
||||
- Add a **configurable scheduling identifier** (default: `➡️`).
|
||||
- Update agent prompt to explain:
|
||||
- The scheduling identifier itself is meaningless.
|
||||
- When receiving `<@USER_ID>` + scheduling identifier, the agent should check chat history and decide whether to reply.
|
||||
- If no reply needed, return `NO_REPLY`.
|
||||
- **Done**: Added `schedulingIdentifier` config field; `buildSchedulingIdentifierInstruction()` injected for group chats.
|
||||
|
||||
## 3) Moderator Handoff Message Format
|
||||
## 3) Moderator Handoff Message Format ✅
|
||||
- Moderator should **no longer send semantic messages** to activate agents.
|
||||
- Replace with: `<@TARGET_USER_ID>` + scheduling identifier (e.g., `<@123>➡️`).
|
||||
- **Done**: Both `before_message_write` and `message_sent` handoff messages now use `<@userId>` + scheduling identifier format.
|
||||
|
||||
## 4) Prompt Language
|
||||
## 4) Prompt Language ✅
|
||||
- **All prompts must be in English** (including end-marker instructions and group-chat rules).
|
||||
- **Done**: `buildEndMarkerInstruction()` and `buildSchedulingIdentifierInstruction()` output English. Slash command help text in English.
|
||||
|
||||
## 5) Full Project Rename
|
||||
## 5) Full Project Rename ✅
|
||||
- Project name changed to **Dirigent**.
|
||||
- Update **all strings** across repo:
|
||||
- plugin name/id
|
||||
- tool name(s)
|
||||
- plugin name/id → `dirigent`
|
||||
- tool name → `dirigent_tools`
|
||||
- slash command → `/dirigent`
|
||||
- docs, config, scripts, examples
|
||||
- any text mentions
|
||||
- dist output dir → `dist/dirigent`
|
||||
- docker service → `dirigent-no-reply-api`
|
||||
- config key fallback: still reads legacy `whispergate` entry if `dirigent` not found
|
||||
- **Done**: All files updated.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "whispergate-discord-control-api",
|
||||
"name": "dirigent-discord-control-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
1150
dist/whispergate/index.ts
vendored
1150
dist/whispergate/index.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"plugins": {
|
||||
"load": {
|
||||
"paths": ["/path/to/WhisperGate/dist/whispergate"]
|
||||
"paths": ["/path/to/Dirigent/dist/dirigent"]
|
||||
},
|
||||
"entries": {
|
||||
"whispergate": {
|
||||
"dirigent": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"enabled": true,
|
||||
@@ -13,11 +13,12 @@
|
||||
"humanList": ["561921120408698910"],
|
||||
"agentList": [],
|
||||
"endSymbols": ["🔚"],
|
||||
"channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json",
|
||||
"noReplyProvider": "whisper-gateway",
|
||||
"schedulingIdentifier": "➡️",
|
||||
"channelPoliciesFile": "~/.openclaw/dirigent-channel-policies.json",
|
||||
"noReplyProvider": "dirigentway",
|
||||
"noReplyModel": "no-reply",
|
||||
"enableDiscordControlTool": true,
|
||||
"enableWhispergatePolicyTool": true,
|
||||
"enableDirigentPolicyTool": true,
|
||||
"enableDebugLogs": false,
|
||||
"debugLogChannelIds": [],
|
||||
"discordControlApiBaseUrl": "http://127.0.0.1:8790",
|
||||
@@ -29,7 +30,7 @@
|
||||
},
|
||||
"models": {
|
||||
"providers": {
|
||||
"whisper-gateway": {
|
||||
"dirigentway": {
|
||||
"apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>",
|
||||
"baseUrl": "http://127.0.0.1:8787/v1",
|
||||
"api": "openai-completions",
|
||||
@@ -52,7 +53,7 @@
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": ["whispergate"]
|
||||
"allow": ["dirigent"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 成员列表(分页)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WhisperGate 测试记录报告(阶段性)
|
||||
# Dirigent 测试记录报告(阶段性)
|
||||
|
||||
日期:2026-02-25
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
|
||||
本轮覆盖:
|
||||
|
||||
1. WhisperGate 基础静态与脚本测试
|
||||
1. Dirigent 基础静态与脚本测试
|
||||
2. no-reply-api 隔离集成测试
|
||||
3. discord-control-api 功能测试(dryRun + 实操)
|
||||
|
||||
未覆盖:
|
||||
|
||||
- WhisperGate 插件真实挂载 OpenClaw 后的端到端(E2E)
|
||||
- Dirigent 插件真实挂载 OpenClaw 后的端到端(E2E)
|
||||
|
||||
---
|
||||
|
||||
## 二、测试环境
|
||||
|
||||
- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate`
|
||||
- 代码仓库:`/root/.openclaw/workspace-operator/Dirigent`
|
||||
- OpenClaw 配置来源:本机已有配置(读取 Discord token)
|
||||
- Discord guild(server)ID:`1368531017534537779`
|
||||
- allowlist user IDs:
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
## 三、已执行测试与结果
|
||||
|
||||
### A. WhisperGate 基础测试
|
||||
### A. Dirigent 基础测试
|
||||
|
||||
命令:
|
||||
|
||||
@@ -95,7 +95,7 @@ make check check-rules test-api
|
||||
|
||||
## 五、待测项(下一阶段)
|
||||
|
||||
### 1) WhisperGate 插件 E2E(需临时接入 OpenClaw 配置)
|
||||
### 1) Dirigent 插件 E2E(需临时接入 OpenClaw 配置)
|
||||
|
||||
目标:验证插件真实挂载后的完整链路。
|
||||
|
||||
@@ -113,7 +113,7 @@ make check check-rules test-api
|
||||
|
||||
### 2) 回归测试
|
||||
|
||||
- discord-control-api 引入后,不影响 WhisperGate 原有流程
|
||||
- discord-control-api 引入后,不影响 Dirigent 原有流程
|
||||
- 规则校验脚本在最新代码继续稳定通过
|
||||
|
||||
### 3) 运行与安全校验
|
||||
@@ -127,4 +127,4 @@ make check check-rules test-api
|
||||
## 六、当前结论
|
||||
|
||||
- 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。
|
||||
- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。
|
||||
- 项目剩余主要测试工作集中在 Dirigent 插件与 OpenClaw 的真实 E2E 联调。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
4
no-reply-api/package-lock.json
generated
4
no-reply-api/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "whispergate-no-reply-api",
|
||||
"name": "dirigent-no-reply-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
39
package.json
Normal file
39
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
# WhisperGate Plugin
|
||||
# Dirigent Plugin
|
||||
|
||||
## Hook strategy
|
||||
|
||||
- `message:received` caches a per-session decision from deterministic rules.
|
||||
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
|
||||
- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is:
|
||||
- `bypass_sender`
|
||||
- `end_symbol:*`
|
||||
- `before_prompt_build` prepends end-marker instruction + scheduling identifier instruction when decision allows speaking.
|
||||
|
||||
## Rules (in order)
|
||||
|
||||
@@ -30,12 +28,14 @@ Optional:
|
||||
- `humanList` (default [])
|
||||
- `agentList` (default [])
|
||||
- `channelPoliciesFile` (per-channel overrides in a standalone JSON file)
|
||||
- `enableWhispergatePolicyTool` (default true)
|
||||
- `schedulingIdentifier` (default `➡️`) — moderator handoff identifier
|
||||
- `enableDirigentPolicyTool` (default true)
|
||||
|
||||
Unified optional tool:
|
||||
- `whispergateway_tools`
|
||||
- `dirigent_tools`
|
||||
- Discord actions: `channel-private-create`, `channel-private-update`, `member-list`
|
||||
- Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
- Turn actions: `turn-status`, `turn-advance`, `turn-reset`
|
||||
- `bypassUserIds` (deprecated alias of `humanList`)
|
||||
- `endSymbols` (default ["🔚"])
|
||||
- `enableDiscordControlTool` (default true)
|
||||
@@ -51,20 +51,23 @@ Policy file behavior:
|
||||
- loaded once on startup into memory
|
||||
- runtime decisions read memory state only
|
||||
- direct file edits do NOT affect memory state
|
||||
- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write)
|
||||
- `dirigent_tools` policy actions update memory first, then persist to file (atomic write)
|
||||
|
||||
## Optional tool: `whispergateway_tools`
|
||||
## Moderator handoff format
|
||||
|
||||
This plugin registers one unified optional tool: `whispergateway_tools`.
|
||||
To use it, add tool allowlist entry for either:
|
||||
- tool name: `whispergateway_tools`
|
||||
- plugin id: `whispergate`
|
||||
When the current speaker NO_REPLYs, the moderator bot sends: `<@NEXT_USER_ID>➡️`
|
||||
|
||||
Supported actions:
|
||||
- Discord: `channel-private-create`, `channel-private-update`, `member-list`
|
||||
- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel`
|
||||
This is a non-semantic scheduling message. The scheduling identifier (`➡️` by default) carries no meaning — it simply signals the next agent to check chat history and decide whether to speak.
|
||||
|
||||
## Slash command (Discord)
|
||||
|
||||
```
|
||||
/dirigent status
|
||||
/dirigent turn-status
|
||||
/dirigent turn-advance
|
||||
/dirigent turn-reset
|
||||
```
|
||||
|
||||
Debug logging:
|
||||
- set `enableDebugLogs: true` to emit detailed hook diagnostics
|
||||
- optionally set `debugLogChannelIds` to only log selected channel IDs
|
||||
- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build`
|
||||
|
||||
307
plugin/index.ts
307
plugin/index.ts
@@ -1,10 +1,50 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
|
||||
// ── No-Reply API child process lifecycle ──────────────────────────────
|
||||
let noReplyProcess: ChildProcess | null = null;
|
||||
|
||||
function startNoReplyApi(logger: { info: (m: string) => void; warn: (m: string) => void }, pluginDir: string, port = 8787): void {
|
||||
if (noReplyProcess) {
|
||||
logger.info("dirigent: no-reply API already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverPath = path.resolve(pluginDir, "..", "no-reply-api", "server.mjs");
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
logger.warn(`dirigent: no-reply API server not found at ${serverPath}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
noReplyProcess = spawn(process.execPath, [serverPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
noReplyProcess.stdout?.on("data", (d: Buffer) => logger.info(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
noReplyProcess.stderr?.on("data", (d: Buffer) => logger.warn(`dirigent: no-reply-api: ${d.toString().trim()}`));
|
||||
|
||||
noReplyProcess.on("exit", (code, signal) => {
|
||||
logger.info(`dirigent: no-reply API exited (code=${code}, signal=${signal})`);
|
||||
noReplyProcess = null;
|
||||
});
|
||||
|
||||
logger.info(`dirigent: no-reply API started (pid=${noReplyProcess.pid}, port=${port})`);
|
||||
}
|
||||
|
||||
function stopNoReplyApi(logger: { info: (m: string) => void }): void {
|
||||
if (!noReplyProcess) return;
|
||||
logger.info("dirigent: stopping no-reply API");
|
||||
noReplyProcess.kill("SIGTERM");
|
||||
noReplyProcess = null;
|
||||
}
|
||||
|
||||
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
||||
|
||||
type DecisionRecord = {
|
||||
@@ -31,15 +71,20 @@ const sessionAccountId = new Map<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_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
||||
let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`;
|
||||
if (isGroupChat) {
|
||||
instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`;
|
||||
instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`;
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
|
||||
function buildSchedulingIdentifierInstruction(schedulingIdentifier: string): string {
|
||||
return `\n\nScheduling identifier: "${schedulingIdentifier}". This identifier itself is meaningless — it carries no semantic content. When you receive a message containing <@YOUR_USER_ID> followed by the scheduling identifier, check recent chat history and decide whether you have something to say. If not, reply NO_REPLY.`;
|
||||
}
|
||||
|
||||
const policyState: PolicyState = {
|
||||
filePath: "",
|
||||
channelPolicies: {},
|
||||
@@ -170,31 +215,33 @@ function pruneDecisionMap(now = Date.now()) {
|
||||
}
|
||||
|
||||
|
||||
function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig {
|
||||
function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentConfig): DirigentConfig {
|
||||
const root = (api.config as Record<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 +258,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 +341,7 @@ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
|
||||
|
||||
/**
|
||||
* Build agent identity string for injection into group chat prompts.
|
||||
* Includes agent name, Discord accountId, and Discord userId.
|
||||
*/
|
||||
function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<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 name = (agent?.name as string) || agentId;
|
||||
|
||||
// Find Discord bot user ID from account token (not available directly)
|
||||
// We'll use accountId as the identifier
|
||||
return `你是 ${name}(Discord 账号: ${accountId})。`;
|
||||
// Resolve Discord userId from bot token
|
||||
const discordUserId = resolveDiscordUserId(api, accountId);
|
||||
|
||||
let identity = `You are ${name} (Discord account: ${accountId}`;
|
||||
if (discordUserId) {
|
||||
identity += `, Discord userId: ${discordUserId}`;
|
||||
}
|
||||
identity += `).`;
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
// --- Moderator bot helpers ---
|
||||
@@ -349,7 +404,7 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string
|
||||
}
|
||||
|
||||
/** Get the moderator bot's Discord user ID from its token */
|
||||
function getModeratorUserId(config: WhisperGateConfig): string | undefined {
|
||||
function getModeratorUserId(config: DirigentConfig): string | undefined {
|
||||
if (!config.moderatorBotToken) return undefined;
|
||||
return userIdFromToken(config.moderatorBotToken);
|
||||
}
|
||||
@@ -367,13 +422,13 @@ async function sendModeratorMessage(token: string, channelId: string, content: s
|
||||
});
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
logger.warn(`whispergate: moderator send failed (${r.status}): ${text}`);
|
||||
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`);
|
||||
return false;
|
||||
}
|
||||
logger.info(`whispergate: moderator message sent to channel=${channelId}`);
|
||||
logger.info(`dirigent: moderator message sent to channel=${channelId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`whispergate: moderator send error: ${String(err)}`);
|
||||
logger.warn(`dirigent: moderator send error: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -386,7 +441,7 @@ function persistPolicies(api: OpenClawPluginApi): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(tmp, before, "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
|
||||
api.logger.info(`dirigent: policy file persisted: ${filePath}`);
|
||||
}
|
||||
|
||||
function pickDefined(input: Record<string, unknown>) {
|
||||
@@ -401,7 +456,7 @@ function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
|
||||
if (!cfg.enableDebugLogs) return false;
|
||||
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
|
||||
if (allow.length === 0) return true;
|
||||
if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景
|
||||
if (!channelId) return true;
|
||||
return allow.includes(channelId);
|
||||
}
|
||||
|
||||
@@ -431,36 +486,51 @@ function debugCtxSummary(ctx: Record<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");
|
||||
// Resolve plugin directory for locating sibling modules (no-reply-api/)
|
||||
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
// 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(
|
||||
{
|
||||
name: "whispergate_tools",
|
||||
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
||||
name: "dirigent_tools",
|
||||
description: "Dirigent unified tool: Discord admin actions + in-memory policy management.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -498,12 +568,12 @@ export default {
|
||||
required: ["action"],
|
||||
},
|
||||
async execute(_id: string, params: Record<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 +598,14 @@ export default {
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }],
|
||||
content: [{ type: "text", text: `dirigent_tools discord failed (${r.status}): ${text}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
if (live.enableWhispergatePolicyTool === false) {
|
||||
if (live.enableDirigentPolicyTool === false) {
|
||||
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
|
||||
}
|
||||
|
||||
@@ -613,9 +683,9 @@ export default {
|
||||
// ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake.
|
||||
// Extract the real Discord channel ID from conversationId or event.to.
|
||||
const preChannelId = extractDiscordChannelId(c, e);
|
||||
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const livePre = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
api.logger.info(`dirigent: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
}
|
||||
|
||||
// Turn management on message received
|
||||
@@ -631,7 +701,7 @@ export default {
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
if (moderatorUserId && from === moderatorUserId) {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`whispergate: ignoring moderator message in channel=${preChannelId}`);
|
||||
api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`);
|
||||
}
|
||||
// Don't call onNewMessage — moderator messages are transparent to turn logic
|
||||
} else {
|
||||
@@ -645,18 +715,18 @@ export default {
|
||||
if (isNew) {
|
||||
// Re-initialize turn order with updated channel membership
|
||||
ensureTurnOrder(api, preChannelId);
|
||||
api.logger.info(`whispergate: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
|
||||
}
|
||||
}
|
||||
|
||||
onNewMessage(preChannelId, senderAccountId, isHuman);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
|
||||
api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: message hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -664,14 +734,14 @@ export default {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
const prompt = ((event as Record<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 +781,7 @@ export default {
|
||||
pruneDecisionMap();
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`whispergate: debug before_model_resolve recompute session=${key} ` +
|
||||
`dirigent: debug before_model_resolve recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`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
|
||||
sessionAllowed.set(key, false);
|
||||
api.logger.info(
|
||||
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||
`dirigent: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||
);
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
@@ -745,7 +815,6 @@ export default {
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) {
|
||||
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
||||
if (rec.needsRestore) {
|
||||
sessionDecision.delete(key);
|
||||
return {
|
||||
@@ -756,16 +825,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记这次执行了 no-reply,下次需要恢复模型
|
||||
rec.needsRestore = true;
|
||||
sessionDecision.set(key, rec);
|
||||
|
||||
// 无论是否有缓存,只要 debug flag 开启就打印决策详情
|
||||
if (live.enableDebugLogs) {
|
||||
const prompt = ((event as Record<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 +843,7 @@ export default {
|
||||
}
|
||||
|
||||
api.logger.info(
|
||||
`whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||
`dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -789,7 +856,7 @@ export default {
|
||||
const key = ctx.sessionKey;
|
||||
if (!key) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
|
||||
let rec = sessionDecision.get(key);
|
||||
@@ -810,7 +877,7 @@ export default {
|
||||
rec = { decision, createdAt: Date.now() };
|
||||
if (shouldDebugLog(live, derived.channelId)) {
|
||||
api.logger.info(
|
||||
`whispergate: debug before_prompt_build recompute session=${key} ` +
|
||||
`dirigent: debug before_prompt_build recompute session=${key} ` +
|
||||
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
|
||||
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
|
||||
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||
@@ -826,7 +893,7 @@ export default {
|
||||
if (sessionInjected.has(key)) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(
|
||||
`whispergate: debug before_prompt_build session=${key} inject skipped (already injected)`,
|
||||
`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -835,7 +902,7 @@ export default {
|
||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||
if (shouldDebugLog(live, undefined)) {
|
||||
api.logger.info(
|
||||
`whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`,
|
||||
`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -846,26 +913,33 @@ export default {
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId);
|
||||
|
||||
// Inject agent identity for group chats
|
||||
// Inject agent identity for group chats (includes userId now)
|
||||
let identity = "";
|
||||
if (isGroupChat && ctx.agentId) {
|
||||
const idStr = buildAgentIdentity(api, ctx.agentId);
|
||||
if (idStr) identity = idStr + "\n\n";
|
||||
}
|
||||
|
||||
// Add scheduling identifier instruction for group chats
|
||||
let schedulingInstruction = "";
|
||||
if (isGroupChat) {
|
||||
schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId);
|
||||
}
|
||||
|
||||
// Mark session as injected (one-time injection)
|
||||
sessionInjected.add(key);
|
||||
|
||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||
return { prependContext: identity + instruction };
|
||||
api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||
return { prependContext: identity + instruction + schedulingInstruction };
|
||||
});
|
||||
|
||||
// Register slash commands for Discord
|
||||
api.registerCommand({
|
||||
name: "whispergate",
|
||||
description: "WhisperGate 频道策略管理",
|
||||
name: "dirigent",
|
||||
description: "Dirigent channel policy management",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const args = cmdCtx.args || "";
|
||||
@@ -873,11 +947,11 @@ export default {
|
||||
const subCmd = parts[0] || "help";
|
||||
|
||||
if (subCmd === "help") {
|
||||
return { text: `WhisperGate 命令:\n` +
|
||||
`/whispergate status - 显示当前频道状态\n` +
|
||||
`/whispergate turn-status - 显示轮流发言状态\n` +
|
||||
`/whispergate turn-advance - 手动推进轮流\n` +
|
||||
`/whispergate turn-reset - 重置轮流顺序` };
|
||||
return { text: `Dirigent commands:\n` +
|
||||
`/dirigent status - Show current channel status\n` +
|
||||
`/dirigent turn-status - Show turn-based speaking status\n` +
|
||||
`/dirigent turn-advance - Manually advance turn\n` +
|
||||
`/dirigent turn-reset - Reset turn order` };
|
||||
}
|
||||
|
||||
if (subCmd === "status") {
|
||||
@@ -886,65 +960,52 @@ export default {
|
||||
|
||||
if (subCmd === "turn-status") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-advance") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-reset") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
if (!channelId) return { text: "Cannot get channel ID", isError: true };
|
||||
resetTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
|
||||
return { text: `未知子命令: ${subCmd}`, isError: true };
|
||||
return { text: `Unknown subcommand: ${subCmd}`, isError: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Handle NO_REPLY detection before message write
|
||||
// This is where we detect if agent output is NO_REPLY and handle turn advancement
|
||||
// NOTE: This hook is synchronous, do not use async/await
|
||||
api.on("before_message_write", (event, ctx) => {
|
||||
try {
|
||||
// Debug: print all available keys in event and ctx
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
`dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
|
||||
);
|
||||
|
||||
// before_message_write ctx only has { agentId, sessionKey }.
|
||||
// Use session mappings populated during before_model_resolve for channelId/accountId.
|
||||
// Content comes from event.message (AgentMessage).
|
||||
let key = ctx.sessionKey;
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
// Get from session mapping (set in before_model_resolve)
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
// Extract content from event.message (AgentMessage)
|
||||
// Only process assistant messages — before_message_write fires for both
|
||||
// user (incoming) and assistant (outgoing) messages. Incoming messages may
|
||||
// contain end symbols from OTHER agents, which would incorrectly advance the turn.
|
||||
let content = "";
|
||||
const msg = (event as Record<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 +1014,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 +1043,76 @@ export default {
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
const wasNoReply = isEmpty || isNoReply;
|
||||
|
||||
// Log turn state for debugging
|
||||
const turnDebug = getTurnDebugInfo(channelId);
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
`dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`,
|
||||
);
|
||||
|
||||
// Check if this session was forced no-reply or allowed to speak
|
||||
const wasAllowed = sessionAllowed.get(key);
|
||||
|
||||
if (wasNoReply) {
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`,
|
||||
`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`,
|
||||
);
|
||||
|
||||
if (wasAllowed === undefined) return; // No record, skip
|
||||
if (wasAllowed === undefined) return;
|
||||
|
||||
if (wasAllowed === false) {
|
||||
// Forced no-reply - do not advance turn
|
||||
sessionAllowed.delete(key);
|
||||
api.logger.info(
|
||||
`whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
`dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn
|
||||
ensureTurnOrder(api, channelId, live);
|
||||
ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
`dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
|
||||
// If all agents NO_REPLY'd (dormant), don't trigger handoff
|
||||
if (!nextSpeaker) {
|
||||
if (shouldDebugLog(live, channelId)) {
|
||||
api.logger.info(
|
||||
`whispergate: before_message_write all agents no-reply, going dormant - no handoff`,
|
||||
`dirigent: before_message_write all agents no-reply, going dormant - no handoff`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger moderator handoff message (fire-and-forget, don't await)
|
||||
// Trigger moderator handoff message using scheduling identifier format
|
||||
if (live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`;
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
|
||||
api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
} else if (hasEndSymbol) {
|
||||
// End symbol detected — advance turn NOW (before message is broadcast to other agents)
|
||||
// This prevents the race condition where other agents receive the message
|
||||
// before message_sent fires and advances the turn.
|
||||
ensureTurnOrder(api, channelId, live);
|
||||
ensureTurnOrder(api, channelId);
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, false);
|
||||
sessionAllowed.delete(key);
|
||||
sessionTurnHandled.add(key);
|
||||
|
||||
api.logger.info(
|
||||
`whispergate: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
`dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||
);
|
||||
} else {
|
||||
api.logger.info(
|
||||
`whispergate: before_message_write no turn action needed session=${key} channel=${channelId}`,
|
||||
`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1074,22 +1123,17 @@ export default {
|
||||
const c = (ctx || {}) as Record<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 +1141,13 @@ export default {
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
// Debug log
|
||||
api.logger.info(
|
||||
`whispergate: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
||||
);
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies);
|
||||
|
||||
@@ -1119,7 +1162,7 @@ export default {
|
||||
if (key && sessionTurnHandled.has(key)) {
|
||||
sessionTurnHandled.delete(key);
|
||||
api.logger.info(
|
||||
`whispergate: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1128,22 +1171,22 @@ export default {
|
||||
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
||||
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
|
||||
api.logger.info(
|
||||
`whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
|
||||
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
|
||||
);
|
||||
// Moderator handoff: when current speaker NO_REPLY'd and there's a next speaker,
|
||||
// send a handoff message via the moderator bot to trigger the next agent
|
||||
// Moderator handoff using scheduling identifier format
|
||||
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
||||
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||
if (nextUserId) {
|
||||
const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`;
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
||||
sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||
} else {
|
||||
api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);
|
||||
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "whispergate",
|
||||
"name": "WhisperGate",
|
||||
"version": "0.1.0",
|
||||
"description": "Rule-based no-reply gate with provider/model override",
|
||||
"id": "dirigent",
|
||||
"name": "Dirigent",
|
||||
"version": "0.2.0",
|
||||
"description": "Rule-based no-reply gate with provider/model override and turn management",
|
||||
"entry": "./index.ts",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
@@ -13,13 +13,14 @@
|
||||
"listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" },
|
||||
"humanList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"agentList": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" },
|
||||
"channelPoliciesFile": { "type": "string", "default": "~/.openclaw/dirigent-channel-policies.json" },
|
||||
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
|
||||
"schedulingIdentifier": { "type": "string", "default": "➡️" },
|
||||
"noReplyProvider": { "type": "string" },
|
||||
"noReplyModel": { "type": "string" },
|
||||
"enableDiscordControlTool": { "type": "boolean", "default": true },
|
||||
"enableWhispergatePolicyTool": { "type": "boolean", "default": true },
|
||||
"enableDirigentPolicyTool": { "type": "boolean", "default": true },
|
||||
"discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" },
|
||||
"discordControlApiToken": { "type": "string" },
|
||||
"discordControlCallerId": { "type": "string" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
352
scripts/install-dirigent-openclaw.mjs
Executable file
352
scripts/install-dirigent-openclaw.mjs
Executable 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" "$@"
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user